From 762b0a3aa40bf047140abbb5a26ac75a7f9f0398 Mon Sep 17 00:00:00 2001 From: Mubashir Osmani Date: Fri, 24 Jul 2026 22:08:14 +0000 Subject: [PATCH] feat(ui): add free-form mcp_info metadata editor to MCP server create/edit/view Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_components/create_mcp_server.test.tsx | 82 +++++++++++++++++++ .../_components/create_mcp_server.tsx | 47 +++++++++++ .../_components/mcp_server_edit.test.tsx | 67 +++++++++++++++ .../_components/mcp_server_edit.tsx | 52 +++++++++++- .../_components/mcp_server_view.tsx | 17 +++- .../mcp-servers/_components/utils.tsx | 24 ++++++ 6 files changed, 286 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.test.tsx index 6ce7f5c75ed..5c748446227 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.test.tsx @@ -2118,3 +2118,85 @@ describe("CreateMCPServer dcr_bridge toggle", () => { expect(payload.dcr_bridge).toBe(true); }); }); + +describe("CreateMCPServer mcp_info metadata", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createdServer = { + server_id: "new-metadata-server", + server_name: "Metadata_Server", + alias: "Metadata_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "none", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }; + + async function setupHttpServerForm() { + render(); + await selectAntOption("Transport Type", "Streamable HTTP"); + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "Metadata_Server" } }); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } }); + }); + await selectAntOption("Authentication", "None"); + } + + it("merges free-form mcp_info metadata JSON into the mcp_info payload", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue(createdServer); + await setupHttpServerForm(); + + const metadataTextarea = document.getElementById("mcp_info_metadata_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(metadataTextarea, { + target: { value: '{"owning_team": "platform", "cost_center": "1234"}' }, + }); + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.mcp_info.owning_team).toBe("platform"); + expect(payload.mcp_info.cost_center).toBe("1234"); + expect(payload.mcp_info.server_name).toBe("Metadata_Server"); + }); + + it("blocks submit and does not call the API when mcp_info metadata JSON is invalid", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue(createdServer); + await setupHttpServerForm(); + + const metadataTextarea = document.getElementById("mcp_info_metadata_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(metadataTextarea, { target: { value: "{not valid json" } }); + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + expect(networking.createMCPServer).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx index b21a5218c20..b2f054978d1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx @@ -441,6 +441,7 @@ const CreateMCPServer: React.FC = ({ oauth_passthrough: oauthPassthroughRaw, dcr_bridge: dcrBridgeRaw, token_validation_json: rawTokenValidationJson, + mcp_info_metadata_json: rawMcpInfoMetadataJson, ...restValues } = values; @@ -524,6 +525,17 @@ const CreateMCPServer: React.FC = ({ } } + let customMcpInfo: Record = {}; + if (rawMcpInfoMetadataJson && rawMcpInfoMetadataJson.trim() !== "") { + try { + customMcpInfo = JSON.parse(rawMcpInfoMetadataJson); + } catch { + NotificationsManager.fromBackend("Invalid JSON in MCP Info Metadata"); + setIsLoading(false); + return; + } + } + // Prepare the payload with cost configuration and allowed tools const payload: Record = { ...restValues, @@ -531,6 +543,7 @@ const CreateMCPServer: React.FC = ({ // Remove the raw stdio_config field as we've extracted its components stdio_config: undefined, mcp_info: { + ...customMcpInfo, server_name: restValues.server_name || restValues.url, description: restValues.description, logo_url: logoUrl || undefined, @@ -886,6 +899,40 @@ const CreateMCPServer: React.FC = ({ /> + + Metadata (JSON) + + + + + } + name="mcp_info_metadata_json" + rules={[ + { + validator: (_: unknown, value: string) => { + if (!value || value.trim() === "") return Promise.resolve(); + try { + const parsed = JSON.parse(value); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return Promise.reject(new Error("Must be a JSON object")); + } + return Promise.resolve(); + } catch { + return Promise.reject(new Error("Must be valid JSON")); + } + }, + }, + ]} + > + + + { expect(payload.dcr_bridge).toBe(true); }); }); + +describe("MCPServerEdit (mcp_info metadata)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const serverWithMetadata = { + ...interactiveOAuthServer, + auth_type: "none", + mcp_info: { server_name: "OAuthServer", owning_team: "platform", cost_center: "1234" }, + }; + + it("pre-populates the metadata textarea with custom mcp_info keys and strips reserved keys", async () => { + render( + , + ); + + await waitFor(() => { + const textarea = document.getElementById("mcp_info_metadata_json") as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + expect(JSON.parse(textarea.value)).toEqual({ owning_team: "platform", cost_center: "1234" }); + }); + }); + + it("merges edited metadata into mcp_info on save and drops removed keys", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue(serverWithMetadata); + + render( + , + ); + + let textarea: HTMLTextAreaElement | null = null; + await waitFor(() => { + textarea = document.getElementById("mcp_info_metadata_json") as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + }); + await act(async () => { + fireEvent.change(textarea!, { target: { value: '{"owning_team": "search"}' } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.mcp_info.owning_team).toBe("search"); + expect(payload.mcp_info.cost_center).toBeUndefined(); + expect(payload.mcp_info.server_name).toBe("OAuthServer"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index 8646ab192c9..a878b819586 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -42,6 +42,7 @@ import { validateMCPServerName, normalizeEnvVars, normalizeToolOverrideMap, + serializeCustomMcpInfo, TOOL_DISPLAY_NAME_PATTERN, } from "./utils"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -280,6 +281,8 @@ const MCPServerEdit: React.FC = ({ } }, [mcpServer.env]); + const initialMcpInfoMetadata = React.useMemo(() => serializeCustomMcpInfo(mcpServer.mcp_info), [mcpServer.mcp_info]); + // If server has spec_path, show it as "openapi" transport in the UI const effectiveTransport = React.useMemo(() => { if (mcpServer.spec_path && mcpServer.transport !== "stdio") { @@ -300,8 +303,9 @@ const MCPServerEdit: React.FC = ({ token_validation_json: mcpServer.token_validation ? JSON.stringify(mcpServer.token_validation, null, 2) : undefined, + mcp_info_metadata_json: initialMcpInfoMetadata || undefined, }), - [mcpServer, effectiveTransport, initialStaticHeaders, initialEnvVars, initialEnvJson], + [mcpServer, effectiveTransport, initialStaticHeaders, initialEnvVars, initialEnvJson, initialMcpInfoMetadata], ); // antd applies `initialValues` only at first mount. When the server loads after @@ -694,6 +698,7 @@ const MCPServerEdit: React.FC = ({ oauth_passthrough: oauthPassthroughRaw, dcr_bridge: dcrBridgeRaw, token_validation_json: rawTokenValidationJson, + mcp_info_metadata_json: rawMcpInfoMetadataJson, ...restValues } = values; @@ -829,6 +834,16 @@ const MCPServerEdit: React.FC = ({ } } + let customMcpInfo: Record = {}; + if (rawMcpInfoMetadataJson && rawMcpInfoMetadataJson.trim() !== "") { + try { + customMcpInfo = JSON.parse(rawMcpInfoMetadataJson); + } catch { + NotificationsManager.fromBackend("Invalid JSON in MCP Info Metadata"); + return; + } + } + // Prepare the payload with cost configuration and permission fields const mcpInfoServerName = restValues.server_name || @@ -856,7 +871,7 @@ const MCPServerEdit: React.FC = ({ : {}), server_id: mcpServer.server_id, mcp_info: { - ...(mcpServer.mcp_info ?? {}), + ...customMcpInfo, server_name: mcpInfoServerName, description: restValues.description, logo_url: logoUrl || undefined, @@ -1034,6 +1049,39 @@ const MCPServerEdit: React.FC = ({ + + Metadata (JSON) + + + + + } + name="mcp_info_metadata_json" + rules={[ + { + validator: (_: unknown, value: string) => { + if (!value || value.trim() === "") return Promise.resolve(); + try { + const parsed = JSON.parse(value); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return Promise.reject(new Error("Must be a JSON object")); + } + return Promise.resolve(); + } catch { + return Promise.reject(new Error("Must be valid JSON")); + } + }, + }, + ]} + > + +