diff --git a/.github/actions/check-component-ids/componentId-registry.js b/.github/actions/check-component-ids/componentId-registry.js index 9925be980e080..69eed2544a209 100644 --- a/.github/actions/check-component-ids/componentId-registry.js +++ b/.github/actions/check-component-ids/componentId-registry.js @@ -1870,10 +1870,24 @@ module.exports = { "mlflow.mcp_registry.bindings.view_toggle": "", "mlflow.mcp_registry.card": "", "mlflow.mcp_registry.card_grid.pagination": "", + "mlflow.mcp_registry.compare.status": "", + "mlflow.mcp_registry.compare.switch_sides": "", + "mlflow.mcp_registry.compare.switch_sides.tooltip": "", + "mlflow.mcp_registry.create.display_name": "", + "mlflow.mcp_registry.create.server_json": "", + "mlflow.mcp_registry.create.source": "", + "mlflow.mcp_registry.create.status": "", + "mlflow.mcp_registry.create.tag.add": "", + "mlflow.mcp_registry.create.tag.add.tooltip": "", + "mlflow.mcp_registry.create.tag.value": "", + "mlflow.mcp_registry.create.tools": "", "mlflow.mcp_registry.create_binding_button": "", "mlflow.mcp_registry.create_server_button": "", + "mlflow.mcp_registry.create_server_version.error": "", + "mlflow.mcp_registry.create_server_version.modal": "", "mlflow.mcp_registry.detail.actions": "", "mlflow.mcp_registry.detail.actions.delete": "", + "mlflow.mcp_registry.detail.actions.edit_display_name": "", "mlflow.mcp_registry.detail.add_binding": "", "mlflow.mcp_registry.detail.binding.card": "", "mlflow.mcp_registry.detail.binding.delete": "", @@ -1886,16 +1900,54 @@ module.exports = { "mlflow.mcp_registry.detail.delete_server_modal": "", "mlflow.mcp_registry.detail.delete_version": "", "mlflow.mcp_registry.detail.delete_version_modal": "", - "mlflow.mcp_registry.detail.edit_status": "", + "mlflow.mcp_registry.detail.display_name_input": "", + "mlflow.mcp_registry.detail.edit_version": "", "mlflow.mcp_registry.detail.error": "", "mlflow.mcp_registry.detail.repository": "", - "mlflow.mcp_registry.detail.status_select": "", - "mlflow.mcp_registry.detail.toggle_json": "", - "mlflow.mcp_registry.detail.update_status_error": "", - "mlflow.mcp_registry.detail.update_status_modal": "", - "mlflow.mcp_registry.detail.update_status_terminal": "", - "mlflow.mcp_registry.detail.usage_example_modal": "", - "mlflow.mcp_registry.detail.use_version": "", + "mlflow.mcp_registry.detail.select_baseline.tooltip": "", + "mlflow.mcp_registry.detail.select_compared.tooltip": "", + "mlflow.mcp_registry.detail.tags.edit": "", + "mlflow.mcp_registry.detail.update_display_name_error": "", + "mlflow.mcp_registry.detail.update_display_name_modal": "", + "mlflow.mcp_registry.detail.copy_package_identifier": "", + "mlflow.mcp_registry.detail.copy_package_identifier_button": "", + "mlflow.mcp_registry.detail.copy_remote_url": "", + "mlflow.mcp_registry.detail.copy_remote_url_button": "", + "mlflow.mcp_registry.detail.env_var_required": "", + "mlflow.mcp_registry.detail.env_var_secret": "", + "mlflow.mcp_registry.detail.header_required": "", + "mlflow.mcp_registry.detail.header_secret": "", + "mlflow.mcp_registry.detail.package_registry_tag": "", + "mlflow.mcp_registry.detail.raw_json.copy": "", + "mlflow.mcp_registry.detail.raw_json.copy_button": "", + "mlflow.mcp_registry.detail.raw_json.toggle": "", + "mlflow.mcp_registry.detail.raw_tools_json.copy": "", + "mlflow.mcp_registry.detail.raw_tools_json.copy_button": "", + "mlflow.mcp_registry.detail.raw_tools_json.toggle": "", + "mlflow.mcp_registry.detail.remote_transport_tag": "", + "mlflow.mcp_registry.detail.toggle_env_vars": "", + "mlflow.mcp_registry.detail.toggle_packages": "", + "mlflow.mcp_registry.detail.toggle_tools": "", + "mlflow.mcp_registry.detail.tool_annotation_tag": "", + "mlflow.mcp_registry.detail.tool_input_schema.copy": "", + "mlflow.mcp_registry.detail.tool_input_schema.copy_button": "", + "mlflow.mcp_registry.detail.tool_input_schema.toggle": "", + "mlflow.mcp_registry.detail.tool_name_tag": "", + "mlflow.mcp_registry.detail.tool_output_schema.copy": "", + "mlflow.mcp_registry.detail.tool_output_schema.copy_button": "", + "mlflow.mcp_registry.detail.tool_output_schema.toggle": "", + "mlflow.mcp_registry.detail.version_tabs": "", + "mlflow.mcp_registry.detail.version.add_metadata": "", + "mlflow.mcp_registry.detail.version.description.error": "", + "mlflow.mcp_registry.detail.version.description.modal": "", + "mlflow.mcp_registry.detail.version.description.textarea": "", + "mlflow.mcp_registry.detail.version.edit_display_name_input": "", + "mlflow.mcp_registry.detail.version.edit_metadata": "", + "mlflow.mcp_registry.detail.version.edit_status_select": "", + "mlflow.mcp_registry.detail.version.edit_tools_input": "", + "mlflow.mcp_registry.detail.version.edit_version_error": "", + "mlflow.mcp_registry.detail.version.edit_version_modal": "", + "mlflow.mcp_registry.detail.version.tools_validation_error": "", "mlflow.mcp_registry.detail.version_status": "", "mlflow.mcp_registry.detail.version_status_tag": "", "mlflow.mcp_registry.detail.versions.header": "", @@ -1909,6 +1961,7 @@ module.exports = { "mlflow.mcp_registry.table.header": "", "mlflow.mcp_registry.table.name_link": "", "mlflow.mcp_registry.table.pagination": "", + "mlflow.mcp_registry.table.tag.edit": "", "mlflow.mcp_registry.tabs": "", "mlflow.mcp_registry.view_toggle": "", diff --git a/mlflow/server/js/src/common/components/AliasSelect.tsx b/mlflow/server/js/src/common/components/AliasSelect.tsx index dab964a61142d..bf8841b6b66c7 100644 --- a/mlflow/server/js/src/common/components/AliasSelect.tsx +++ b/mlflow/server/js/src/common/components/AliasSelect.tsx @@ -2,6 +2,7 @@ import type { Dispatch } from 'react'; import { useCallback, useState } from 'react'; import { LegacySelect, useDesignSystemTheme } from '@databricks/design-system'; +import type { TagColors } from '@databricks/design-system'; import { AliasTag } from './AliasTag'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -17,6 +18,8 @@ export const AliasSelect = ({ version, aliasToVersionMap, disabled, + pinnedAliases, + pinnedAliasColor, }: { renderKey: any; disabled: boolean; @@ -25,6 +28,8 @@ export const AliasSelect = ({ draftAliases: string[]; version: string; aliasToVersionMap: Record; + pinnedAliases?: string[]; + pinnedAliasColor?: TagColors; }) => { const intl = useIntl(); const [dropdownVisible, setDropdownVisible] = useState(false); @@ -50,14 +55,16 @@ export const AliasSelect = ({ ) // After sanitization, filter out invalid aliases // so we won't get empty values - .filter((alias) => alias.length > 0); + .filter((alias) => alias.length > 0) + // Exclude pinned aliases from the draft set + .filter((alias) => !pinnedAliases?.includes(alias)); // Remove duplicates that might result from varying letter case const uniqueAliases = Array.from(new Set(sanitizedAliases)); setDraftAliases(uniqueAliases); setDropdownVisible(false); }, - [setDraftAliases], + [setDraftAliases, pinnedAliases], ); return ( @@ -82,33 +89,39 @@ export const AliasSelect = ({ dangerouslySetAntdProps={{ dropdownMatchSelectWidth: true, // eslint-disable-next-line @databricks/no-unstable-nested-components -- go/no-nested-components - tagRender: ({ value }) => ( - removeFromEditedAliases(value.toString())} - value={value.toString()} - /> - ), + tagRender: ({ value }) => { + const isPinned = pinnedAliases?.includes(value.toString()); + return ( + removeFromEditedAliases(value.toString())} + value={value.toString()} + color={isPinned ? pinnedAliasColor || 'turquoise' : undefined} + /> + ); + }, }} onDropdownVisibleChange={setDropdownVisible} open={dropdownVisible} - value={draftAliases || []} + value={[...(pinnedAliases ?? []), ...(draftAliases || [])]} > - {existingAliases.map((alias) => ( - -
-
{alias}
-
- + {existingAliases + .filter((alias) => !pinnedAliases?.includes(alias)) + .map((alias) => ( + +
+
{alias}
+
+ +
-
- - ))} + + ))} {Object.entries(aliasToVersionMap) .filter(([, otherVersion]) => otherVersion !== version) .map(([alias, aliasedVersion]) => ( diff --git a/mlflow/server/js/src/common/components/AliasTag.tsx b/mlflow/server/js/src/common/components/AliasTag.tsx index da52579b16957..1e370a2b0a64c 100644 --- a/mlflow/server/js/src/common/components/AliasTag.tsx +++ b/mlflow/server/js/src/common/components/AliasTag.tsx @@ -3,7 +3,7 @@ import type { TagProps } from '@databricks/design-system'; type ModelVersionAliasTagProps = { value: string; compact?: boolean } & Pick< TagProps, - 'closable' | 'onClose' | 'className' + 'closable' | 'onClose' | 'className' | 'color' >; // When displayed in compact mode (e.g. within setEditVersionDisplayName(e.target.value)} + placeholder={intl.formatMessage({ + defaultMessage: 'Enter display name', + description: 'Placeholder for version display name input', + })} + /> +
+
+ + + + setEditVersionStatus(target.value as MCPStatus)} + > + {(['draft', 'active', 'deprecated'] as MCPStatus[]).map((s) => ( + + {s.charAt(0).toUpperCase() + s.slice(1)} + + ))} + +
+
+ + + + a.alias).filter((a) => !RESERVED_ALIASES.includes(a))} + setDraftAliases={setEditVersionAliases} + version={version.version} + aliasToVersionMap={Object.fromEntries((server.aliases ?? []).map((a) => [a.alias, a.version]))} + pinnedAliases={resolvedLatestVersion === version.version ? [LATEST_ALIAS] : undefined} + pinnedAliasColor={aliasColors?.[LATEST_ALIAS]} + /> +
+
+ + + + {toolsValidationError && ( + setToolsValidationError(null)} + message={toolsValidationError} + css={{ marginBottom: theme.spacing.xs }} + /> + )} + { + setEditVersionToolsText(e.target.value); + setToolsValidationError(null); + }} + autoSize={{ minRows: 4, maxRows: 12 }} + css={{ fontFamily: 'monospace' }} + placeholder={intl.formatMessage({ + defaultMessage: 'Enter tools JSON array', + description: 'Placeholder for version tools input', + })} + /> +
+ + } isLoading={deleteVersionMutation.isLoading} - error={deleteVersionMutation.error?.message ?? null} + error={(deleteVersionMutation.error as Error | null)?.message ?? null} onConfirm={() => { deleteVersionMutation.mutate(version.version, { onSuccess: () => setDeleteModalVisible(false), @@ -308,19 +507,6 @@ export const MCPServerVersionDetail = ({ setDeleteModalVisible(false); }} /> - - } - visible={showUsageExample} - onCancel={() => setShowUsageExample(false)} - cancelText={intl.formatMessage({ - defaultMessage: 'Dismiss', - description: 'MCP server usage example modal dismiss button', - })} - > - - ); }; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDiffSelectorButton.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDiffSelectorButton.test.tsx new file mode 100644 index 0000000000000..b05a36f6484e7 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDiffSelectorButton.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import { DesignSystemProvider } from '@databricks/design-system'; +import { MCPServerVersionDiffSelectorButton } from './MCPServerVersionDiffSelectorButton'; + +const renderButton = (props: Partial> = {}) => + render( + + + + + , + ); + +describe('MCPServerVersionDiffSelectorButton', () => { + it('renders two radio buttons', () => { + renderButton(); + const radios = screen.getAllByRole('radio'); + expect(radios).toHaveLength(2); + }); + + it('marks baseline as checked when selected', () => { + renderButton({ isSelectedBaseline: true }); + const radios = screen.getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('aria-checked', 'true'); + expect(radios[1]).toHaveAttribute('aria-checked', 'false'); + }); + + it('marks compared as checked when selected', () => { + renderButton({ isSelectedCompared: true }); + const radios = screen.getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('aria-checked', 'false'); + expect(radios[1]).toHaveAttribute('aria-checked', 'true'); + }); + + it('calls onSelectBaseline when baseline button is clicked', async () => { + const onSelectBaseline = jest.fn(); + renderButton({ onSelectBaseline }); + const radios = screen.getAllByRole('radio'); + await userEvent.click(radios[0]); + expect(onSelectBaseline).toHaveBeenCalledTimes(1); + }); + + it('calls onSelectCompared when compared button is clicked', async () => { + const onSelectCompared = jest.fn(); + renderButton({ onSelectCompared }); + const radios = screen.getAllByRole('radio'); + await userEvent.click(radios[1]); + expect(onSelectCompared).toHaveBeenCalledTimes(1); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDiffSelectorButton.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDiffSelectorButton.tsx new file mode 100644 index 0000000000000..5905c4fb3873f --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDiffSelectorButton.tsx @@ -0,0 +1,107 @@ +import { Tooltip, useDesignSystemTheme } from '@databricks/design-system'; +import { FormattedMessage, useIntl } from 'react-intl'; + +export const MCPServerVersionDiffSelectorButton = ({ + isSelectedBaseline, + isSelectedCompared, + onSelectBaseline, + onSelectCompared, +}: { + isSelectedBaseline: boolean; + isSelectedCompared: boolean; + onSelectBaseline?: () => void; + onSelectCompared?: () => void; +}) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + return ( +
+
+ + } + delayDuration={0} + side="left" + > +
+
+ ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionList.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionList.tsx index d4400c0110171..7cc59c249f631 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionList.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionList.tsx @@ -16,14 +16,19 @@ import type { ColumnDef } from '@tanstack/react-table'; import { flexRender, getCoreRowModel } from '@tanstack/react-table'; import { FormattedMessage, useIntl } from 'react-intl'; +import type { TagColors } from '@databricks/design-system'; import type { MCPServerVersion } from '../types'; import { STATUS_TAG_COLOR } from '../utils'; +import { MCPServerDetailViewMode } from '../hooks/useMCPServerDetailViewState'; +import { MCPServerVersionDiffSelectorButton } from './MCPServerVersionDiffSelectorButton'; import { ModelVersionTableAliasesCell } from '../../model-registry/components/aliases/ModelVersionTableAliasesCell'; import Utils from '../../common/utils/Utils'; interface MCPServerVersionListMeta { serverName: string; + serverDisplayName: string; aliasesByVersion: Record; + aliasColors?: Record; showEditAliasesModal?: (versionNumber: string) => void; } @@ -35,15 +40,19 @@ const MCPServerVersionCell: ColumnDef['cell'] = ({ }) => { const { theme } = useDesignSystemTheme(); const intl = useIntl(); - const { serverName, aliasesByVersion, showEditAliasesModal } = meta as MCPServerVersionListMeta; + const { serverName, serverDisplayName, aliasesByVersion, aliasColors, showEditAliasesModal } = + meta as MCPServerVersionListMeta; const aliases = aliasesByVersion[original.version] || []; + const rawDisplayName = original.display_name || original.server_json?.title; + const versionDisplayName = rawDisplayName && rawDisplayName !== serverDisplayName ? rawDisplayName : undefined; + return (
@@ -55,11 +64,22 @@ const MCPServerVersionCell: ColumnDef['cell'] = ({ modelName={serverName} version={original.version} aliases={aliases} + aliasColors={aliasColors} onAddEdit={() => { showEditAliasesModal?.(original.version); }} />
+ {versionDisplayName && ( + + {versionDisplayName} + + )} {original.creation_timestamp && ( {Utils.formatTimestamp(original.creation_timestamp, intl)} @@ -72,22 +92,33 @@ const MCPServerVersionCell: ColumnDef['cell'] = ({ export const MCPServerVersionList = ({ versions, selectedVersion, + comparedVersion, + mode, onSelectVersion, + onSelectComparedVersion, isLoading, serverName, + serverDisplayName, aliasesByVersion, + aliasColors, showEditAliasesModal, }: { versions?: MCPServerVersion[]; selectedVersion?: string; + comparedVersion?: string; + mode: MCPServerDetailViewMode; onSelectVersion: (version: string) => void; + onSelectComparedVersion?: (version: string) => void; isLoading?: boolean; serverName: string; + serverDisplayName: string; aliasesByVersion: Record; + aliasColors?: Record; showEditAliasesModal?: (versionNumber: string) => void; }) => { const { theme } = useDesignSystemTheme(); const intl = useIntl(); + const isCompareMode = mode === MCPServerDetailViewMode.COMPARE; const columns = useMemo[]>( () => [ @@ -109,7 +140,7 @@ export const MCPServerVersionList = ({ columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.version, - meta: { serverName, aliasesByVersion, showEditAliasesModal }, + meta: { serverName, serverDisplayName, aliasesByVersion, aliasColors, showEditAliasesModal }, }); const emptyState = @@ -136,32 +167,48 @@ export const MCPServerVersionList = ({ ) : ( table.getRowModel().rows.map((row) => { - const isSelected = selectedVersion === row.original.version; + const version = row.original.version; + const isSelected = selectedVersion === version; + const isCompared = comparedVersion === version; return ( onSelectVersion(row.original.version)} + onClick={isCompareMode ? undefined : () => onSelectVersion(version)} > {row.getAllCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} - {isSelected && ( -
- -
+ {isCompareMode ? ( + onSelectVersion(version)} + onSelectCompared={() => onSelectComparedVersion?.(version)} + /> + ) : ( + isSelected && ( +
+ +
+ ) )}
); diff --git a/mlflow/server/js/src/mcp-registry/components/ServerJSONSection.tsx b/mlflow/server/js/src/mcp-registry/components/ServerJSONSection.tsx new file mode 100644 index 0000000000000..679476550a814 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/ServerJSONSection.tsx @@ -0,0 +1,948 @@ +import { useMemo, useState } from 'react'; +import { + Button, + ChevronDownIcon, + ChevronRightIcon, + CopyIcon, + Tag, + Tooltip, + Typography, + useDesignSystemTheme, +} from '@databricks/design-system'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import type { MCPTool, ServerJSONPayload } from '../types'; +import { copyToClipboard } from '../../common/utils/copyToClipboard'; + +export const ServerJSONSection = ({ serverJson }: { serverJson: ServerJSONPayload }) => { + const { theme } = useDesignSystemTheme(); + const packages = serverJson.packages ?? []; + const remotes = serverJson.remotes ?? []; + + return ( +
+ {packages.length > 0 && } + {remotes.length > 0 && } + +
+ ); +}; + +export const ToolsSection = ({ tools }: { tools: MCPTool[] }) => { + const { theme } = useDesignSystemTheme(); + + return ( +
+ + +
+ ); +}; + +const INITIAL_VISIBLE_PACKAGES = 5; + +const PackagesSubsection = ({ packages }: { packages: NonNullable }) => { + const { theme } = useDesignSystemTheme(); + const [expandedIndex, setExpandedIndex] = useState(null); + const [showAll, setShowAll] = useState(false); + const visiblePackages = showAll ? packages : packages.slice(0, INITIAL_VISIBLE_PACKAGES); + const hiddenCount = packages.length - INITIAL_VISIBLE_PACKAGES; + + return ( +
+ + + +
+ {visiblePackages.map((pkg, index) => ( + setExpandedIndex(expandedIndex === index ? null : index)} + showTopBorder={index > 0} + /> + ))} + {hiddenCount > 0 && ( +
+ +
+ )} +
+
+ ); +}; + +const PackageRow = ({ + pkg, + expanded, + onToggle, + showTopBorder, +}: { + pkg: NonNullable[number]; + expanded: boolean; + onToggle: () => void; + showTopBorder: boolean; +}) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const allEnvVars = pkg.environmentVariables ?? []; + const runtimeHint = pkg.runtimeHint; + const transportLabel = [pkg.transport?.type, runtimeHint].filter(Boolean).join(' · '); + + return ( +
+ + + {expanded && ( +
+
+ {pkg.identifier} + +
+ + {pkg.version && ( +
+ + + + {pkg.version} +
+ )} + + {pkg.transport?.type && ( +
+ + + + {transportLabel} +
+ )} + + {allEnvVars.length > 0 && } +
+ )} +
+ ); +}; + +const INITIAL_VISIBLE_ENV_VARS = 5; + +const EnvVarList = ({ + envVars, +}: { + envVars: NonNullable[number]['environmentVariables']; +}) => { + const { theme } = useDesignSystemTheme(); + const vars = envVars ?? []; + const [showAll, setShowAll] = useState(false); + const visibleVars = showAll ? vars : vars.slice(0, INITIAL_VISIBLE_ENV_VARS); + const hiddenCount = vars.length - INITIAL_VISIBLE_ENV_VARS; + + return ( +
+ + + +
+ {visibleVars.map((envVar, i) => ( +
0 ? `1px solid ${theme.colors.borderDecorative}` : 'none', + }} + > +
+ + {envVar.name} + + {envVar.isRequired && ( + + + + )} + {envVar.isSecret && ( + + + + )} +
+ {envVar.description && ( + + {envVar.description} + + )} +
+ ))} + {hiddenCount > 0 && ( +
+ +
+ )} +
+
+ ); +}; + +const RemotesSubsection = ({ remotes }: { remotes: NonNullable }) => { + const { theme } = useDesignSystemTheme(); + const [expandedIndex, setExpandedIndex] = useState(null); + + return ( +
+ + + +
+ {remotes.map((remote, index) => ( + setExpandedIndex(expandedIndex === index ? null : index)} + showTopBorder={index > 0} + /> + ))} +
+
+ ); +}; + +const RemoteRow = ({ + remote, + expanded, + onToggle, + showTopBorder, +}: { + remote: NonNullable[number]; + expanded: boolean; + onToggle: () => void; + showTopBorder: boolean; +}) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + + return ( +
+ + + {expanded && ( +
+ {remote.url && ( +
+ {remote.url} + +
+ )} + + {remote.headers && remote.headers.length > 0 && ( +
+ + + +
+ {remote.headers.map((header, i) => ( +
0 ? `1px solid ${theme.colors.borderDecorative}` : 'none', + }} + > +
+ + {header.name} + + {header.isRequired && ( + + + + )} + {header.isSecret && ( + + + + )} +
+ {header.description && ( + + {header.description} + + )} +
+ ))} +
+
+ )} + + {remote.variables && Object.keys(remote.variables).length > 0 && ( +
+ + + +
+ {Object.entries(remote.variables).map(([name, variable], i) => { + const v = + typeof variable === 'object' && variable !== null ? (variable as Record) : {}; + return ( +
0 ? `1px solid ${theme.colors.borderDecorative}` : 'none', + }} + > + + {`{${name}}`} + + {v['description'] && ( + + {String(v['description'])} + + )} +
+ ); + })} +
+
+ )} +
+ )} +
+ ); +}; + +const INITIAL_VISIBLE_TOOLS = 10; + +const ToolsSubsection = ({ tools }: { tools: MCPTool[] }) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const [expandedIndex, setExpandedIndex] = useState(null); + const [showAll, setShowAll] = useState(false); + const visibleTools = showAll ? tools : tools.slice(0, INITIAL_VISIBLE_TOOLS); + const hiddenCount = tools.length - INITIAL_VISIBLE_TOOLS; + + return ( +
+ + + +
+ {visibleTools.map((tool, index) => ( +
0 ? `1px solid ${theme.colors.borderDecorative}` : 'none', + }} + > + + + {expandedIndex === index && ( +
+ {tool.annotations && Object.keys(tool.annotations).length > 0 && ( +
+ {Object.entries(tool.annotations).map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+ )} + {tool.inputSchema && Object.keys(tool.inputSchema).length > 0 && ( + + )} + {tool.outputSchema && Object.keys(tool.outputSchema).length > 0 && ( + + )} +
+ )} +
+ ))} + {hiddenCount > 0 && ( +
+ +
+ )} +
+
+ ); +}; + +const useJSONToggle = (data: unknown) => { + const [show, setShow] = useState(false); + const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]); + return { show, setShow, jsonString }; +}; + +const jsonPreStyles = (theme: ReturnType['theme'], padding = theme.spacing.sm) => + ({ + margin: 0, + padding, + backgroundColor: theme.colors.backgroundSecondary, + borderRadius: theme.borders.borderRadiusSm, + overflow: 'auto' as const, + fontSize: theme.typography.fontSizeSm, + }) as const; + +const InputSchemaToggle = ({ data }: { data: unknown }) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const { show, setShow, jsonString } = useJSONToggle(data); + + return ( +
+ + {show && ( +
+ +
+ )} +
+ ); +}; + +const OutputSchemaToggle = ({ data }: { data: unknown }) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const { show, setShow, jsonString } = useJSONToggle(data); + + return ( +
+ + {show && ( +
+ +
+ )} +
+ ); +}; + +const RawJSONToggle = ({ serverJson }: { serverJson: ServerJSONPayload }) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const { show: showRaw, setShow: setShowRaw, jsonString } = useJSONToggle(serverJson); + + return ( +
+ + {showRaw && ( +
+ + } + > +
+ )} +
+ ); +}; + +const RawToolsJSONToggle = ({ tools }: { tools: MCPTool[] }) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const { show: showRaw, setShow: setShowRaw, jsonString } = useJSONToggle(tools); + + return ( +
+ + {showRaw && ( +
+ + } + > +
+ )} +
+ ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/ServerJSONViewer.tsx b/mlflow/server/js/src/mcp-registry/components/ServerJSONViewer.tsx deleted file mode 100644 index 4da6c8181cad4..0000000000000 --- a/mlflow/server/js/src/mcp-registry/components/ServerJSONViewer.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Button, ChevronDownIcon, ChevronRightIcon, Spacer, useDesignSystemTheme } from '@databricks/design-system'; -import { FormattedMessage } from 'react-intl'; - -import type { ServerJSONPayload } from '../types'; - -export const ServerJSONViewer = ({ serverJson }: { serverJson: ServerJSONPayload }) => { - const { theme } = useDesignSystemTheme(); - const [expanded, setExpanded] = useState(false); - const formattedJson = useMemo(() => JSON.stringify(serverJson, null, 2), [serverJson]); - - return ( - <> - - - {expanded && ( - <> - -
-            {formattedJson}
-          
- - )} - - ); -}; diff --git a/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.test.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.test.tsx new file mode 100644 index 0000000000000..173579f11dbe9 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import { DesignSystemProvider } from '@databricks/design-system'; +import { UpdateDescriptionModal } from './UpdateDescriptionModal'; + +const renderModal = (props: Partial> = {}) => + render( + + + + + , + ); + +describe('UpdateDescriptionModal', () => { + it('renders with current description', () => { + renderModal(); + expect(screen.getByDisplayValue('Original description')).toBeInTheDocument(); + }); + + it('does not render when not visible', () => { + renderModal({ visible: false }); + expect(screen.queryByDisplayValue('Original description')).not.toBeInTheDocument(); + }); + + it('calls onUpdate with null for empty description', async () => { + const onUpdate = jest.fn(); + renderModal({ onUpdate, currentDescription: '' }); + await userEvent.click(screen.getByText('Save')); + expect(onUpdate).toHaveBeenCalledWith(null); + }); + + it('calls onUpdate with text for non-empty description', async () => { + const onUpdate = jest.fn(); + renderModal({ onUpdate }); + const textarea = screen.getByDisplayValue('Original description'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'New description'); + await userEvent.click(screen.getByText('Save')); + expect(onUpdate).toHaveBeenCalledWith('New description'); + }); + + it('calls onCancel when cancel is clicked', async () => { + const onCancel = jest.fn(); + renderModal({ onCancel }); + await userEvent.click(screen.getByText('Cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('shows error alert when error is provided', () => { + renderModal({ error: new Error('Update failed') }); + expect(screen.getByText('Update failed')).toBeInTheDocument(); + }); + + it('resets draft when reopened with different description', () => { + const { rerender } = render( + + + + + , + ); + rerender( + + + + + , + ); + expect(screen.getByDisplayValue('Second')).toBeInTheDocument(); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.tsx new file mode 100644 index 0000000000000..fbc9fc8a4b893 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { Alert, Input, Modal, useDesignSystemTheme } from '@databricks/design-system'; +import { FormattedMessage, useIntl } from 'react-intl'; + +export const UpdateDescriptionModal = ({ + visible, + currentDescription, + isLoading, + error, + onUpdate, + onCancel, + onClearError, +}: { + visible: boolean; + currentDescription: string; + isLoading?: boolean; + error?: Error | null; + onUpdate: (description: string | null) => void; + onCancel: () => void; + onClearError?: () => void; +}) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const [draft, setDraft] = useState(currentDescription); + + useEffect(() => { + if (visible) { + setDraft(currentDescription); + } + }, [visible, currentDescription]); + + return ( + + } + visible={visible} + destroyOnClose + confirmLoading={isLoading} + okText={} + cancelText={ + + } + onOk={() => onUpdate(draft || null)} + onCancel={onCancel} + > + {error && ( + + )} + setDraft(e.target.value)} + autoSize={{ minRows: 3, maxRows: 10 }} + placeholder={intl.formatMessage({ + defaultMessage: 'Enter a description', + description: 'Placeholder for MCP server version description textarea', + })} + /> + + ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/UpdateVersionDisplayNameModal.test.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateVersionDisplayNameModal.test.tsx new file mode 100644 index 0000000000000..4e21d88b3571b --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/UpdateVersionDisplayNameModal.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import { DesignSystemProvider } from '@databricks/design-system'; +import { UpdateVersionDisplayNameModal } from './UpdateVersionDisplayNameModal'; + +const renderModal = (props: Partial> = {}) => + render( + + + + + , + ); + +describe('UpdateVersionDisplayNameModal', () => { + it('renders with current display name', () => { + renderModal(); + expect(screen.getByDisplayValue('Original Name')).toBeInTheDocument(); + }); + + it('does not render when not visible', () => { + renderModal({ visible: false }); + expect(screen.queryByDisplayValue('Original Name')).not.toBeInTheDocument(); + }); + + it('calls onUpdate with trimmed value on save', async () => { + const onUpdate = jest.fn(); + renderModal({ onUpdate }); + const input = screen.getByDisplayValue('Original Name'); + await userEvent.clear(input); + await userEvent.type(input, ' New Name '); + await userEvent.click(screen.getByText('Save')); + expect(onUpdate).toHaveBeenCalledWith('New Name'); + }); + + it('shows error alert when error is provided', () => { + renderModal({ error: new Error('Failed to save') }); + expect(screen.getByText('Failed to save')).toBeInTheDocument(); + }); + + it('resets draft when visibility changes', () => { + const { rerender } = render( + + + + + , + ); + rerender( + + + + + , + ); + expect(screen.getByDisplayValue('Updated')).toBeInTheDocument(); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/UpdateVersionDisplayNameModal.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateVersionDisplayNameModal.tsx new file mode 100644 index 0000000000000..b82dc1d4eb942 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/UpdateVersionDisplayNameModal.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { Alert, Input, Modal, useDesignSystemTheme } from '@databricks/design-system'; +import { FormattedMessage, useIntl } from 'react-intl'; + +export const UpdateVersionDisplayNameModal = ({ + visible, + currentDisplayName, + isLoading, + error, + onUpdate, + onCancel, +}: { + visible: boolean; + currentDisplayName: string; + isLoading?: boolean; + error?: Error | null; + onUpdate: (displayName: string) => void; + onCancel: () => void; +}) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const [draft, setDraft] = useState(currentDisplayName); + + useEffect(() => { + if (visible) { + setDraft(currentDisplayName); + } + }, [visible, currentDisplayName]); + + return ( + + } + visible={visible} + onCancel={onCancel} + onOk={() => onUpdate(draft.trim())} + okText={intl.formatMessage({ + defaultMessage: 'Save', + description: 'MCP server update version display name modal confirm button', + })} + confirmLoading={isLoading} + > +
+ {error && ( + + )} + setDraft(e.target.value)} + aria-label={intl.formatMessage({ + defaultMessage: 'Display name', + description: 'Aria label for display name input', + })} + placeholder={intl.formatMessage({ + defaultMessage: 'Enter display name', + description: 'Placeholder for version display name input', + })} + /> +
+
+ ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx deleted file mode 100644 index 51cabfb9893e7..0000000000000 --- a/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - Alert, - Modal, - SimpleSelect, - SimpleSelectOption, - Typography, - useDesignSystemTheme, -} from '@databricks/design-system'; -import { FormattedMessage, useIntl } from 'react-intl'; - -import type { MCPStatus } from '../types'; -import { STATUS_TRANSITIONS } from '../utils'; - -const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - -export const UpdateVersionStatusModal = ({ - visible, - currentStatus, - isLoading, - error, - onUpdate, - onCancel, -}: { - visible: boolean; - currentStatus: MCPStatus; - isLoading?: boolean; - error?: Error | null; - onUpdate: (newStatus: MCPStatus) => void; - onCancel: () => void; -}) => { - const { theme } = useDesignSystemTheme(); - const intl = useIntl(); - const allowedTransitions = STATUS_TRANSITIONS[currentStatus] ?? []; - const isTerminal = allowedTransitions.length === 0; - const [selectedStatus, setSelectedStatus] = useState(allowedTransitions[0]); - - useEffect(() => { - if (visible) { - const transitions = STATUS_TRANSITIONS[currentStatus] ?? []; - setSelectedStatus(transitions[0]); - } - }, [visible, currentStatus]); - - return ( - - } - visible={visible} - onCancel={onCancel} - onOk={() => selectedStatus && onUpdate(selectedStatus)} - okText={intl.formatMessage({ - defaultMessage: 'Update', - description: 'MCP server update version status modal confirm button', - })} - confirmLoading={isLoading} - okButtonProps={{ disabled: isTerminal || !selectedStatus }} - > -
- {error && ( - - )} - {isTerminal && ( - - )} - {!isTerminal && ( - <> -
- - - - {capitalize(currentStatus)} -
-
- - - - setSelectedStatus(target.value as MCPStatus)} - > - {allowedTransitions.map((status) => ( - - {capitalize(status)} - - ))} - -
- - )} -
-
- ); -}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.test.tsx b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.test.tsx new file mode 100644 index 0000000000000..eb223155e6052 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.test.tsx @@ -0,0 +1,222 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import { DesignSystemProvider } from '@databricks/design-system'; +import { QueryClient, QueryClientProvider } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { testRoute, TestRouter } from '../../common/utils/RoutingTestUtils'; +import { setupServer } from '../../common/utils/setup-msw'; +import { + getMockedSearchMCPServersResponse, + getMockedCreateMCPServerVersionResponse, + getMockedCreateMCPServerVersionErrorResponse, + getMockedUpdateMCPServerResponse, + createMockMCPServerVersion, +} from '../test-utils'; +import { useCreateMCPServerVersionModal } from './useCreateMCPServerVersionModal'; + +const VALID_SERVER_JSON = JSON.stringify({ + name: 'io.github.test/server', + version: '1.0.0', + description: 'Test server', +}); + +const setTextareaValue = (element: HTMLElement, value: string) => { + fireEvent.change(element, { target: { value } }); +}; + +const TestComponent = ({ onSuccess }: { onSuccess?: (result: { name: string; version: string }) => void }) => { + const { CreateMCPServerVersionModal, openModal } = useCreateMCPServerVersionModal({ onSuccess }); + return ( + <> + + {CreateMCPServerVersionModal} + + ); +}; + +describe('useCreateMCPServerVersionModal', () => { + const mswServer = setupServer( + getMockedSearchMCPServersResponse([]), + getMockedCreateMCPServerVersionResponse(), + getMockedUpdateMCPServerResponse(), + ); + + const renderModal = (onSuccess?: (result: { name: string; version: string }) => void) => { + const queryClient = new QueryClient(); + render(, { + wrapper: ({ children }) => ( + + + {children} + , + '/', + ), + ]} + initialEntries={['/']} + /> + + ), + }); + }; + + const openModal = async () => { + await userEvent.click(screen.getByText('Open')); + await waitFor(() => { + expect(screen.getByText('Create MCP server')).toBeInTheDocument(); + }); + }; + + it('opens modal with all form fields', async () => { + renderModal(); + await openModal(); + + expect(screen.getByText('Display name:')).toBeInTheDocument(); + expect(screen.getByText(/server\.json:/)).toBeInTheDocument(); + expect(screen.getByText('Status:')).toBeInTheDocument(); + expect(screen.getByText('Source:')).toBeInTheDocument(); + expect(screen.getByText('Tools:')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + it('disables Create button when server.json is empty', async () => { + renderModal(); + await openModal(); + + const createButton = screen.getByRole('button', { name: 'Create' }); + expect(createButton).toBeDisabled(); + }); + + it('shows validation error for invalid JSON', async () => { + renderModal(); + await openModal(); + + const textarea = screen.getByPlaceholderText('Enter your MCP server definition'); + setTextareaValue(textarea, '{invalid json'); + await userEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(screen.getByText('Invalid JSON format in server configuration')).toBeInTheDocument(); + }); + }); + + it('shows validation error when name is missing from server.json', async () => { + renderModal(); + await openModal(); + + const textarea = screen.getByPlaceholderText('Enter your MCP server definition'); + setTextareaValue(textarea, '{"version": "1.0.0"}'); + await userEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(screen.getByText('Server configuration must include a "name" field')).toBeInTheDocument(); + }); + }); + + it('shows validation error when version is missing from server.json', async () => { + renderModal(); + await openModal(); + + const textarea = screen.getByPlaceholderText('Enter your MCP server definition'); + setTextareaValue(textarea, '{"name": "test-server"}'); + await userEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(screen.getByText('Server configuration must include a "version" field')).toBeInTheDocument(); + }); + }); + + it('submits successfully with valid server.json and calls onSuccess', async () => { + const onSuccess = jest.fn(); + const mockVersion = createMockMCPServerVersion({ + name: 'io.github.test/server', + version: '1.0.0', + }); + mswServer.use(getMockedCreateMCPServerVersionResponse(mockVersion)); + + renderModal(onSuccess); + await openModal(); + + const textarea = screen.getByPlaceholderText('Enter your MCP server definition'); + setTextareaValue(textarea, VALID_SERVER_JSON); + await userEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith({ + name: 'io.github.test/server', + version: '1.0.0', + }); + }); + }); + + it('displays API error when creation fails', async () => { + mswServer.use(getMockedCreateMCPServerVersionErrorResponse(409, 'Version already exists')); + + renderModal(); + await openModal(); + + const textarea = screen.getByPlaceholderText('Enter your MCP server definition'); + setTextareaValue(textarea, VALID_SERVER_JSON); + await userEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(screen.getByText('Version already exists')).toBeInTheDocument(); + }); + }); + + it('closes modal on cancel', async () => { + renderModal(); + await openModal(); + + await userEvent.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('clears validation errors when form fields change', async () => { + renderModal(); + await openModal(); + + // Enter invalid JSON and submit to trigger a validation error + const textarea = screen.getByPlaceholderText('Enter your MCP server definition'); + setTextareaValue(textarea, '{bad json'); + await userEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => { + expect(screen.getByText('Invalid JSON format in server configuration')).toBeInTheDocument(); + }); + + // Change the textarea value to clear the error + setTextareaValue(textarea, 'something else'); + + await waitFor(() => { + expect(screen.queryByText('Invalid JSON format in server configuration')).not.toBeInTheDocument(); + }); + }); + + it('resets form state when reopened', async () => { + renderModal(); + await openModal(); + + // Type something in display name + const displayNameInput = screen.getByPlaceholderText('Human-readable label for this server'); + await userEvent.type(displayNameInput, 'My Server'); + + // Close and reopen + await userEvent.click(screen.getByText('Cancel')); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + await openModal(); + + // Display name should be empty + const freshInput = screen.getByPlaceholderText('Human-readable label for this server'); + expect(freshInput).toHaveValue(''); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx new file mode 100644 index 0000000000000..f236c62f3076e --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx @@ -0,0 +1,359 @@ +import { + Alert, + Button, + FormUI, + Input, + Modal, + PlusIcon, + RHFControlledComponents, + SimpleSelect, + SimpleSelectOption, + Spacer, + Tooltip, + useDesignSystemTheme, +} from '@databricks/design-system'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import type { MCPServerVersion, MCPStatus } from '../types'; +import { useCreateMCPServerVersionMutation } from './useCreateMCPServerVersionMutation'; +import { validateServerJson, validateToolsJson } from '../utils'; +import { KeyValueTag } from '../../common/components/KeyValueTag'; +import type { KeyValueEntity } from '../../common/types'; +import { TagKeySelectDropdown } from '../../common/components/TagSelectDropdown'; + +interface CreateMCPServerVersionFormState { + displayName: string; + serverJsonText: string; + status: MCPStatus; + source: string; + toolsText: string; + tags: Record; +} + +const INITIAL_FORM_STATE: CreateMCPServerVersionFormState = { + displayName: '', + serverJsonText: '', + status: 'draft', + source: '', + toolsText: '', + tags: {}, +}; + +export const useCreateMCPServerVersionModal = ({ + onSuccess, + serverName, + latestVersion, +}: { + onSuccess?: (result: { name: string; version: string }) => void; + serverName?: string; + latestVersion?: MCPServerVersion; +} = {}) => { + const isVersionMode = Boolean(serverName); + const [open, setOpen] = useState(false); + const [formState, setFormState] = useState(INITIAL_FORM_STATE); + const [validationError, setValidationError] = useState(undefined); + const intl = useIntl(); + const { theme } = useDesignSystemTheme(); + + const { mutate, error: mutationError, reset: resetMutation, isLoading } = useCreateMCPServerVersionMutation(); + + const tagForm = useForm({ defaultValues: { key: undefined, value: '' } }); + const tagFormValues = tagForm.watch(); + + const handleAddTag = () => { + if (!tagFormValues.key?.trim()) return; + setFormState((prev) => ({ + ...prev, + tags: { ...prev.tags, [tagFormValues.key.trim()]: tagFormValues.value?.trim() || '' }, + })); + tagForm.reset(); + }; + + const handleRemoveTag = (key: string) => { + setFormState((prev) => { + const next = { ...prev.tags }; + delete next[key]; + return { ...prev, tags: next }; + }); + }; + + const handleFieldChange = ( + field: K, + value: CreateMCPServerVersionFormState[K], + ) => { + setFormState((prev) => ({ ...prev, [field]: value })); + if (validationError) { + setValidationError(undefined); + } + }; + + const handleSubmit = () => { + const serverJsonResult = validateServerJson(formState.serverJsonText); + if (!serverJsonResult.valid || !serverJsonResult.parsed) { + setValidationError(serverJsonResult.error); + return; + } + + let parsedTools; + if (formState.toolsText.trim()) { + const toolsResult = validateToolsJson(formState.toolsText); + if (!toolsResult.valid) { + setValidationError(toolsResult.error); + return; + } + parsedTools = toolsResult.parsed as { name: string; [key: string]: unknown }[]; + } + + setValidationError(undefined); + + const tagsToSet = Object.keys(formState.tags).length > 0 ? formState.tags : undefined; + + mutate( + { + serverJson: serverJsonResult.parsed, + displayName: formState.displayName.trim() || undefined, + isNewServer: !isVersionMode, + status: formState.status, + source: formState.source.trim() || undefined, + tools: parsedTools, + tags: tagsToSet, + }, + { + onSuccess: (data) => { + onSuccess?.({ name: data.name, version: data.version }); + setOpen(false); + }, + }, + ); + }; + + const displayError = validationError || mutationError?.message; + + const modalElement = ( + setOpen(false)} + title={ + isVersionMode ? ( + + ) : ( + + ) + } + okText={ + + } + okButtonProps={{ + loading: isLoading, + disabled: !formState.serverJsonText.trim(), + }} + onOk={handleSubmit} + cancelText={ + + } + size="wide" + > + {displayError && ( + <> + + + + )} + + + + handleFieldChange('displayName', e.target.value)} + placeholder={intl.formatMessage({ + defaultMessage: 'Human-readable label for this server', + description: 'Placeholder for display name in create MCP server modal', + })} + /> + + + + * + + handleFieldChange('serverJsonText', e.target.value)} + placeholder={intl.formatMessage({ + defaultMessage: 'Enter your MCP server definition', + description: 'Placeholder for server.json in create MCP server modal', + })} + autoSize={{ minRows: 6, maxRows: 14 }} + css={{ fontFamily: 'monospace' }} + /> + + + + * + + handleFieldChange('status', target.value as MCPStatus)} + > + + + + + + + + + + + + + + + handleFieldChange('source', e.target.value)} + placeholder={intl.formatMessage({ + defaultMessage: 'https://github.com/org/repo', + description: 'Placeholder for source in create MCP server modal', + })} + /> + + + + + handleFieldChange('toolsText', e.target.value)} + placeholder='[{"name": "search", "description": "Search the web"}]' + autoSize={{ minRows: 3, maxRows: 8 }} + css={{ fontFamily: 'monospace' }} + /> + + + {isVersionMode ? ( + + ) : ( + + )} + +
+
+
+ +
+
+ +
+
+ + + +
+ {Object.keys(formState.tags).length > 0 && ( +
+ {Object.entries(formState.tags).map(([key, value]) => ( + handleRemoveTag(key)} key={key} /> + ))} +
+ )} +
+ ); + + const openModal = () => { + resetMutation(); + setValidationError(undefined); + + if (latestVersion) { + setFormState({ + displayName: '', + serverJsonText: JSON.stringify(latestVersion.server_json, null, 2), + status: latestVersion.status === 'deleted' ? 'draft' : latestVersion.status, + source: latestVersion.source || '', + toolsText: latestVersion.tools?.length ? JSON.stringify(latestVersion.tools, null, 2) : '', + tags: { ...latestVersion.tags }, + }); + } else { + setFormState({ + ...INITIAL_FORM_STATE, + serverJsonText: serverName ? JSON.stringify({ name: serverName }, null, 2) : '', + }); + } + tagForm.reset(); + setOpen(true); + }; + + return { CreateMCPServerVersionModal: modalElement, openModal }; +}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts new file mode 100644 index 0000000000000..6915a4651312b --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { MCPRegistryApi } from '../api'; +import type { MCPServerVersion, MCPStatus, MCPTool, ServerJSONPayload } from '../types'; +import { MCP_QUERY_KEYS } from '../utils'; + +type CreateMCPServerVersionPayload = { + serverJson: ServerJSONPayload; + displayName?: string; + isNewServer?: boolean; + status?: MCPStatus; + source?: string; + tools?: MCPTool[]; + tags?: Record; +}; + +export const useCreateMCPServerVersionMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ serverJson, displayName, isNewServer, status, source, tools, tags }) => { + const name = serverJson.name; + const version = await MCPRegistryApi.createMCPServerVersion(name, { + server_json: serverJson, + display_name: displayName || undefined, + status, + source, + tools, + }); + + try { + if (isNewServer) { + const serverDisplayName = displayName || serverJson.title; + if (serverDisplayName || serverJson.description) { + await MCPRegistryApi.updateMCPServer(name, { + display_name: serverDisplayName || undefined, + description: serverJson.description || undefined, + }); + } + } + + if (tags) { + const setTag = isNewServer + ? (key: string, value: string) => MCPRegistryApi.setMCPServerTag(name, { key, value }) + : (key: string, value: string) => + MCPRegistryApi.setMCPServerVersionTag(name, version.version, { key, value }); + await Promise.all(Object.entries(tags).map(([key, value]) => setTag(key, value))); + } + } catch { + // Version was created successfully; metadata/tag failures are non-fatal + } + + return version; + }, + onSuccess: (_data, { serverJson }) => { + const name = serverJson.name; + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVERS_LIST]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, name]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_VERSIONS, name]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_LATEST_VERSION, name]); + }, + }); +}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts index 6c0d5c13c226f..06489d71614e1 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts @@ -3,6 +3,7 @@ import { MCPRegistryApi } from '../api'; import type { MCPAccessBinding, MCPServer, + MCPServerVersion, SearchMCPServerVersionsResponse, SearchMCPAccessBindingsResponse, } from '../types'; @@ -18,7 +19,7 @@ export const useMCPServerQuery = (name: string) => { export const useMCPServerVersionsQuery = (name: string) => { const queryResult = useQuery([MCP_QUERY_KEYS.SERVER_VERSIONS, name], { - queryFn: () => MCPRegistryApi.searchMCPServerVersions(name), + queryFn: () => MCPRegistryApi.searchMCPServerVersions(name, { order_by: ['created_at DESC'] }), retry: false, enabled: Boolean(name), }); @@ -29,6 +30,23 @@ export const useMCPServerVersionsQuery = (name: string) => { }; }; +export const useLatestMCPServerVersionQuery = (name: string, enabled = true) => { + return useQuery([MCP_QUERY_KEYS.SERVER_LATEST_VERSION, name], { + queryFn: async () => { + try { + return await MCPRegistryApi.getLatestMCPServerVersion(name); + } catch (e: unknown) { + if (e instanceof Error && (e.message.includes('404') || e.message.includes('RESOURCE_DOES_NOT_EXIST'))) { + return undefined; + } + throw e; + } + }, + retry: false, + enabled: Boolean(name) && enabled, + }); +}; + export const useMCPAccessBindingQuery = (serverName: string, bindingId: string) => { return useQuery([MCP_QUERY_KEYS.BINDING_DETAIL, serverName, bindingId], { queryFn: () => MCPRegistryApi.getMCPAccessBinding(serverName, Number(bindingId)), diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.test.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.test.ts new file mode 100644 index 0000000000000..a9274e69cecff --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from '@jest/globals'; +import { renderHook, act } from '@testing-library/react'; +import { useMCPServerDetailViewState, MCPServerDetailViewMode } from './useMCPServerDetailViewState'; +import { createMockMCPServerVersion } from '../test-utils'; + +const v1 = createMockMCPServerVersion({ version: '1' }); +const v2 = createMockMCPServerVersion({ version: '2' }); +const v3 = createMockMCPServerVersion({ version: '3' }); + +describe('useMCPServerDetailViewState', () => { + it('starts in preview mode with no selection', () => { + const { result } = renderHook(() => useMCPServerDetailViewState([v1, v2])); + expect(result.current.viewState.mode).toBe(MCPServerDetailViewMode.PREVIEW); + expect(result.current.selectedVersion).toBeUndefined(); + }); + + it('setSelectedVersion updates the selected version', () => { + const { result } = renderHook(() => useMCPServerDetailViewState([v1, v2])); + act(() => result.current.setSelectedVersion('1')); + expect(result.current.selectedVersion).toBe('1'); + }); + + it('setPreviewMode clears comparedVersion and sets preview mode', () => { + const { result } = renderHook(() => useMCPServerDetailViewState([v1, v2])); + act(() => result.current.setCompareMode()); + expect(result.current.viewState.mode).toBe(MCPServerDetailViewMode.COMPARE); + + act(() => result.current.setPreviewMode()); + expect(result.current.viewState.mode).toBe(MCPServerDetailViewMode.PREVIEW); + expect(result.current.viewState.comparedVersion).toBeUndefined(); + }); + + it('setCompareMode auto-selects baseline and compared versions', () => { + const { result } = renderHook(() => useMCPServerDetailViewState([v1, v2, v3])); + act(() => result.current.setSelectedVersion('1')); + act(() => result.current.setCompareMode()); + + expect(result.current.viewState.mode).toBe(MCPServerDetailViewMode.COMPARE); + expect(result.current.selectedVersion).toBeDefined(); + expect(result.current.viewState.comparedVersion).toBeDefined(); + expect(result.current.selectedVersion).not.toBe(result.current.viewState.comparedVersion); + }); + + it('switchSides swaps baseline and compared versions', () => { + const { result } = renderHook(() => useMCPServerDetailViewState([v1, v2])); + act(() => result.current.setSelectedVersion('1')); + act(() => result.current.setCompareMode()); + + const before = { + selected: result.current.selectedVersion, + compared: result.current.viewState.comparedVersion, + }; + + act(() => result.current.switchSides()); + + expect(result.current.selectedVersion).toBe(before.compared); + expect(result.current.viewState.comparedVersion).toBe(before.selected); + }); + + it('auto-swaps when selecting the same version as compared', () => { + const { result } = renderHook(() => useMCPServerDetailViewState([v1, v2])); + act(() => result.current.setSelectedVersion('1')); + act(() => { + result.current.setCompareMode(); + }); + + const comparedBefore = result.current.viewState.comparedVersion; + act(() => result.current.setSelectedVersion(comparedBefore!)); + + expect(result.current.selectedVersion).toBe(comparedBefore); + expect(result.current.viewState.comparedVersion).not.toBe(comparedBefore); + }); + + it('auto-swaps when setting compared to the same version as selected', () => { + const { result } = renderHook(() => useMCPServerDetailViewState([v1, v2])); + act(() => result.current.setSelectedVersion('1')); + act(() => result.current.setCompareMode()); + + const selectedBefore = result.current.selectedVersion!; + act(() => result.current.setComparedVersion(selectedBefore)); + + expect(result.current.viewState.comparedVersion).toBe(selectedBefore); + expect(result.current.selectedVersion).not.toBe(selectedBefore); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.ts new file mode 100644 index 0000000000000..b9e986f75e83c --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.ts @@ -0,0 +1,91 @@ +import { useCallback, useReducer } from 'react'; +import { first } from 'lodash'; +import type { MCPServerVersion } from '../types'; + +export enum MCPServerDetailViewMode { + PREVIEW = 'preview', + COMPARE = 'compare', +} + +interface State { + mode: MCPServerDetailViewMode; + selectedVersion?: string; + comparedVersion?: string; +} + +type ViewAction = + | { type: 'setPreviewMode' } + | { type: 'setCompareMode'; selectedVersion?: string; comparedVersion?: string } + | { type: 'setComparedVersion'; comparedVersion?: string } + | { type: 'setSelectedVersion'; version?: string } + | { type: 'switchSides' }; + +const viewStateReducer = (state: State, action: ViewAction): State => { + switch (action.type) { + case 'setPreviewMode': + return { ...state, mode: MCPServerDetailViewMode.PREVIEW, comparedVersion: undefined }; + case 'setCompareMode': + return { + ...state, + mode: MCPServerDetailViewMode.COMPARE, + selectedVersion: action.selectedVersion, + comparedVersion: action.comparedVersion, + }; + case 'setComparedVersion': + if (action.comparedVersion === state.selectedVersion) { + return { ...state, comparedVersion: action.comparedVersion, selectedVersion: state.comparedVersion }; + } + return { ...state, comparedVersion: action.comparedVersion }; + case 'setSelectedVersion': + if (action.version === state.comparedVersion) { + return { ...state, selectedVersion: action.version, comparedVersion: state.selectedVersion }; + } + return { ...state, selectedVersion: action.version }; + case 'switchSides': + return { ...state, selectedVersion: state.comparedVersion, comparedVersion: state.selectedVersion }; + default: + return state; + } +}; + +export const useMCPServerDetailViewState = (versions?: MCPServerVersion[]) => { + const [state, dispatch] = useReducer(viewStateReducer, { + mode: MCPServerDetailViewMode.PREVIEW, + selectedVersion: undefined, + comparedVersion: undefined, + }); + + const setSelectedVersion = useCallback((version?: string) => { + dispatch({ type: 'setSelectedVersion', version }); + }, []); + + const setPreviewMode = useCallback(() => { + dispatch({ type: 'setPreviewMode' }); + }, []); + + const setCompareMode = useCallback(() => { + const latestVersion = first(versions)?.version; + const baselineVersion = state.selectedVersion ?? versions?.[1]?.version; + const comparedVersion = baselineVersion === latestVersion ? versions?.[1]?.version : latestVersion; + + dispatch({ type: 'setCompareMode', selectedVersion: baselineVersion, comparedVersion }); + }, [versions, state.selectedVersion]); + + const setComparedVersion = useCallback((comparedVersion: string) => { + dispatch({ type: 'setComparedVersion', comparedVersion }); + }, []); + + const switchSides = useCallback(() => { + dispatch({ type: 'switchSides' }); + }, []); + + return { + viewState: { mode: state.mode, comparedVersion: state.comparedVersion }, + selectedVersion: state.selectedVersion, + setSelectedVersion, + setPreviewMode, + setCompareMode, + setComparedVersion, + switchSides, + }; +}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts index b11fbb2b86a23..2125fcd31b301 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; import { MCPRegistryApi } from '../api'; -import type { MCPStatus } from '../types'; +import type { MCPStatus, MCPTool } from '../types'; import { MCP_QUERY_KEYS } from '../utils'; const useInvalidateServerQueries = () => { @@ -9,16 +9,51 @@ const useInvalidateServerQueries = () => { queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_VERSIONS, serverName]); queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_BINDINGS, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_LATEST_VERSION, serverName]); queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVERS_LIST]); queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDINGS_LIST]); }; }; -export const useUpdateMCPServerVersionStatus = (serverName: string) => { +type UpdateMCPServerVersionPayload = { + version: string; + displayName?: string; + status?: MCPStatus; + tools?: MCPTool[] | null; + aliases?: { add: string[]; remove: string[] }; +}; + +export const useUpdateMCPServerVersion = (serverName: string) => { const invalidate = useInvalidateServerQueries(); - return useMutation({ - mutationFn: ({ version, status }) => MCPRegistryApi.updateMCPServerVersion(serverName, version, { status }), + return useMutation({ + mutationFn: async ({ version, displayName, status, tools, aliases }) => { + const versionUpdate: Record = {}; + if (displayName !== undefined) { + versionUpdate['display_name'] = displayName || null; + } + if (status !== undefined) { + versionUpdate['status'] = status; + } + if (tools !== undefined) { + versionUpdate['tools'] = tools; + } + + const promises: Promise[] = []; + + if (Object.keys(versionUpdate).length > 0) { + promises.push(MCPRegistryApi.updateMCPServerVersion(serverName, version, versionUpdate)); + } + + if (aliases) { + promises.push( + ...aliases.add.map((alias) => MCPRegistryApi.setMCPServerAlias(serverName, { alias, version })), + ...aliases.remove.map((alias) => MCPRegistryApi.deleteMCPServerAlias(serverName, alias)), + ); + } + + await Promise.all(promises); + }, onSuccess: () => invalidate(serverName), }); }; @@ -32,6 +67,16 @@ export const useDeleteMCPServerVersion = (serverName: string) => { }); }; +export const useUpdateMCPServerDisplayName = (serverName: string) => { + const invalidate = useInvalidateServerQueries(); + + return useMutation({ + mutationFn: (displayName: string | null) => + MCPRegistryApi.updateMCPServer(serverName, { display_name: displayName }), + onSuccess: () => invalidate(serverName), + }); +}; + export const useDeleteMCPServer = () => { const queryClient = useQueryClient(); diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts index 497b63fc39dac..02f48a762d6f4 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts @@ -12,7 +12,7 @@ export const useMCPServersListQuery = ({ storageKey: 'mcp_registry.page_size', queryFn: ({ searchFilter: filter, pageToken, pageSize }) => MCPRegistryApi.searchMCPServers({ - filter_string: buildSearchFilterClause(filter, 'name'), + filter_string: buildSearchFilterClause(filter, 'display_name'), page_token: pageToken, max_results: pageSize, }), diff --git a/mlflow/server/js/src/mcp-registry/hooks/useSelectedMCPServerVersion.ts b/mlflow/server/js/src/mcp-registry/hooks/useSelectedMCPServerVersion.ts deleted file mode 100644 index 80ca9c88509b3..0000000000000 --- a/mlflow/server/js/src/mcp-registry/hooks/useSelectedMCPServerVersion.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useCallback } from 'react'; -import { useSearchParams } from '@mlflow/mlflow/src/common/utils/RoutingUtils'; - -const VERSION_QUERY_PARAM = 'version'; - -export const useSelectedMCPServerVersion = (latestVersion?: string) => { - const [searchParams, setSearchParams] = useSearchParams(); - - const selectedVersion = searchParams.get(VERSION_QUERY_PARAM) ?? latestVersion; - - const setSelectedVersion = useCallback( - (version: string | undefined) => { - setSearchParams( - (params) => { - if (version === undefined) { - params.delete(VERSION_QUERY_PARAM); - return params; - } - params.set(VERSION_QUERY_PARAM, version); - return params; - }, - { replace: true }, - ); - }, - [setSearchParams], - ); - - return [selectedVersion, setSelectedVersion] as const; -}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useUpdateMCPServerTags.ts b/mlflow/server/js/src/mcp-registry/hooks/useUpdateMCPServerTags.ts new file mode 100644 index 0000000000000..85653483f2b78 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useUpdateMCPServerTags.ts @@ -0,0 +1,57 @@ +import { useCallback } from 'react'; +import { useMutation, useQueryClient } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { useEditKeyValueTagsModal } from '../../common/hooks/useEditKeyValueTagsModal'; +import { diffCurrentAndNewTags } from '../../common/utils/TagUtils'; +import { MCPRegistryApi } from '../api'; +import { MCP_QUERY_KEYS, tagsRecordToArray } from '../utils'; +import type { MCPServer } from '../types'; + +type MCPServerTagEntity = { name: string; tags?: { key: string; value: string }[] }; + +type UpdateTagsPayload = { + serverName: string; + toAdd: { key: string; value: string }[]; + toDelete: { key: string }[]; +}; + +export const useUpdateMCPServerTags = ({ onSuccess }: { onSuccess?: () => void } = {}) => { + const queryClient = useQueryClient(); + + const updateMutation = useMutation({ + mutationFn: async ({ serverName, toAdd, toDelete }) => + Promise.all([ + ...toAdd.map(({ key, value }) => MCPRegistryApi.setMCPServerTag(serverName, { key, value })), + ...toDelete.map(({ key }) => MCPRegistryApi.deleteMCPServerTag(serverName, key)), + ]), + onSuccess: (_data, { serverName }) => { + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVERS_LIST]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); + onSuccess?.(); + }, + }); + + const { EditTagsModal, showEditTagsModal } = useEditKeyValueTagsModal({ + valueRequired: true, + saveTagsHandler: (entity, currentTags, newTags) => { + const { addedOrModifiedTags, deletedTags } = diffCurrentAndNewTags(currentTags, newTags); + return new Promise((resolve, reject) => { + updateMutation.mutate( + { serverName: entity.name, toAdd: addedOrModifiedTags, toDelete: deletedTags }, + { onSuccess: () => resolve(), onError: reject }, + ); + }); + }, + }); + + const showEditServerTagsModal = useCallback( + (server: MCPServer) => { + showEditTagsModal({ + name: server.name, + tags: tagsRecordToArray(server.tags), + }); + }, + [showEditTagsModal], + ); + + return { EditTagsModal, showEditServerTagsModal }; +}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useUpdateMCPServerVersionMetadataModal.tsx b/mlflow/server/js/src/mcp-registry/hooks/useUpdateMCPServerVersionMetadataModal.tsx new file mode 100644 index 0000000000000..932cfb695c405 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useUpdateMCPServerVersionMetadataModal.tsx @@ -0,0 +1,89 @@ +import { useMutation, useQueryClient } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { useEditKeyValueTagsModal } from '../../common/hooks/useEditKeyValueTagsModal'; +import { MCPRegistryApi } from '../api'; +import type { MCPServerVersion } from '../types'; +import { MCP_QUERY_KEYS, tagsRecordToArray } from '../utils'; +import { useCallback } from 'react'; +import { diffCurrentAndNewTags } from '../../common/utils/TagUtils'; +import { FormattedMessage, useIntl } from 'react-intl'; +import type { KeyValueEntity } from '../../common/types'; + +type UpdateMCPServerVersionMetadataPayload = { + serverName: string; + version: string; + toAdd: { key: string; value: string }[]; + toDelete: { key: string }[]; +}; + +export const useUpdateMCPServerVersionMetadataModal = ({ + serverName, + onSuccess, +}: { + serverName: string; + onSuccess?: () => void; +}) => { + const queryClient = useQueryClient(); + const intl = useIntl(); + + const updateMutation = useMutation({ + mutationFn: async ({ serverName: name, version, toAdd, toDelete }) => { + return Promise.all([ + ...toAdd.map(({ key, value }) => MCPRegistryApi.setMCPServerVersionTag(name, version, { key, value })), + ...toDelete.map(({ key }) => MCPRegistryApi.deleteMCPServerVersionTag(name, version, key)), + ]); + }, + }); + + const { + EditTagsModal: EditMCPServerVersionMetadataModal, + showEditTagsModal, + isLoading, + } = useEditKeyValueTagsModal<{ version: string; tags?: KeyValueEntity[] }>({ + title: ( + + ), + saveButtonLabel: intl.formatMessage({ + defaultMessage: 'Save metadata', + description: 'Button label for saving metadata in the MCP server version metadata modal', + }), + valueRequired: true, + saveTagsHandler: (editedVersion, currentTags, newTags) => { + const { addedOrModifiedTags, deletedTags } = diffCurrentAndNewTags(currentTags, newTags); + + return new Promise((resolve, reject) => { + updateMutation.mutate( + { + serverName, + version: editedVersion.version, + toAdd: addedOrModifiedTags, + toDelete: deletedTags, + }, + { + onSuccess: () => { + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_VERSIONS, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_LATEST_VERSION, serverName]); + resolve(); + onSuccess?.(); + }, + onError: reject, + }, + ); + }); + }, + }); + + const showEditMetadataModal = useCallback( + (version: MCPServerVersion) => + showEditTagsModal({ + version: version.version, + tags: tagsRecordToArray(version.tags), + }), + [showEditTagsModal], + ); + + return { EditMCPServerVersionMetadataModal, showEditMetadataModal, isLoading }; +}; diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.test.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.test.tsx index ef1291cc85bc1..c3b452265b4f5 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.test.tsx @@ -87,7 +87,7 @@ describe('MCPAccessBindingDetailPage', () => { expect(screen.getByText('Transport:')).toBeInTheDocument(); expect(screen.getByText('MCP server:')).toBeInTheDocument(); expect(screen.getAllByText('io.test/server').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('Version:')).toBeInTheDocument(); + expect(screen.getByText('Version/Alias:')).toBeInTheDocument(); expect(screen.getByText('Last updated:')).toBeInTheDocument(); expect(screen.getByText('Created at:')).toBeInTheDocument(); }); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx index fb28fc585f691..77feaadabed38 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx @@ -220,7 +220,7 @@ const MCPAccessBindingDetailPage = () => { - + {target} @@ -311,4 +311,4 @@ const MCPAccessBindingDetailPage = () => { ); }; -export default withErrorBoundary(ErrorUtils.mlflowServices.EXPERIMENTS, MCPAccessBindingDetailPage); +export default withErrorBoundary(ErrorUtils.mlflowServices.MCP_REGISTRY, MCPAccessBindingDetailPage); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx index 9033dff7c55bd..dac6dc0705856 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx @@ -15,6 +15,8 @@ import { getMockedSearchMCPServersResponse, getMockedSearchMCPServersErrorResponse, getMockedSearchMCPAccessBindingsAllResponse, + getMockedCreateMCPServerVersionResponse, + getMockedUpdateMCPServerResponse, } from '../test-utils'; describe('MCPRegistryPage', () => { @@ -114,13 +116,21 @@ describe('MCPRegistryPage', () => { ); renderPage(['/?tab=servers']); + await waitFor(() => { + expect(screen.getByText('io.github.demo/raw-name-only')).toBeInTheDocument(); + }); + + capturedFilterString = null; const searchInput = screen.getByPlaceholderText('Search MCP servers by name'); await userEvent.type(searchInput, 'raw'); - await waitFor(() => { - expect(capturedFilterString).toBe("name ILIKE '%raw%'"); - }); - }); + await waitFor( + () => { + expect(capturedFilterString).toBe("display_name LIKE '%raw%'"); + }, + { timeout: 10000 }, + ); + }, 15000); it('shows Create MCP server button when servers exist', async () => { const servers = [createMockMCPServer({ name: 's1', display_name: 'Server 1' })]; @@ -363,4 +373,45 @@ describe('MCPRegistryPage', () => { }); expect(screen.getByPlaceholderText('Search access bindings')).toBeInTheDocument(); }); + + it('opens create modal when header create button is clicked', async () => { + const servers = [createMockMCPServer({ name: 's1', display_name: 'Server 1' })]; + server.use( + getMockedSearchMCPServersResponse(servers), + getMockedSearchMCPAccessBindingsAllResponse([]), + getMockedCreateMCPServerVersionResponse(), + getMockedUpdateMCPServerResponse(), + ); + renderPage(['/?tab=servers']); + await waitFor(() => { + expect(screen.getByText('MCP Registry')).toBeInTheDocument(); + }); + + const createButton = screen.getAllByText('Create MCP server')[0]; + await userEvent.click(createButton); + + await waitFor(() => { + expect(screen.getByText(/server\.json:/)).toBeInTheDocument(); + }); + }); + + it('opens create modal from empty state button', async () => { + server.use( + getMockedSearchMCPServersResponse([]), + getMockedSearchMCPAccessBindingsAllResponse([]), + getMockedCreateMCPServerVersionResponse(), + getMockedUpdateMCPServerResponse(), + ); + renderPage(['/?tab=servers']); + await waitFor(() => { + expect(screen.getByText('Create and manage MCP servers using MLflow.')).toBeInTheDocument(); + }); + + const createButtons = screen.getAllByText('Create MCP server'); + await userEvent.click(createButtons[createButtons.length - 1]); + + await waitFor(() => { + expect(screen.getByText('Display name:')).toBeInTheDocument(); + }); + }); }); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx index 3beef6117ab51..d5fd476f98ced 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx @@ -22,17 +22,21 @@ import { FormattedMessage, useIntl } from 'react-intl'; import { ScrollablePageWrapper } from '../../common/components/ScrollablePageWrapper'; import { withErrorBoundary } from '../../common/utils/withErrorBoundary'; import ErrorUtils from '../../common/utils/ErrorUtils'; -import { useSearchParams } from '../../common/utils/RoutingUtils'; +import { useNavigate, useSearchParams } from '../../common/utils/RoutingUtils'; import { ModelSearchInputHelpTooltip } from '../../model-registry/components/model-list/ModelListFilters'; import { useMCPServersListQuery } from '../hooks/useMCPServersListQuery'; +import { useUpdateMCPServerTags } from '../hooks/useUpdateMCPServerTags'; +import { useCreateMCPServerVersionModal } from '../hooks/useCreateMCPServerVersionModal'; import { useMCPAccessBindingsListQuery } from '../hooks/useMCPAccessBindingsListQuery'; import { MCPServerCardGrid } from '../components/MCPServerCardGrid'; import { MCPServerListTable } from '../components/MCPServerListTable'; -import type { MCPAccessBinding } from '../types'; import { emptyCenterStyles } from '../utils'; +import MCPRegistryRoutes from '../routes'; +import type { MCPAccessBinding } from '../types'; import { MCPAccessBindingCardGrid } from '../components/MCPAccessBindingCardGrid'; import { MCPAccessBindingListTable } from '../components/MCPAccessBindingListTable'; import { AccessBindingModal } from '../components/AccessBindingModal'; +import type { MCPServer } from '../types'; import { useDebounce } from 'use-debounce'; type ViewMode = 'list' | 'grid'; @@ -60,6 +64,7 @@ const MCPRegistryPage = () => { onNextPage, onPreviousPage, pageSizeSelect, + refetch, } = useMCPServersListQuery({ searchFilter: activeTab === 'servers' ? effectiveFilter : undefined, }); @@ -78,6 +83,15 @@ const MCPRegistryPage = () => { enabled: activeTab === 'bindings', }); + const navigate = useNavigate(); + const { CreateMCPServerVersionModal, openModal } = useCreateMCPServerVersionModal({ + onSuccess: ({ name }) => navigate(MCPRegistryRoutes.getMCPServerDetailRoute(name)), + }); + + const { EditTagsModal, showEditServerTagsModal: handleEditTags } = useUpdateMCPServerTags({ + onSuccess: refetch, + }); + const handleTabChange = useCallback( (e: RadioChangeEvent) => { const value = e.target.value as ActiveTab; @@ -108,7 +122,7 @@ const MCPRegistryPage = () => { ) : !isServersEmpty ? ( - ) : null; @@ -130,7 +144,7 @@ const MCPRegistryPage = () => { componentId="mlflow.mcp_registry.empty_state.create_server" type="primary" icon={} - disabled + onClick={openModal} > @@ -140,275 +154,280 @@ const MCPRegistryPage = () => { ); return ( - - -
- - + <> + + +
+ + + + - - - } - buttons={createButton} - /> - -
- - - - - - - - + } + buttons={createButton} + /> + +
+ + + + + + + + - {activeTab === 'servers' && ( -
-
-
- - setSearchFilter(e.target.value)} - suffix={} - /> - -
- setViewMode(e.target.value as ViewMode)} - componentId="mlflow.mcp_registry.view_toggle" + {activeTab === 'servers' && ( +
+
- } /> - } /> - -
- {error?.message && ( - - )} - {viewMode === 'grid' ? ( - isServersEmpty ? ( - serversEmptyState +
+ + setSearchFilter(e.target.value)} + suffix={} + /> + +
+ setViewMode(e.target.value as ViewMode)} + componentId="mlflow.mcp_registry.view_toggle" + > + } /> + } /> + +
+ {error?.message && ( + + )} + {viewMode === 'grid' ? ( + isServersEmpty ? ( + serversEmptyState + ) : ( + + ) ) : ( - - ) - ) : ( - - )} -
- )} + )} +
+ )} - {activeTab === 'bindings' && ( -
-
-
- - setSearchFilter(e.target.value)} - suffix={null} - /> - -
- setViewMode(e.target.value as ViewMode)} - componentId="mlflow.mcp_registry.bindings.view_toggle" + {activeTab === 'bindings' && ( +
+
- } /> - } /> - -
- - - - {bindingsError?.message && ( - - )} - {isServersEmpty && viewMode === 'grid' ? ( -
- - } - description={ - + + setSearchFilter(e.target.value)} + suffix={null} /> - } - button={ -
+ setViewMode(e.target.value as ViewMode)} + componentId="mlflow.mcp_registry.bindings.view_toggle" + > + } /> + } /> + +
+ + + + {bindingsError?.message && ( + + )} + {isServersEmpty && viewMode === 'grid' ? ( +
+ - - } - /> -
- ) : viewMode === 'grid' ? ( - { - setEditingBinding(undefined); - setBindingModalOpen(true); - }} - /> - ) : ( - { - setEditingBinding(undefined); - setBindingModalOpen(true); - }} - onEditBinding={(binding) => { - setEditingBinding(binding); - setBindingModalOpen(true); - }} - emptyStateOverride={ - isServersEmpty ? ( - + } + button={ + + } + /> +
+ ) : viewMode === 'grid' ? ( + { + setEditingBinding(undefined); + setBindingModalOpen(true); + }} + /> + ) : ( + { + setEditingBinding(undefined); + setBindingModalOpen(true); + }} + onEditBinding={(binding) => { + setEditingBinding(binding); + setBindingModalOpen(true); + }} + emptyStateOverride={ + isServersEmpty ? ( + - - } - /> - ) : undefined - } - /> - )} -
- )} -
- { - setEditingBinding(undefined); - setBindingModalOpen(false); - }} - editBinding={editingBinding} - /> - + } + description={ + + } + button={ + + } + /> + ) : undefined + } + /> + )} +
+ )} +
+ { + setEditingBinding(undefined); + setBindingModalOpen(false); + }} + editBinding={editingBinding} + /> + + {CreateMCPServerVersionModal} + {EditTagsModal} + ); }; -export default withErrorBoundary(ErrorUtils.mlflowServices.EXPERIMENTS, MCPRegistryPage); +export default withErrorBoundary(ErrorUtils.mlflowServices.MCP_REGISTRY, MCPRegistryPage); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx index f3af91a421edd..e18b63902dd5b 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from '@jest/globals'; -import { render, screen, waitFor, within } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; import { DesignSystemProvider } from '@databricks/design-system'; @@ -17,6 +17,11 @@ import { getMockedSearchMCPAccessBindingsResponse, getMockedDeleteMCPServerVersionResponse, getMockedDeleteMCPServerResponse, + getMockedGetLatestMCPServerVersionResponse, + getMockedUpdateMCPServerResponse, + getMockedUpdateMCPServerErrorResponse, + getMockedSetMCPServerTagResponse, + getMockedDeleteMCPServerTagResponse, } from '../test-utils'; const mockServer = createMockMCPServer({ @@ -33,10 +38,30 @@ const mockVersion = createMockMCPServerVersion({ version: '1.0.0', title: 'Mainline', description: 'Gives your AI agent your story map.', + packages: [ + { + registryType: 'npm', + identifier: '@mainline/mcp-server', + version: '1.0.0', + runtimeHint: 'npx', + transport: { type: 'stdio' }, + environmentVariables: [ + { name: 'API_KEY', description: 'API key for authentication', isRequired: true, isSecret: true }, + { name: 'LOG_LEVEL', description: 'Logging verbosity' }, + ], + }, + { + registryType: 'pypi', + identifier: 'mainline-mcp-server', + transport: { type: 'stdio' }, + }, + ], + remotes: [{ type: 'streamable-http', url: 'https://api.mainline.dev/mcp' }], }, }); const defaultHandlers = [ + getMockedGetLatestMCPServerVersionResponse(mockVersion), getMockedGetMCPServerResponse(mockServer), getMockedSearchMCPServerVersionsResponse([mockVersion]), getMockedSearchMCPAccessBindingsResponse([]), @@ -81,7 +106,7 @@ describe('MCPServerDetailPage', () => { it('renders version list with status badge', async () => { renderPage(); await waitFor(() => { - expect(screen.getByText('Version 1')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); }); expect(screen.getAllByText('active').length).toBeGreaterThanOrEqual(1); }); @@ -92,8 +117,6 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); expect(screen.getAllByText('dev.mainline/mcp').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('Display name:')).toBeInTheDocument(); - expect(screen.getByText('1.0.0')).toBeInTheDocument(); expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); }); @@ -105,35 +128,101 @@ describe('MCPServerDetailPage', () => { }); }); - it('expands JSON viewer', async () => { + it('shows packages in version detail', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Packages (2)')).toBeInTheDocument(); + }); + expect(screen.getByText('npm')).toBeInTheDocument(); + expect(screen.getByText('pypi')).toBeInTheDocument(); + }); + + it('shows remotes in version detail', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Remotes (1)')).toBeInTheDocument(); + }); + expect(screen.getByText('streamable-http')).toBeInTheDocument(); + expect(screen.getByText('https://api.mainline.dev/mcp')).toBeInTheDocument(); + }); + + it('expands a package row to show environment variables', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('npm')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /Expand package @mainline\/mcp-server/ })); + await waitFor(() => { + expect(screen.getByText('Environment Variables (2)')).toBeInTheDocument(); + }); + expect(screen.getAllByText('@mainline/mcp-server').length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('API_KEY')).toBeInTheDocument(); + expect(screen.getByText('required')).toBeInTheDocument(); + expect(screen.getByText('secret')).toBeInTheDocument(); + expect(screen.getByText('API key for authentication')).toBeInTheDocument(); + expect(screen.getByText('LOG_LEVEL')).toBeInTheDocument(); + }); + + it('toggles raw server.json view', async () => { renderPage(); await waitFor(() => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - await userEvent.click(screen.getByText('View full configuration')); + await waitFor(() => { + expect(screen.getByText('View raw server.json')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('View raw server.json')); await waitFor(() => { expect(screen.getByText(/"name": "dev.mainline\/mcp"/)).toBeInTheDocument(); }); + expect(screen.getByText('Hide raw server.json')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Hide raw server.json')); + await waitFor(() => { + expect(screen.queryByText(/"name": "dev.mainline\/mcp"/)).not.toBeInTheDocument(); + }); }); it('renders empty access bindings message', async () => { renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Access Bindings')); await waitFor(() => { expect(screen.getByText('No access bindings configured for this server.')).toBeInTheDocument(); }); }); - it('opens status update modal when Edit is clicked', async () => { + it('opens edit version modal with status select when Edit is clicked', async () => { renderPage(); await waitFor(() => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - await userEvent.click(screen.getByText('Edit')); + const editBtn = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.edit_version"]', + ) as HTMLElement; + await userEvent.click(editBtn); await waitFor(() => { - expect(screen.getByText('Update version status')).toBeInTheDocument(); - expect(screen.getByText('Current status:')).toBeInTheDocument(); + expect(screen.getByText('Edit version details')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); }); }); @@ -153,14 +242,12 @@ describe('MCPServerDetailPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByText('Version 1')).toBeInTheDocument(); - expect(screen.getByText('Version 2')).toBeInTheDocument(); + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - await userEvent.click(screen.getByText('Version 2')); + await userEvent.click(screen.getByText('2')); await waitFor(() => { expect(screen.getByText('Viewing version 2')).toBeInTheDocument(); - expect(screen.getByText('2.0.0')).toBeInTheDocument(); }); }); @@ -183,25 +270,15 @@ describe('MCPServerDetailPage', () => { }); await userEvent.click(screen.getByRole('button', { name: 'More actions' })); - const menuItem = await screen.findByRole('menuitem'); - await userEvent.click(menuItem); + const menuItems = await screen.findAllByRole('menuitem'); + const deleteItem = menuItems.find((item) => item.textContent === 'Delete'); + expect(deleteItem).toBeDefined(); + await userEvent.click(deleteItem!); await waitFor(() => { expect(screen.getByText(/Are you sure you want to delete this MCP server/)).toBeInTheDocument(); }); }); - it('opens usage example modal', async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByRole('button', { name: /Use/ })); - await waitFor(() => { - expect(screen.getByText('Usage example')).toBeInTheDocument(); - }); - }); - it('renders access bindings table when bindings exist', async () => { const binding = createMockMCPAccessBinding({ server_name: 'dev.mainline/mcp', @@ -211,12 +288,17 @@ describe('MCPServerDetailPage', () => { }); server.use(getMockedSearchMCPAccessBindingsResponse([binding])); renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Access Bindings')); await waitFor(() => { expect(screen.getByText('https://mcp.example.com/server')).toBeInTheDocument(); }); }); - it('pre-selects version from URL query param', async () => { + it('selects first version by default when multiple exist', async () => { const version2 = createMockMCPServerVersion({ name: 'dev.mainline/mcp', version: '2', @@ -230,10 +312,9 @@ describe('MCPServerDetailPage', () => { }); server.use(getMockedSearchMCPServerVersionsResponse([mockVersion, version2])); - renderPage(['/mcp-registry/dev.mainline%2Fmcp?version=2']); + renderPage(); await waitFor(() => { - expect(screen.getByText('Viewing version 2')).toBeInTheDocument(); - expect(screen.getByText('2.0.0')).toBeInTheDocument(); + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); }); @@ -244,7 +325,7 @@ describe('MCPServerDetailPage', () => { }); }); - it('persists selected version across re-renders', async () => { + it('persists selected version across clicks', async () => { const version2 = createMockMCPServerVersion({ name: 'dev.mainline/mcp', version: '2', @@ -263,14 +344,13 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - await userEvent.click(screen.getByText('Version 2')); + await userEvent.click(screen.getByText('2')); await waitFor(() => { expect(screen.getByText('Viewing version 2')).toBeInTheDocument(); - expect(screen.getByText('2.0.0')).toBeInTheDocument(); }); }); - it('shows terminal state warning for deleted version status', async () => { + it('disables all status transitions for deleted version in edit modal', async () => { const deletedVersion = createMockMCPServerVersion({ name: 'dev.mainline/mcp', version: '1', @@ -288,9 +368,165 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - await userEvent.click(screen.getByText('Edit')); + const editBtn = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.edit_version"]', + ) as HTMLElement; + await userEvent.click(editBtn); await waitFor(() => { - expect(screen.getByText(/terminal state/)).toBeInTheDocument(); + expect(screen.getByText('Edit version details')).toBeInTheDocument(); }); + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + + it('displays server description as read-only', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); + }); + + expect( + document.querySelector('[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]'), + ).not.toBeInTheDocument(); + }); + + describe('server display name editing', () => { + it('opens edit display name modal from overflow menu', async () => { + server.use(getMockedUpdateMCPServerResponse()); + renderPage(); + await waitFor(() => { + expect(screen.getAllByText('Mainline').length).toBeGreaterThanOrEqual(1); + }); + + await userEvent.click(screen.getByRole('button', { name: 'More actions' })); + const menuItems = await screen.findAllByRole('menuitem'); + const editItem = menuItems.find((item) => item.textContent === 'Edit display name'); + expect(editItem).toBeDefined(); + await userEvent.click(editItem!); + await waitFor(() => { + expect(screen.getByText('Edit display name')).toBeInTheDocument(); + }); + }); + }); + + describe('server tags', () => { + it('shows "Add tags" button when server has no tags', async () => { + server.use(getMockedSetMCPServerTagResponse(), getMockedDeleteMCPServerTagResponse()); + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + expect(screen.getByText('Add tags')).toBeInTheDocument(); + }); + + it('shows tags when server has tags', async () => { + const taggedServer = createMockMCPServer({ + name: 'dev.mainline/mcp', + display_name: 'Mainline', + description: 'A test server', + tags: { env: 'production', team: 'platform' }, + }); + server.use( + getMockedGetMCPServerResponse(taggedServer), + getMockedSetMCPServerTagResponse(), + getMockedDeleteMCPServerTagResponse(), + ); + renderPage(); + await waitFor(() => { + expect(screen.getByText('env')).toBeInTheDocument(); + expect(screen.getByText('team')).toBeInTheDocument(); + }); + }); + }); + + it('Compare toggle is disabled with a single version', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + // SegmentedControlButton renders as a radio input; find the one associated with "Compare" + const compareLabel = screen.getByText('Compare').closest('label'); + const compareInput = compareLabel?.querySelector('input'); + expect(compareInput).toBeDisabled(); + }); + + it('Compare toggle is enabled with multiple versions', async () => { + const mockVersion2 = createMockMCPServerVersion({ + name: 'dev.mainline/mcp', + version: '2', + status: 'draft', + server_json: { + name: 'dev.mainline/mcp', + version: '2.0.0', + title: 'Mainline v2', + description: 'Updated version.', + }, + }); + server.use(getMockedSearchMCPServerVersionsResponse([mockVersion, mockVersion2])); + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + const compareLabel = screen.getByText('Compare').closest('label'); + const compareInput = compareLabel?.querySelector('input'); + expect(compareInput).not.toBeDisabled(); + }); + + it('clicking Compare shows compare view', async () => { + const mockVersion2 = createMockMCPServerVersion({ + name: 'dev.mainline/mcp', + version: '2', + status: 'draft', + server_json: { + name: 'dev.mainline/mcp', + version: '2.0.0', + title: 'Mainline v2', + description: 'Updated version.', + }, + }); + server.use(getMockedSearchMCPServerVersionsResponse([mockVersion, mockVersion2])); + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Compare')); + await waitFor(() => { + expect(screen.getByText(/Comparing version .+ with version/)).toBeInTheDocument(); + }); + }); + + it('switching back to Preview restores version detail', async () => { + const mockVersion2 = createMockMCPServerVersion({ + name: 'dev.mainline/mcp', + version: '2', + status: 'draft', + server_json: { + name: 'dev.mainline/mcp', + version: '2.0.0', + title: 'Mainline v2', + description: 'Updated version.', + }, + }); + server.use(getMockedSearchMCPServerVersionsResponse([mockVersion, mockVersion2])); + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Compare')); + await waitFor(() => { + expect(screen.getByText(/Comparing version .+ with version/)).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Preview')); + await waitFor( + () => { + expect(screen.getByText(/Viewing version/)).toBeInTheDocument(); + }, + { timeout: 10000 }, + ); }); }); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx index 02f38f3e6f4d2..4bd9e3256cfee 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, Breadcrumb, @@ -14,10 +14,11 @@ import { ZoomMarqueeSelection, useDesignSystemTheme, } from '@databricks/design-system'; +import type { TagColors } from '@databricks/design-system'; import { FormattedMessage, useIntl } from 'react-intl'; import { ScrollablePageWrapper } from '../../common/components/ScrollablePageWrapper'; -import { Link, useNavigate, useParams } from '../../common/utils/RoutingUtils'; +import { Link, useNavigate, useParams, useSearchParams } from '../../common/utils/RoutingUtils'; import { withErrorBoundary } from '../../common/utils/withErrorBoundary'; import ErrorUtils from '../../common/utils/ErrorUtils'; import { ConfirmationModal } from '../../admin/ConfirmationModal'; @@ -27,16 +28,22 @@ import { MCPRegistryApi } from '../api'; import { useMCPServerQuery, useMCPServerVersionsQuery, + useLatestMCPServerVersionQuery, useMCPAccessBindingsQuery, } from '../hooks/useMCPServerDetailQuery'; -import { useDeleteMCPServer } from '../hooks/useMCPServerVersionMutations'; +import { useDeleteMCPServer, useUpdateMCPServerDisplayName } from '../hooks/useMCPServerVersionMutations'; import { useDeleteAccessBindingMutation } from '../hooks/useAccessBindingMutation'; import type { MCPAccessBinding } from '../types'; +import { useCreateMCPServerVersionModal } from '../hooks/useCreateMCPServerVersionModal'; +import { useUpdateMCPServerVersionMetadataModal } from '../hooks/useUpdateMCPServerVersionMetadataModal'; import { MCPServerVersionList } from '../components/MCPServerVersionList'; import { MCPServerVersionDetail } from '../components/MCPServerVersionDetail'; import { AccessBindingModal } from '../components/AccessBindingModal'; -import { useSelectedMCPServerVersion } from '../hooks/useSelectedMCPServerVersion'; -import { resolveDisplayName } from '../utils'; +import { MCPServerVersionCompare } from '../components/MCPServerVersionCompare'; +import { UpdateVersionDisplayNameModal } from '../components/UpdateVersionDisplayNameModal'; +import { MCPServerTagsBox } from '../components/MCPServerTagsBox'; +import { useMCPServerDetailViewState, MCPServerDetailViewMode } from '../hooks/useMCPServerDetailViewState'; +import { LATEST_ALIAS, RESERVED_ALIASES, resolveDisplayName } from '../utils'; const getAliasesModalTitle = (version: string) => ( { const intl = useIntl(); const navigate = useNavigate(); const params = useParams<{ serverName: string }>(); + const [searchParams] = useSearchParams(); + const versionFromUrl = searchParams.get('version') ?? undefined; const serverName = decodeURIComponent(params.serverName ?? ''); const [deleteServerModalVisible, setDeleteServerModalVisible] = useState(false); const [addBindingModalOpen, setAddBindingModalOpen] = useState(false); const [editingBinding, setEditingBinding] = useState(undefined); const [deletingBinding, setDeletingBinding] = useState(undefined); + const [editServerDisplayNameVisible, setEditServerDisplayNameVisible] = useState(false); const deleteServerMutation = useDeleteMCPServer(); + const updateDisplayNameMutation = useUpdateMCPServerDisplayName(serverName); const deleteBindingMutation = useDeleteAccessBindingMutation(); - const { data: server, isLoading: serverLoading, @@ -71,15 +81,56 @@ const MCPServerDetailPage = () => { error: versionsError, refetch: refetchVersions, } = useMCPServerVersionsQuery(serverName); + const { data: latestVersion, refetch: refetchLatestVersion } = useLatestMCPServerVersionQuery(serverName); const { data: bindings, isLoading: bindingsLoading, error: bindingsError } = useMCPAccessBindingsQuery(serverName); - const latestVersion = versions?.[0]?.version; - const [selectedVersion, setSelectedVersion] = useSelectedMCPServerVersion(latestVersion); + const { + viewState, + selectedVersion, + setSelectedVersion, + setPreviewMode, + setCompareMode, + setComparedVersion, + switchSides, + } = useMCPServerDetailViewState(versions); + + useEffect(() => { + if (!versions?.length) { + setSelectedVersion(undefined); + return; + } + const currentStillValid = versions.some((v) => v.version === selectedVersion); + if (!currentStillValid) { + const urlVersion = + versionFromUrl && versions.some((v) => v.version === versionFromUrl) ? versionFromUrl : undefined; + setSelectedVersion(urlVersion ?? versions[0].version); + } + if (viewState.mode === MCPServerDetailViewMode.COMPARE && versions.length < 2) { + setPreviewMode(); + } else if (viewState.comparedVersion && !versions.some((v) => v.version === viewState.comparedVersion)) { + setComparedVersion( + versions[0]?.version === selectedVersion ? (versions[1]?.version ?? '') : (versions[0]?.version ?? ''), + ); + } + }, [ + versions, + selectedVersion, + versionFromUrl, + viewState.comparedVersion, + viewState.mode, + setComparedVersion, + setSelectedVersion, + setPreviewMode, + ]); + + const currentVersion = versions?.find((v) => v.version === selectedVersion); + + const resolvedLatestVersion = latestVersion?.version; - const currentVersion = useMemo(() => { - if (!versions?.length) return undefined; - return versions.find((v) => v.version === selectedVersion) ?? versions[0]; - }, [versions, selectedVersion]); + const comparedVersionEntity = useMemo( + () => versions?.find((v) => v.version === viewState.comparedVersion), + [versions, viewState.comparedVersion], + ); const aliasesByVersion = useMemo(() => { const result: Record = {}; @@ -89,12 +140,45 @@ const MCPServerDetailPage = () => { } result[version].push(alias); }); + if (resolvedLatestVersion) { + if (!result[resolvedLatestVersion]) { + result[resolvedLatestVersion] = []; + } + if (!result[resolvedLatestVersion].includes(LATEST_ALIAS)) { + result[resolvedLatestVersion].unshift(LATEST_ALIAS); + } + } return result; - }, [server?.aliases]); + }, [server?.aliases, resolvedLatestVersion]); + + const versionBindings = useMemo(() => { + if (!currentVersion || !bindings) return []; + return bindings.filter( + (b) => + b.server_version === currentVersion.version || + (b.server_alias && aliasesByVersion[currentVersion.version]?.includes(b.server_alias)), + ); + }, [bindings, currentVersion, aliasesByVersion]); + + const isPinnedLatest = Boolean(server?.latest_version); + + const aliasColors = useMemo>( + () => ({ [LATEST_ALIAS]: isPinnedLatest ? 'turquoise' : 'brown' }), + [isPinnedLatest], + ); const refetchAll = useCallback(async () => { - await Promise.all([refetchServer(), refetchVersions()]); - }, [refetchServer, refetchVersions]); + await Promise.all([refetchServer(), refetchVersions(), refetchLatestVersion()]); + }, [refetchServer, refetchVersions, refetchLatestVersion]); + + const { CreateMCPServerVersionModal, openModal: openCreateVersionModal } = useCreateMCPServerVersionModal({ + serverName: serverName, + latestVersion: currentVersion, + onSuccess: async ({ version }) => { + await refetchAll(); + setSelectedVersion(version); + }, + }); const { EditAliasesModal, showEditAliasesModal } = useEditAliasesModal({ aliases: server?.aliases ?? [], @@ -116,7 +200,14 @@ const MCPServerDetailPage = () => { description="Description for the edit aliases modal on the MCP server detail page" /> ), - reservedAliases: ['latest'], + reservedAliases: RESERVED_ALIASES, + pinnedLatestVersion: resolvedLatestVersion ?? undefined, + pinnedAliasColor: aliasColors[LATEST_ALIAS], + }); + + const { EditMCPServerVersionMetadataModal, showEditMetadataModal } = useUpdateMCPServerVersionMetadataModal({ + serverName: serverName, + onSuccess: refetchAll, }); const breadcrumbs = ( @@ -190,6 +281,15 @@ const MCPServerDetailPage = () => { /> + setEditServerDisplayNameVisible(true)} + > + + setDeleteServerModalVisible(true)} @@ -198,7 +298,11 @@ const MCPServerDetailPage = () => { - ) : ( <> - {aliases.map((alias) => ( - - ))} + {aliases.map((alias) => { + const color = aliasColors?.[alias] ?? (highlightedAliases?.includes(alias) ? 'turquoise' : undefined); + return ; + })}