mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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>
This commit is contained in:
parent
1b2a7ce518
commit
762b0a3aa4
6 changed files with 286 additions and 3 deletions
|
|
@ -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(<CreateMCPServer {...defaultProps} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -441,6 +441,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
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<CreateMCPServerProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
let customMcpInfo: Record<string, unknown> = {};
|
||||
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<string, any> = {
|
||||
...restValues,
|
||||
|
|
@ -531,6 +543,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// 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<CreateMCPServerProps> = ({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Metadata (JSON)
|
||||
<Tooltip title="Optional free-form JSON stored on the server's mcp_info. Use it for arbitrary metadata such as the owning team or cost center.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
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"));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={'{\n "owning_team": "platform",\n "cost_center": "1234"\n}'}
|
||||
rows={4}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
|
||||
|
||||
<Form.Item
|
||||
|
|
|
|||
|
|
@ -2156,3 +2156,70 @@ describe("MCPServerEdit (dcr_bridge toggle)", () => {
|
|||
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(
|
||||
<MCPServerEdit
|
||||
mcpServer={serverWithMetadata}
|
||||
accessToken={null}
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MCPServerEdit
|
||||
mcpServer={serverWithMetadata}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<MCPServerEditProps> = ({
|
|||
}
|
||||
}, [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<MCPServerEditProps> = ({
|
|||
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<MCPServerEditProps> = ({
|
|||
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<MCPServerEditProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
let customMcpInfo: Record<string, unknown> = {};
|
||||
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<MCPServerEditProps> = ({
|
|||
: {}),
|
||||
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<MCPServerEditProps> = ({
|
|||
<Form.Item label="Description" name="description">
|
||||
<Input className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="flex items-center">
|
||||
Metadata (JSON)
|
||||
<Tooltip title="Optional free-form JSON stored on the server's mcp_info. Use it for arbitrary metadata such as the owning team or cost center.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
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"));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={'{\n "owning_team": "platform",\n "cost_center": "1234"\n}'}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
|
||||
<Form.Item label="Transport Type" name="transport" rules={[{ required: true }]}>
|
||||
<Select onChange={handleTransportChange}>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { MCPToolsViewer } from ".";
|
|||
import MCPServerEdit, { EDIT_OAUTH_UI_STATE_KEY } from "./mcp_server_edit";
|
||||
import { getSecureItem } from "@/utils/secureStorage";
|
||||
import MCPServerCostDisplay from "./mcp_server_cost_display";
|
||||
import { getMaskedAndFullUrl } from "./utils";
|
||||
import { getMaskedAndFullUrl, extractCustomMcpInfo } from "./utils";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { Button as AntdButton } from "antd";
|
||||
|
|
@ -70,6 +70,9 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
const urlValue = mcpServer.url ?? "";
|
||||
const { maskedUrl, hasToken } = urlValue ? getMaskedAndFullUrl(urlValue) : { maskedUrl: "—", hasToken: false };
|
||||
|
||||
const customMcpInfo = extractCustomMcpInfo(mcpServer.mcp_info as Record<string, unknown> | null | undefined);
|
||||
const hasCustomMcpInfo = Object.keys(customMcpInfo).length > 0;
|
||||
|
||||
const renderUrlWithToggle = (url: string | null | undefined, showFull: boolean) => {
|
||||
if (!url) return "—";
|
||||
if (!hasToken) return url;
|
||||
|
|
@ -260,6 +263,18 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
{mcpServer.description || <span className="text-gray-400">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Metadata</Text>
|
||||
<div className="col-span-2 text-sm text-gray-900">
|
||||
{hasCustomMcpInfo ? (
|
||||
<pre className="text-xs bg-gray-50 border border-gray-200 rounded-md p-3 overflow-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(customMcpInfo, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">URL</Text>
|
||||
<div className="col-span-2 text-sm font-mono text-gray-900 break-all flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -86,6 +86,30 @@ export const normalizeEnvVars = (list: unknown): MCPEnvVar[] => {
|
|||
return out;
|
||||
};
|
||||
|
||||
export const MCP_INFO_RESERVED_KEYS = [
|
||||
"server_name",
|
||||
"description",
|
||||
"logo_url",
|
||||
"mcp_server_cost_info",
|
||||
"tool_allowlist_enforced",
|
||||
] as const;
|
||||
|
||||
export const extractCustomMcpInfo = (info: Record<string, unknown> | null | undefined): Record<string, unknown> => {
|
||||
if (!info || typeof info !== "object") return {};
|
||||
const reserved = new Set<string>(MCP_INFO_RESERVED_KEYS);
|
||||
return Object.fromEntries(Object.entries(info).filter(([key]) => !reserved.has(key)));
|
||||
};
|
||||
|
||||
export const serializeCustomMcpInfo = (info: Record<string, unknown> | null | undefined): string => {
|
||||
const custom = extractCustomMcpInfo(info);
|
||||
if (Object.keys(custom).length === 0) return "";
|
||||
try {
|
||||
return JSON.stringify(custom, null, 2);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/** Normalize tool override maps from API/DB (dict or JSON string) for form state. */
|
||||
export const normalizeToolOverrideMap = (
|
||||
value: Record<string, string> | string | null | undefined,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue