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:
Mubashir Osmani 2026-07-24 22:08:14 +00:00
parent 1b2a7ce518
commit 762b0a3aa4
6 changed files with 286 additions and 3 deletions

View file

@ -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();
});
});

View file

@ -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

View file

@ -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");
});
});

View file

@ -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}>

View file

@ -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">

View file

@ -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,