mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
feat: add provider-executed MCP registration UI
This commit is contained in:
parent
18cd30435c
commit
e8fe7788f9
12 changed files with 914 additions and 146 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Provider-executed MCP registration
|
||||
|
||||
Status: registration and permission integration are approved and pushed through `f1bb8d8ddd`. Native request authorization is implemented and approved for commit and push
|
||||
Status: registration, permission integration, and native request authorization are approved and pushed through `18cd30435c`. The provider registration UI and generated API schema refresh are verified and approved for commit and push
|
||||
|
||||
## Implementation plan
|
||||
|
||||
|
|
@ -10,9 +10,9 @@ Work through these layers in order on `litellm_provider_mcp_registration`. Updat
|
|||
| --- | --- | --- |
|
||||
| 1 | Settle registration fields, values, storage, and validation | Approved |
|
||||
| 2 | Implement persistence and execution-mode isolation before enabling provider registration | Database, isolation, and admin API committed; YAML registration and provider input model reviewed and approved |
|
||||
| 3 | Management UI/API, stored tool options, and existing key/team/toolset selectors | Scoped retrieval, assignment, and stored tool selectors implemented and approved. Provider registration form remains separate |
|
||||
| 4 | Authorize effective OpenAI Responses and Anthropic Messages MCP attachments through existing MCP permissions | Implemented and approved |
|
||||
| 5 | Live provider tests, streaming admission tests, UI tests, and gateway regressions | Request admission and gateway regressions verified. Real OpenAI JSON/streaming passed. Real Anthropic calls blocked by invalid QA credential. Earlier browser QA remains incomplete |
|
||||
| 3 | Management UI/API, stored tool options, and existing key/team/toolset selectors | Scoped retrieval, assignment, and stored tool selectors approved and pushed. Provider create/edit/detail UI verified and approved |
|
||||
| 4 | Authorize effective OpenAI Responses and Anthropic Messages MCP attachments through existing MCP permissions | Approved and pushed in `18cd30435c` |
|
||||
| 5 | Live provider tests, streaming admission tests, UI tests, and gateway regressions | Request admission and gateway regressions verified. Real OpenAI JSON/streaming passed. Real Anthropic legacy/modern JSON and SSE calls, cache revocation, and token accounting passed with the updated QA credential. Registration browser CRUD, empty-tool persistence, and config read-only QA passed without MCP discovery or execution |
|
||||
|
||||
Request authorization enforces native attachments before provider dispatch using the completed permission integration. Preserve the current sources of truth and lookup paths. Object permissions remain database-backed and use the existing auth caches. Key assignment keeps its database lookup and gains recognition of provider-only YAML records in its config fallback. Scoped lists keep their existing permission calculation and hydrate authorized provider IDs from the already-loaded registration inventory. Provider tool selectors use stored allowed tools and descriptions. Gateway lookup, discovery, and execution retain their existing paths
|
||||
|
||||
|
|
@ -24,6 +24,48 @@ Chat Completions entry routes, response/SSE inspection, per-invocation argument
|
|||
|
||||
The registry design review is deferred by agreement on 2026-11-02. This batch retains the executable registry and separate provider registration inventory. A later revision may publish both modes through one registry and derive executable views at gateway boundaries. No registry consolidation is included in this approval
|
||||
|
||||
## Registration UI review
|
||||
|
||||
The existing Add New MCP Server flow offers LiteLLM-executed and Provider-executed registration. Provider mode mounts a separate compact form shared by create and edit. It collects name, optional alias/description, HTTPS URL, exact allowed tool names, access groups, and allow-all-keys. The payload contains no transport or credential settings. Create sends execution mode; update omits it because mode is immutable
|
||||
|
||||
Provider cards show execution mode and allowed-tool count. Details show stored tools without mounting the gateway tool viewer. Connection tests, stored credentials, and gateway cost settings are hidden for provider registrations. YAML registrations retain their read-only settings. Empty allowed-tools lists retain the enforced deny-all policy
|
||||
|
||||
Validation passed 302 focused UI tests, TypeScript, and the production build. Changed-file ESLint has no errors. A live browser against the proxy and a disposable PostgreSQL database verified create, stored-tool detail, edit to an empty list, reload, read-only YAML settings, and deletion. Browser traffic contained no MCP REST discovery/execution requests, and the provider endpoint recorded zero outbound connections. Before/after screenshots and the live harness are saved under the local registration UI review evidence directory
|
||||
|
||||
The UI reuses the existing registration endpoints and shared MCP entity. This batch adds no backend routes, permission rules, database changes, or catalog changes. Public docs remain separate
|
||||
|
||||
The UI is approved, and the remaining v1 work is final release review. Live Anthropic verification passed with the updated QA credential; see the follow-up below. The earlier OpenAI native MCP JSON and streaming checks passed. Chat Completions, response/SSE inspection, usage/billing changes, and registry consolidation remain outside this batch
|
||||
|
||||
## Full-stack verification
|
||||
|
||||
The 2026-11-02 admin sequence passed 22 live checks using the public OpenAI documentation MCP and `gpt-6-luna`. Both execution modes were registered through the UI for the same HTTPS endpoint. Scoped MCP protocol and REST calls returned real documentation. Gateway Responses execution sent function tools and returned tool output in a second provider request. Provider-native JSON and streaming calls executed successfully with one provider request each after registration and permission assignment
|
||||
|
||||
Before provider registration, native URL requests rejected even for the admin. Missing server grants, narrower tool permissions, unregistered tools, changed URLs, unbounded scopes, and enforced empty lists rejected with zero provider requests. Provider registrations remained excluded from gateway REST execution, and gateway protocol calls still worked after both modes were registered
|
||||
|
||||
Messages denial checks passed without provider dispatch. Direct Anthropic happy-path execution remains unverified because the designated API key returns 401. The authorized QA vault service account is deleted. The user's AWS profile is valid, but a standard Bedrock call is not equivalent proof of Anthropic MCP connector execution. The live harness, wire evidence, screenshots, and report are in the local `mcp-gap-matrix/full-stack-review` directory. No application changes were required by this run
|
||||
|
||||
## Live risk and adversarial review
|
||||
|
||||
The 2026-11-02 review covered the cumulative stack plus uncommitted UI against merge base `a392a39627`. Five independent lenses completed. One unintended issue was confirmed: the lazy OpenAPI snapshot and dashboard API types omitted the new mode/nullable transport contract. Both were regenerated through the documented commands; repeat generation was deterministic, TypeScript passed, and an independent scoped re-review found no unrelated schema changes
|
||||
|
||||
The live A/B rig used two proxy processes with two workers each per side, real PostgreSQL with the catalog revision trigger, shared Redis per side, a forwarding recorder, real OpenAI, and the public documentation MCP. Gateway calls, agentic continuation, ordinary JSON/streaming inference, Messages adaptation, fallback, and key lifecycle retained their behavior. Provider-native allowed JSON/streaming calls executed; missing grants, unknown endpoints, registration/tool revocation and deletion rejected with zero provider requests, including after a shared response-cache hit
|
||||
|
||||
Supplemental head and merged-current-main checks verified late callback URL mutation is blocked and key-tool revocation overrides the cache across both proxy processes. Identity-free SDK native calls still execute. Native success, cache hits, and denied requests produced the expected nonzero/zero spend records. Current main `a2d27881e8` merged cleanly in an isolated tree; the original branch was not merged or committed
|
||||
|
||||
Two compatibility changes remain explicit: native URL attachments require provider registration and bounded grants, and MCP updates must omit `execution_mode`, including a same-value field copied from GET. The latter implements the existing v1 decision; no update-contract relaxation was made. A malformed mixed-config reload concern was classified as optional hardening after the base showed the same non-atomic update behavior
|
||||
|
||||
Direct Anthropic native execution remains unverified. AWS CLI with `aws-dev` successfully ran a Claude control, but the tested Bedrock native request rejected `mcp_toolset`; this does not replace direct Anthropic connector proof. More unusual adapter/continuation/retry and long-lived config-reload paths remain individually listed in the local risk report. No blanket release-safety or full-CI claim is made. Evidence: `mcp-gap-matrix/risk-review-20261102/REPORT.md`
|
||||
|
||||
## Live Anthropic follow-up, passed
|
||||
|
||||
The updated designated QA key enabled a real Claude Haiku 5.5 base-versus-head run. Both legacy connector and modern MCP toolset formats executed the public documentation MCP successfully in JSON and SSE. The rig used real PostgreSQL, the catalog revision trigger, shared Redis, and two proxy processes with two workers each per side
|
||||
|
||||
All 47 HTTP checks passed. Registered finite tools with key/team grants executed; unregistered URLs, missing grants, forbidden tools, changed URLs and unbounded scopes rejected before provider dispatch. Registration and key-tool revocation each blocked cached responses across both process ports. The branch recorded 17 native request denials with zero provider calls. Gateway automatic MCP execution also passed on base and head with `require_approval=never`
|
||||
|
||||
All eight native JSON/SSE executions had identical final token totals between the recorded upstream response, client response and spend log. Successful calls recorded nonzero cost; the two cache hits and 17 denied calls recorded zero cost. The corresponding outbound request bodies matched base/head exactly. This is ordinary token-accounting proof, not comprehensive provider-specific tool-fee attribution
|
||||
|
||||
The earlier sections describing a blocked Anthropic key record the state before this follow-up; that happy-path/cache-revocation gap is now closed. These calls used direct Anthropic, not Bedrock. Advanced retry/adapter paths remain separately bounded, and this was not another full gauntlet-mini run. No product-code fix was needed. Evidence is in `mcp-gap-matrix/risk-review-20261102/anthropic-followup/`
|
||||
|
||||
## Request authorization review
|
||||
|
||||
This batch adds first-class request authorization. It changes no registration schema, permission-combination policy, database lookup owner, or management inventory design. The admission logic lives in `litellm/proxy/mcp_server/auth/provider_mcp.py` and calls the existing server and tool permission resolvers
|
||||
|
|
|
|||
|
|
@ -36659,6 +36659,14 @@
|
|||
"title": "MCPEnvVarScope",
|
||||
"type": "string"
|
||||
},
|
||||
"MCPExecutionMode": {
|
||||
"enum": [
|
||||
"litellm",
|
||||
"provider"
|
||||
],
|
||||
"title": "MCPExecutionMode",
|
||||
"type": "string"
|
||||
},
|
||||
"NewMCPServerRequest": {
|
||||
"properties": {
|
||||
"alias": {
|
||||
|
|
@ -36849,6 +36857,10 @@
|
|||
],
|
||||
"title": "Env Vars"
|
||||
},
|
||||
"execution_mode": {
|
||||
"$ref": "#/components/schemas/MCPExecutionMode",
|
||||
"default": "litellm"
|
||||
},
|
||||
"extra_headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -37135,14 +37147,21 @@
|
|||
"title": "Tool Name To Display Name"
|
||||
},
|
||||
"transport": {
|
||||
"default": "sse",
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transport",
|
||||
"type": "string"
|
||||
"default": "sse",
|
||||
"title": "Transport"
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [
|
||||
|
|
@ -38677,6 +38696,10 @@
|
|||
],
|
||||
"title": "Env Vars"
|
||||
},
|
||||
"execution_mode": {
|
||||
"$ref": "#/components/schemas/MCPExecutionMode",
|
||||
"default": "litellm"
|
||||
},
|
||||
"extra_headers": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
|
|
@ -39059,13 +39082,20 @@
|
|||
"title": "Tool Name To Display Name"
|
||||
},
|
||||
"transport": {
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transport",
|
||||
"type": "string"
|
||||
"title": "Transport"
|
||||
},
|
||||
"updated_at": {
|
||||
"anyOf": [
|
||||
|
|
@ -39487,6 +39517,14 @@
|
|||
"title": "MCPEnvVarScope",
|
||||
"type": "string"
|
||||
},
|
||||
"MCPExecutionMode": {
|
||||
"enum": [
|
||||
"litellm",
|
||||
"provider"
|
||||
],
|
||||
"title": "MCPExecutionMode",
|
||||
"type": "string"
|
||||
},
|
||||
"NewMCPServerRequest": {
|
||||
"properties": {
|
||||
"alias": {
|
||||
|
|
@ -39677,6 +39715,10 @@
|
|||
],
|
||||
"title": "Env Vars"
|
||||
},
|
||||
"execution_mode": {
|
||||
"$ref": "#/components/schemas/MCPExecutionMode",
|
||||
"default": "litellm"
|
||||
},
|
||||
"extra_headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -39963,14 +40005,21 @@
|
|||
"title": "Tool Name To Display Name"
|
||||
},
|
||||
"transport": {
|
||||
"default": "sse",
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transport",
|
||||
"type": "string"
|
||||
"default": "sse",
|
||||
"title": "Transport"
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [
|
||||
|
|
@ -41876,6 +41925,10 @@
|
|||
],
|
||||
"title": "Env Vars"
|
||||
},
|
||||
"execution_mode": {
|
||||
"$ref": "#/components/schemas/MCPExecutionMode",
|
||||
"default": "litellm"
|
||||
},
|
||||
"extra_headers": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
|
|
@ -42258,13 +42311,20 @@
|
|||
"title": "Tool Name To Display Name"
|
||||
},
|
||||
"transport": {
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transport",
|
||||
"type": "string"
|
||||
"title": "Transport"
|
||||
},
|
||||
"updated_at": {
|
||||
"anyOf": [
|
||||
|
|
@ -42903,6 +42963,14 @@
|
|||
"title": "MCPEnvVarScope",
|
||||
"type": "string"
|
||||
},
|
||||
"MCPExecutionMode": {
|
||||
"enum": [
|
||||
"litellm",
|
||||
"provider"
|
||||
],
|
||||
"title": "MCPExecutionMode",
|
||||
"type": "string"
|
||||
},
|
||||
"MCPGatewaySession": {
|
||||
"description": "One live stateful Streamable HTTP session held by this proxy worker.",
|
||||
"properties": {
|
||||
|
|
@ -43720,6 +43788,10 @@
|
|||
],
|
||||
"title": "Env Vars"
|
||||
},
|
||||
"execution_mode": {
|
||||
"$ref": "#/components/schemas/MCPExecutionMode",
|
||||
"default": "litellm"
|
||||
},
|
||||
"extra_headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -44006,14 +44078,21 @@
|
|||
"title": "Tool Name To Display Name"
|
||||
},
|
||||
"transport": {
|
||||
"default": "sse",
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transport",
|
||||
"type": "string"
|
||||
"default": "sse",
|
||||
"title": "Transport"
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [
|
||||
|
|
@ -44529,14 +44608,21 @@
|
|||
"title": "Tool Name To Display Name"
|
||||
},
|
||||
"transport": {
|
||||
"default": "sse",
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transport",
|
||||
"type": "string"
|
||||
"default": "sse",
|
||||
"title": "Transport"
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [
|
||||
|
|
@ -46994,6 +47080,14 @@
|
|||
"title": "MCPEnvVarScope",
|
||||
"type": "string"
|
||||
},
|
||||
"MCPExecutionMode": {
|
||||
"enum": [
|
||||
"litellm",
|
||||
"provider"
|
||||
],
|
||||
"title": "MCPExecutionMode",
|
||||
"type": "string"
|
||||
},
|
||||
"NewMCPServerRequest": {
|
||||
"properties": {
|
||||
"alias": {
|
||||
|
|
@ -47184,6 +47278,10 @@
|
|||
],
|
||||
"title": "Env Vars"
|
||||
},
|
||||
"execution_mode": {
|
||||
"$ref": "#/components/schemas/MCPExecutionMode",
|
||||
"default": "litellm"
|
||||
},
|
||||
"extra_headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -47470,14 +47568,21 @@
|
|||
"title": "Tool Name To Display Name"
|
||||
},
|
||||
"transport": {
|
||||
"default": "sse",
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"sse",
|
||||
"http",
|
||||
"stdio"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transport",
|
||||
"type": "string"
|
||||
"default": "sse",
|
||||
"title": "Transport"
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import { StdioDisabledBanner, TransportSelectItems } from "./StdioAvailability";
|
|||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection";
|
||||
import MCPLogoSelector from "./MCPLogoSelector";
|
||||
import ProviderMCPRegistrationForm from "./ProviderMCPRegistrationForm";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import EnvVarsSection from "./EnvVarsSection";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
|
||||
|
|
@ -84,6 +86,7 @@ interface CreateMCPServerProps {
|
|||
prefillData?: DiscoverableMCPServer | null;
|
||||
onBackToDiscovery?: () => void;
|
||||
stdioEnabled?: boolean;
|
||||
executionModeSelector?: React.ReactNode;
|
||||
}
|
||||
|
||||
const payloadErrorMessage = (result: Exclude<BuildCreatePayloadResult, { kind: "ok" }>): string => {
|
||||
|
|
@ -104,7 +107,7 @@ const CREATE_DEFAULTS: MountedFormValues = {
|
|||
oauth_passthrough: false,
|
||||
};
|
||||
|
||||
const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
const GatewayCreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
userID,
|
||||
userRole,
|
||||
accessToken,
|
||||
|
|
@ -116,6 +119,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
prefillData,
|
||||
onBackToDiscovery,
|
||||
stdioEnabled = true,
|
||||
executionModeSelector,
|
||||
}) => {
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues: CREATE_DEFAULTS });
|
||||
const registry = useMountRegistry();
|
||||
|
|
@ -647,6 +651,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{executionModeSelector}
|
||||
<div className="mt-6">
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
|
|
@ -1016,4 +1021,63 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const CreateMCPServer: React.FC<CreateMCPServerProps> = (props) => {
|
||||
const [executionMode, setExecutionMode] = useState<"litellm" | "provider">("litellm");
|
||||
const selectorId = React.useId();
|
||||
const { setModalVisible } = props;
|
||||
const setVisible = React.useCallback(
|
||||
(visible: boolean) => {
|
||||
if (!visible) setExecutionMode("litellm");
|
||||
setModalVisible(visible);
|
||||
},
|
||||
[setModalVisible],
|
||||
);
|
||||
const executionModeSelector = isAdminRole(props.userRole) ? (
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label htmlFor={selectorId}>Execution mode</Label>
|
||||
<Select
|
||||
value={executionMode}
|
||||
onValueChange={(value) => setExecutionMode(value === "provider" ? "provider" : "litellm")}
|
||||
items={[
|
||||
{ value: "litellm", label: "LiteLLM-executed" },
|
||||
{ value: "provider", label: "Provider-executed" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger id={selectorId} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="litellm">LiteLLM-executed</SelectItem>
|
||||
<SelectItem value="provider">Provider-executed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null;
|
||||
if (executionMode !== "provider" || !isAdminRole(props.userRole)) {
|
||||
return (
|
||||
<GatewayCreateMCPServer {...props} setModalVisible={setVisible} executionModeSelector={executionModeSelector} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dialog open={props.isModalVisible} onOpenChange={setVisible}>
|
||||
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New MCP Server</DialogTitle>
|
||||
</DialogHeader>
|
||||
{executionModeSelector}
|
||||
<ProviderMCPRegistrationForm
|
||||
accessToken={props.accessToken}
|
||||
availableAccessGroups={props.availableAccessGroups}
|
||||
existingServers={props.existingServers}
|
||||
onCancel={() => setVisible(false)}
|
||||
onSuccess={(server) => {
|
||||
setVisible(false);
|
||||
props.onCreateSuccess(server);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateMCPServer;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
onDelete,
|
||||
stdioEnabled = true,
|
||||
}) => {
|
||||
const isProvider = server.execution_mode === "provider";
|
||||
const recheckHealth = isProvider ? undefined : onRecheckHealth;
|
||||
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`.
|
||||
|
|
@ -113,7 +115,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const hasMenu = !!onRecheckHealth || !!onDelete;
|
||||
const hasMenu = !!recheckHealth || !!onDelete;
|
||||
|
||||
// Card uses role="button" + nested <button> children (Set, BYOK Connect, the
|
||||
// recheck-health Badge), so a real <button> wrapper would produce invalid
|
||||
|
|
@ -170,19 +172,19 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
{onRecheckHealth && (
|
||||
{recheckHealth && (
|
||||
<DropdownMenuItem
|
||||
disabled={isRechecking}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onRecheckHealth();
|
||||
recheckHealth();
|
||||
}}
|
||||
>
|
||||
<Zap />
|
||||
Test Connection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRecheckHealth && onDelete && <DropdownMenuSeparator />}
|
||||
{recheckHealth && onDelete && <DropdownMenuSeparator />}
|
||||
{onDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
@ -212,17 +214,26 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<HealthChip
|
||||
status={status}
|
||||
isLoadingHealth={isLoadingHealth}
|
||||
isRechecking={isRechecking}
|
||||
onRecheck={onRecheckHealth}
|
||||
lastCheck={server.last_health_check}
|
||||
error={server.health_check_error}
|
||||
dotClass={healthTone.dot}
|
||||
/>
|
||||
<Badge variant="outline">{displayTransport.toUpperCase()}</Badge>
|
||||
<Badge variant="outline">{authType}</Badge>
|
||||
{isProvider ? (
|
||||
<>
|
||||
<Badge variant="outline">Provider-executed</Badge>
|
||||
<Badge variant="outline">{server.allowed_tools?.length ?? 0} allowed tools</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HealthChip
|
||||
status={status}
|
||||
isLoadingHealth={isLoadingHealth}
|
||||
isRechecking={isRechecking}
|
||||
onRecheck={recheckHealth}
|
||||
lastCheck={server.last_health_check}
|
||||
error={server.health_check_error}
|
||||
dotClass={healthTone.dot}
|
||||
/>
|
||||
<Badge variant="outline">{displayTransport.toUpperCase()}</Badge>
|
||||
<Badge variant="outline">{authType}</Badge>
|
||||
</>
|
||||
)}
|
||||
{transport === "stdio" && !stdioEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -236,7 +247,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
<TooltipContent>{STDIO_DISABLED_MESSAGE}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{oauthFlowUnset && (
|
||||
{!isProvider && oauthFlowUnset && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
|
@ -252,17 +263,19 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge variant="outline">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", networkAccess.dotClassName)} />
|
||||
{networkAccess.label}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{networkAccess.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isProvider && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge variant="outline">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", networkAccess.dotClassName)} />
|
||||
{networkAccess.label}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{networkAccess.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{accessGroups.slice(0, 2).map((g) => (
|
||||
<Tooltip key={g}>
|
||||
<TooltipTrigger
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import * as networking from "@/components/networking";
|
||||
import CreateMCPServer from "./CreateMCPServer";
|
||||
import MCPServerEdit from "./mcp_server_edit";
|
||||
import { MCPServerView } from "./mcp_server_view";
|
||||
import MCPServerCard from "./MCPServerCard";
|
||||
import { selectOption } from "./testUtils";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
createMCPServer: vi.fn(),
|
||||
updateMCPServer: vi.fn(),
|
||||
fetchOpenAPIRegistry: vi.fn().mockResolvedValue({ apis: [] }),
|
||||
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
getMCPOAuthUserCredentialStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const server: MCPServer = {
|
||||
server_id: "provider-docs",
|
||||
execution_mode: "provider",
|
||||
server_name: "docs_native",
|
||||
url: "https://docs.example.com/mcp",
|
||||
allowed_tools: ["search_docs", "fetch_doc"],
|
||||
mcp_info: { tool_allowlist_enforced: true },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_by: "admin",
|
||||
};
|
||||
|
||||
const props = {
|
||||
accessToken: "test-token",
|
||||
userRole: "Admin",
|
||||
userID: "admin",
|
||||
availableAccessGroups: ["engineering"],
|
||||
};
|
||||
|
||||
function renderView(isConfig = false) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MCPServerView
|
||||
{...props}
|
||||
mcpServer={{ ...server, is_config: isConfig }}
|
||||
isProxyAdmin
|
||||
isEditing={false}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("provider registration UI", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue(server);
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue({ ...server, allowed_tools: [] });
|
||||
});
|
||||
|
||||
it("switches from a gateway draft to provider registration without sending connection settings", async () => {
|
||||
const onCreateSuccess = vi.fn();
|
||||
render(<CreateMCPServer {...props} isModalVisible setModalVisible={vi.fn()} onCreateSuccess={onCreateSuccess} />);
|
||||
await selectOption("Transport Type", "Streamable HTTP");
|
||||
await selectOption("Authentication", "API Key");
|
||||
fireEvent.change(screen.getByLabelText("Authentication Value"), { target: { value: "draft-credential" } });
|
||||
await selectOption("Execution mode", "Provider-executed");
|
||||
fireEvent.change(screen.getByLabelText("MCP Server Name"), { target: { value: "docs_native" } });
|
||||
fireEvent.change(screen.getByLabelText("MCP Server URL"), { target: { value: server.url } });
|
||||
fireEvent.change(screen.getByLabelText("Allowed tools"), { target: { value: "search_docs\nfetch_doc" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" }));
|
||||
await waitFor(() => expect(onCreateSuccess).toHaveBeenCalledWith(server));
|
||||
expect(networking.createMCPServer).toHaveBeenCalledExactlyOnceWith("test-token", {
|
||||
execution_mode: "provider",
|
||||
server_name: "docs_native",
|
||||
alias: null,
|
||||
description: null,
|
||||
url: server.url,
|
||||
allowed_tools: ["search_docs", "fetch_doc"],
|
||||
mcp_access_groups: [],
|
||||
allow_all_keys: false,
|
||||
mcp_info: { tool_allowlist_enforced: true },
|
||||
});
|
||||
expect(networking.testMCPToolsListRequest).not.toHaveBeenCalled();
|
||||
expect(screen.queryByLabelText("Authentication Value")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits stored tools and can deny all without discovery", async () => {
|
||||
const onSuccess = vi.fn();
|
||||
render(<MCPServerEdit {...props} mcpServer={server} onCancel={vi.fn()} onSuccess={onSuccess} />);
|
||||
expect(screen.getByLabelText("Allowed tools")).toHaveValue("search_docs\nfetch_doc");
|
||||
fireEvent.change(screen.getByLabelText("Allowed tools"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ allowed_tools: [] })));
|
||||
expect(networking.updateMCPServer).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.objectContaining({
|
||||
server_id: server.server_id,
|
||||
allowed_tools: [],
|
||||
mcp_info: { tool_allowlist_enforced: true },
|
||||
}),
|
||||
);
|
||||
expect(networking.listMCPTools).not.toHaveBeenCalled();
|
||||
expect(networking.testMCPToolsListRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("displays stored tools without fetching live tools or credentials", async () => {
|
||||
renderView();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Allowed tools" }));
|
||||
expect(await screen.findByRole("cell", { name: "search_docs" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /User Credentials/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Test Connection/i })).not.toBeInTheDocument();
|
||||
expect(networking.listMCPTools).not.toHaveBeenCalled();
|
||||
expect(networking.getMCPOAuthUserCredentialStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps config registrations read-only", () => {
|
||||
renderView(true);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeDisabled();
|
||||
expect(screen.getByText("Defined in config. Edit your YAML configuration to make changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps deletion available but hides gateway connection actions on provider cards", async () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<MCPServerCard server={server} onClick={vi.fn()} onRecheckHealth={vi.fn()} onDelete={onDelete} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Server actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Test Connection" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
expect(screen.getByText("2 allowed tools")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { useId } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import { createMCPServer, updateMCPServer } from "@/components/networking";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { DUPLICATE_IDENTIFIER_MESSAGE, findDuplicateMcpServer, mcpSubmitErrorReason } from "./duplicateServerCheck";
|
||||
import {
|
||||
buildProviderRegistrationPayload,
|
||||
providerRegistrationDefaults,
|
||||
providerRegistrationSchema,
|
||||
type ProviderRegistrationValues,
|
||||
} from "./providerRegistrationForm";
|
||||
|
||||
interface ProviderMCPRegistrationFormProps {
|
||||
readonly mcpServer?: MCPServer;
|
||||
readonly accessToken: string | null;
|
||||
readonly availableAccessGroups: string[];
|
||||
readonly existingServers?: MCPServer[];
|
||||
readonly onCancel: () => void;
|
||||
readonly onSuccess: (server: MCPServer) => void;
|
||||
}
|
||||
|
||||
export default function ProviderMCPRegistrationForm({
|
||||
mcpServer,
|
||||
accessToken,
|
||||
availableAccessGroups,
|
||||
existingServers,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: ProviderMCPRegistrationFormProps) {
|
||||
const id = useId();
|
||||
const form = useForm<ProviderRegistrationValues>({
|
||||
resolver: zodResolver(providerRegistrationSchema),
|
||||
defaultValues: providerRegistrationDefaults(mcpServer),
|
||||
});
|
||||
const { errors, isSubmitting } = form.formState;
|
||||
const submitLabel = mcpServer ? "Save Changes" : "Add MCP Server";
|
||||
const save = async (values: ProviderRegistrationValues) => {
|
||||
if (!accessToken || mcpServer?.is_config) return;
|
||||
const duplicate = findDuplicateMcpServer(existingServers, values.server_name, values.alias, mcpServer?.server_id);
|
||||
if (duplicate) {
|
||||
form.setError(duplicate.field, { message: DUPLICATE_IDENTIFIER_MESSAGE });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = buildProviderRegistrationPayload(values, mcpServer);
|
||||
const server = mcpServer
|
||||
? await updateMCPServer(accessToken, payload)
|
||||
: await createMCPServer(accessToken, payload);
|
||||
toast.success(mcpServer ? "MCP server updated" : "MCP server registered");
|
||||
onSuccess(server);
|
||||
} catch (error) {
|
||||
form.setError("root", { message: mcpSubmitErrorReason(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(save)} className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The LLM provider discovers and calls this MCP server. LiteLLM checks server and tool permissions without
|
||||
connecting to it. Supply authentication in your provider request.
|
||||
</p>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.server_name}>
|
||||
<FieldLabel htmlFor={`${id}-name`}>MCP Server Name</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
{...form.register("server_name")}
|
||||
aria-invalid={!!errors.server_name}
|
||||
placeholder="docs_native"
|
||||
/>
|
||||
<FieldError errors={[errors.server_name]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.alias}>
|
||||
<FieldLabel htmlFor={`${id}-alias`}>Alias (optional)</FieldLabel>
|
||||
<Input id={`${id}-alias`} {...form.register("alias")} aria-invalid={!!errors.alias} placeholder="docs" />
|
||||
<FieldError errors={[errors.alias]} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={!!errors.url}>
|
||||
<FieldLabel htmlFor={`${id}-url`}>MCP Server URL</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-url`}
|
||||
{...form.register("url")}
|
||||
aria-invalid={!!errors.url}
|
||||
placeholder="https://docs.example.com/mcp"
|
||||
/>
|
||||
<FieldDescription>Use the same HTTPS endpoint in your provider request.</FieldDescription>
|
||||
<FieldError errors={[errors.url]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-description`}>Description (optional)</FieldLabel>
|
||||
<Input id={`${id}-description`} {...form.register("description")} placeholder="Search and read documentation" />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.tools_text}>
|
||||
<FieldLabel htmlFor={`${id}-tools`}>Allowed tools</FieldLabel>
|
||||
<Textarea
|
||||
id={`${id}-tools`}
|
||||
{...form.register("tools_text")}
|
||||
aria-invalid={!!errors.tools_text}
|
||||
aria-describedby={`${id}-tools-help`}
|
||||
placeholder={"search_docs\nfetch_doc"}
|
||||
rows={4}
|
||||
className="font-mono"
|
||||
/>
|
||||
<FieldDescription id={`${id}-tools-help`}>
|
||||
Enter one exact tool name per line. An empty list blocks all tool calls. LiteLLM does not discover this list.
|
||||
</FieldDescription>
|
||||
<FieldError errors={[errors.tools_text]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-groups`}>Access groups</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="mcp_access_groups"
|
||||
render={({ field }) => (
|
||||
<MultiSelect
|
||||
id={`${id}-groups`}
|
||||
options={availableAccessGroups.map((group) => ({ label: group, value: group }))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
allowCustomValues
|
||||
placeholder="Select or create access groups"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="allow_all_keys"
|
||||
render={({ field }) => (
|
||||
<Switch id={`${id}-all-keys`} checked={field.value} onCheckedChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<FieldLabel htmlFor={`${id}-all-keys`}>Allow All LiteLLM Keys</FieldLabel>
|
||||
<FieldDescription>Otherwise, grant access through key, team, or toolset permissions.</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
<FieldError errors={[errors.root]} />
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !accessToken || mcpServer?.is_config} aria-busy={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export default function ProviderMCPTools({ server }: { readonly server: MCPServer }) {
|
||||
const tools = server.allowed_tools ?? [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These allowed tools are stored in the registration. The LLM provider discovers and executes them.
|
||||
</p>
|
||||
{tools.length === 0 ? (
|
||||
<p className="text-sm">No tools allowed</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tool name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tools.map((name) => (
|
||||
<TableRow key={name}>
|
||||
<TableCell className="font-mono">{name}</TableCell>
|
||||
<TableCell>{server.tool_name_to_description?.[name] || "No description"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ import TokenExchangeFormFields from "./TokenExchangeFormFields";
|
|||
import IdJagFormFields from "./IdJagFormFields";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
import MCPLogoSelector from "./MCPLogoSelector";
|
||||
import ProviderMCPRegistrationForm from "./ProviderMCPRegistrationForm";
|
||||
import EnvVarsSection from "./EnvVarsSection";
|
||||
import { validateMCPServerUrl, validateMCPServerName, normalizeToolOverrideMap } from "./utils";
|
||||
import { AUTH_TYPES_REQUIRING_AUTH_VALUE } from "./createServerPayload";
|
||||
|
|
@ -97,7 +98,7 @@ interface MCPServerEditProps {
|
|||
|
||||
export const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
|
||||
|
||||
const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
||||
const GatewayMCPServerEdit: React.FC<MCPServerEditProps> = ({
|
||||
mcpServer,
|
||||
accessToken,
|
||||
userID,
|
||||
|
|
@ -1378,4 +1379,11 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const MCPServerEdit: React.FC<MCPServerEditProps> = (props) => {
|
||||
if (props.mcpServer.execution_mode === "provider") {
|
||||
return <ProviderMCPRegistrationForm {...props} />;
|
||||
}
|
||||
return <GatewayMCPServerEdit {...props} />;
|
||||
};
|
||||
|
||||
export default MCPServerEdit;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { MCPServerUserCredentialsPanel } from "./MCPServerUserCredentialsPanel";
|
|||
import { getSecureItem } from "@/utils/secureStorage";
|
||||
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
|
||||
import MCPServerCostDisplay from "./mcp_server_cost_display";
|
||||
import ProviderMCPTools from "./ProviderMCPTools";
|
||||
import { StdioDisabledBanner } from "./StdioAvailability";
|
||||
import { getMaskedAndFullUrl, getMCPNetworkAccess } from "./utils";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
|
|
@ -70,6 +71,7 @@ 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 queryClient = useQueryClient();
|
||||
const isProvider = mcpServer.execution_mode === "provider";
|
||||
const canEdit = isProxyAdmin && !isViewOnly && !mcpServer.is_config;
|
||||
const returningFromEditOAuth = isReturningFromEditOAuth(canEdit, mcpServer.server_id);
|
||||
const [editing, setEditing] = useState(isEditing || returningFromEditOAuth);
|
||||
|
|
@ -79,7 +81,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
const [selectedTabIndex, setSelectedTabIndex] = useState(returningFromEditOAuth ? 2 : initialTabIndex);
|
||||
const editFormShowsStdioBanner = selectedTabIndex === 2 && editing && canEdit;
|
||||
const showStdioBanner = mcpServer.transport === TRANSPORT.STDIO && !stdioEnabled && !editFormShowsStdioBanner;
|
||||
const canViewUserCredentials = userRole !== null && isProxyAdminTierRole(userRole);
|
||||
const canViewUserCredentials = !isProvider && userRole !== null && isProxyAdminTierRole(userRole);
|
||||
const canRevokeUserCredentials = userRole !== null && isProxyAdminRole(userRole) && !isViewOnly;
|
||||
|
||||
const handleSuccess = (updated: MCPServer) => {
|
||||
|
|
@ -121,6 +123,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</h1>
|
||||
{isProvider && <Badge variant="outline">Provider-executed</Badge>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
|
@ -157,7 +160,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="1" className="flex-none rounded-none px-4 py-2">
|
||||
MCP Tools
|
||||
{isProvider ? "Allowed tools" : "MCP Tools"}
|
||||
</TabsTrigger>
|
||||
{isProxyAdmin && (
|
||||
<TabsTrigger value="2" className="flex-none rounded-none px-4 py-2">
|
||||
|
|
@ -175,15 +178,27 @@ 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">
|
||||
{isProvider ? "Execution mode" : "Transport"}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
{getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))}
|
||||
{isProvider ? (
|
||||
<Badge variant="outline">Provider-executed</Badge>
|
||||
) : (
|
||||
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>
|
||||
<div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div>
|
||||
<div className="mt-3">
|
||||
{isProvider ? (
|
||||
<p className="text-sm">Supplied in the provider request</p>
|
||||
) : (
|
||||
getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
|
|
@ -210,29 +225,35 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mt-4 p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Cost Configuration</p>
|
||||
<div className="mt-3">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</Card>
|
||||
{!isProvider && (
|
||||
<Card className="mt-4 p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Cost Configuration</p>
|
||||
<div className="mt-3">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tool Panel */}
|
||||
<TabsContent value="1" keepMounted>
|
||||
<MCPToolsViewer
|
||||
serverId={mcpServer.server_id}
|
||||
accessToken={accessToken}
|
||||
auth_type={mcpServer.auth_type}
|
||||
oauth2_flow={mcpServer.oauth2_flow}
|
||||
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
|
||||
dcr_bridge={mcpServer.dcr_bridge}
|
||||
tokenUrl={mcpServer.token_url}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
serverAlias={mcpServer.alias}
|
||||
extraHeaders={mcpServer.extra_headers}
|
||||
/>
|
||||
{isProvider ? (
|
||||
<ProviderMCPTools server={mcpServer} />
|
||||
) : (
|
||||
<MCPToolsViewer
|
||||
serverId={mcpServer.server_id}
|
||||
accessToken={accessToken}
|
||||
auth_type={mcpServer.auth_type}
|
||||
oauth2_flow={mcpServer.oauth2_flow}
|
||||
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
|
||||
dcr_bridge={mcpServer.dcr_bridge}
|
||||
tokenUrl={mcpServer.token_url}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
serverAlias={mcpServer.alias}
|
||||
extraHeaders={mcpServer.extra_headers}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Settings Panel */}
|
||||
|
|
@ -299,25 +320,33 @@ 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>
|
||||
<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>
|
||||
<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>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
|
||||
mcpServer.extra_headers.join(", ")
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Execution mode</p>
|
||||
<div className="col-span-2 text-sm">{isProvider ? "Provider-executed" : "LiteLLM-executed"}</div>
|
||||
</div>
|
||||
{!isProvider && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">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>
|
||||
<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>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
|
||||
mcpServer.extra_headers.join(", ")
|
||||
) : (
|
||||
<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">Allow All Keys</p>
|
||||
<div className="col-span-2">
|
||||
|
|
@ -331,16 +360,18 @@ 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">Network access</p>
|
||||
<div className="col-span-2">
|
||||
<Badge variant="outline">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${networkAccess.dotClassName}`} />
|
||||
{networkAccess.label}
|
||||
</Badge>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{networkAccess.description}</p>
|
||||
{!isProvider && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Network access</p>
|
||||
<div className="col-span-2">
|
||||
<Badge variant="outline">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${networkAccess.dotClassName}`} />
|
||||
{networkAccess.label}
|
||||
</Badge>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{networkAccess.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
|
@ -401,16 +432,22 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline">All tools enabled</Badge>
|
||||
<Badge variant="outline">
|
||||
{isProvider || mcpServer.mcp_info?.tool_allowlist_enforced
|
||||
? "No tools allowed"
|
||||
: "All tools enabled"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost</p>
|
||||
<div className="col-span-2">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
{!isProvider && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost</p>
|
||||
<div className="col-span-2">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import {
|
||||
buildProviderRegistrationPayload,
|
||||
providerRegistrationDefaults,
|
||||
providerRegistrationSchema,
|
||||
} from "./providerRegistrationForm";
|
||||
|
||||
const values = {
|
||||
...providerRegistrationDefaults(),
|
||||
server_name: "docs_native",
|
||||
url: "https://docs.example.com/mcp",
|
||||
tools_text: " search_docs \nfetch_doc\nsearch_docs\n\n",
|
||||
};
|
||||
|
||||
const server: MCPServer = {
|
||||
server_id: "provider-docs",
|
||||
execution_mode: "provider",
|
||||
server_name: "docs_native",
|
||||
url: values.url,
|
||||
allowed_tools: ["search_docs", "fetch_doc"],
|
||||
mcp_info: { tool_allowlist_enforced: true, logo_url: "https://example.com/icon.svg" },
|
||||
tool_name_to_description: { search_docs: "Search documentation", fetch_doc: "Fetch a document" },
|
||||
tool_name_to_display_name: { search_docs: "Search", fetch_doc: "Fetch" },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_by: "admin",
|
||||
};
|
||||
|
||||
describe("provider registration payload", () => {
|
||||
it("sends finite deduplicated tools and only provider registration fields", () => {
|
||||
expect(buildProviderRegistrationPayload(providerRegistrationSchema.parse(values))).toEqual({
|
||||
execution_mode: "provider",
|
||||
server_name: "docs_native",
|
||||
alias: null,
|
||||
description: null,
|
||||
url: values.url,
|
||||
allowed_tools: ["search_docs", "fetch_doc"],
|
||||
mcp_access_groups: [],
|
||||
allow_all_keys: false,
|
||||
mcp_info: { tool_allowlist_enforced: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears tools with an enforced empty list and removes stale tool metadata", () => {
|
||||
const edited = { ...providerRegistrationDefaults(server), tools_text: " \n " };
|
||||
const payload = buildProviderRegistrationPayload(edited, server);
|
||||
expect(payload).not.toHaveProperty("execution_mode");
|
||||
expect(payload).toMatchObject({
|
||||
server_id: server.server_id,
|
||||
allowed_tools: [],
|
||||
mcp_info: server.mcp_info,
|
||||
tool_name_to_description: {},
|
||||
tool_name_to_display_name: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves descriptions for retained tools and hydrates stored permissions", () => {
|
||||
const stored = { ...server, mcp_access_groups: ["engineering"], allow_all_keys: true };
|
||||
const defaults = providerRegistrationDefaults(stored);
|
||||
expect(defaults).toMatchObject({
|
||||
tools_text: "search_docs\nfetch_doc",
|
||||
mcp_access_groups: ["engineering"],
|
||||
allow_all_keys: true,
|
||||
});
|
||||
expect(buildProviderRegistrationPayload({ ...defaults, tools_text: "search_docs" }, stored)).toMatchObject({
|
||||
allowed_tools: ["search_docs"],
|
||||
tool_name_to_description: { search_docs: "Search documentation" },
|
||||
tool_name_to_display_name: { search_docs: "Search" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://example.com/mcp",
|
||||
"https:example.com/mcp",
|
||||
"https://user:password@example.com/mcp",
|
||||
"https://@example.com/mcp",
|
||||
"https://example.com/mcp#tools",
|
||||
"https://example.com/with space",
|
||||
"https://example.com/with\u0001control",
|
||||
"https://example.com\\mcp",
|
||||
])("rejects unsupported endpoint %s", (url) => {
|
||||
expect(providerRegistrationSchema.safeParse({ ...values, url }).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["*", "search?", "search[docs]"])("rejects wildcard tool %s", (tools_text) => {
|
||||
expect(providerRegistrationSchema.safeParse({ ...values, tools_text }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { z } from "zod";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
const toolNames = (value: string): string[] => [
|
||||
...new Set(
|
||||
value
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
|
||||
const isProviderUrl = (value: string): boolean => {
|
||||
if (/[\s\\#]/.test(value) || [...value].some((char) => char.charCodeAt(0) < 32)) return false;
|
||||
if (!/^https:\/\/[^/?#@]+(?:[/?]|$)/i.test(value)) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const identifier = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => !/[\s-]/.test(value), "Use underscores instead of spaces or hyphens");
|
||||
|
||||
export const providerRegistrationSchema = z.object({
|
||||
server_name: identifier.refine(Boolean, "Enter a server name"),
|
||||
alias: identifier,
|
||||
description: z.string(),
|
||||
url: z.string().trim().refine(isProviderUrl, "Enter an HTTPS URL without credentials or a fragment"),
|
||||
tools_text: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => toolNames(value).every((name) => !/[*?\[\]]/.test(name)),
|
||||
"Enter exact tool names without wildcards",
|
||||
),
|
||||
mcp_access_groups: z.array(z.string()),
|
||||
allow_all_keys: z.boolean(),
|
||||
});
|
||||
|
||||
export type ProviderRegistrationValues = z.infer<typeof providerRegistrationSchema>;
|
||||
|
||||
export const providerRegistrationDefaults = (server?: MCPServer): ProviderRegistrationValues => ({
|
||||
server_name: server?.server_name ?? "",
|
||||
alias: server?.alias ?? "",
|
||||
description: server?.description ?? "",
|
||||
url: server?.url ?? "",
|
||||
tools_text: (server?.allowed_tools ?? []).join("\n"),
|
||||
mcp_access_groups: server?.mcp_access_groups ?? [],
|
||||
allow_all_keys: server?.allow_all_keys ?? false,
|
||||
});
|
||||
|
||||
export const buildProviderRegistrationPayload = (values: ProviderRegistrationValues, server?: MCPServer) => {
|
||||
const allowedTools = toolNames(values.tools_text);
|
||||
const retainedOverrides = (overrides: Readonly<Record<string, string>> | undefined) =>
|
||||
Object.fromEntries(Object.entries(overrides ?? {}).filter(([name]) => allowedTools.includes(name)));
|
||||
return {
|
||||
...(server ? { server_id: server.server_id } : { execution_mode: "provider" as const }),
|
||||
server_name: values.server_name,
|
||||
alias: values.alias || null,
|
||||
description: values.description || null,
|
||||
url: values.url,
|
||||
allowed_tools: allowedTools,
|
||||
mcp_access_groups: values.mcp_access_groups,
|
||||
allow_all_keys: values.allow_all_keys,
|
||||
mcp_info: { ...server?.mcp_info, tool_allowlist_enforced: true },
|
||||
...(server
|
||||
? {
|
||||
tool_name_to_description: retainedOverrides(server.tool_name_to_description),
|
||||
tool_name_to_display_name: retainedOverrides(server.tool_name_to_display_name),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
22
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
22
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -34935,6 +34935,8 @@ export interface components {
|
|||
};
|
||||
/** Env Vars */
|
||||
env_vars?: components["schemas"]["MCPEnvVar"][] | null;
|
||||
/** @default litellm */
|
||||
execution_mode: components["schemas"]["MCPExecutionMode"];
|
||||
/** Extra Headers */
|
||||
extra_headers?: string[];
|
||||
/** Has User Credential */
|
||||
|
|
@ -35034,11 +35036,8 @@ export interface components {
|
|||
tool_name_to_display_name?: {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
/**
|
||||
* Transport
|
||||
* @enum {string}
|
||||
*/
|
||||
transport: "sse" | "http" | "stdio";
|
||||
/** Transport */
|
||||
transport: ("sse" | "http" | "stdio") | null;
|
||||
/** Updated At */
|
||||
updated_at?: string | null;
|
||||
/** Updated By */
|
||||
|
|
@ -37951,6 +37950,11 @@ export interface components {
|
|||
* @enum {string}
|
||||
*/
|
||||
MCPEnvVarScope: "global" | "user";
|
||||
/**
|
||||
* MCPExecutionMode
|
||||
* @enum {string}
|
||||
*/
|
||||
MCPExecutionMode: "litellm" | "provider";
|
||||
/**
|
||||
* MCPGatewaySession
|
||||
* @description One live stateful Streamable HTTP session held by this proxy worker.
|
||||
|
|
@ -39176,6 +39180,8 @@ export interface components {
|
|||
};
|
||||
/** Env Vars */
|
||||
env_vars?: components["schemas"]["MCPEnvVar"][] | null;
|
||||
/** @default litellm */
|
||||
execution_mode: components["schemas"]["MCPExecutionMode"];
|
||||
/** Extra Headers */
|
||||
extra_headers?: string[] | null;
|
||||
/** Instructions */
|
||||
|
|
@ -39254,9 +39260,8 @@ export interface components {
|
|||
/**
|
||||
* Transport
|
||||
* @default sse
|
||||
* @enum {string}
|
||||
*/
|
||||
transport: "sse" | "http" | "stdio";
|
||||
transport: ("sse" | "http" | "stdio") | null;
|
||||
/** Url */
|
||||
url?: string | null;
|
||||
};
|
||||
|
|
@ -48880,9 +48885,8 @@ export interface components {
|
|||
/**
|
||||
* Transport
|
||||
* @default sse
|
||||
* @enum {string}
|
||||
*/
|
||||
transport: "sse" | "http" | "stdio";
|
||||
transport: ("sse" | "http" | "stdio") | null;
|
||||
/** Url */
|
||||
url?: string | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue