From 2c1bcede72c7c3f4b5a8fcee5cf1ff89f0a0c73e Mon Sep 17 00:00:00 2001 From: Nana Nosirova <10577112+nananosirova@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:07:10 -0400 Subject: [PATCH 1/5] Add create MCP server version modal with validation, tags, and tests Signed-off-by: Nana Nosirova <10577112+nananosirova@users.noreply.github.com> --- .../componentId-registry.js | 62 +-- mlflow/server/js/src/lang/default/en.json | 76 +++ .../useCreateMCPServerVersionModal.test.tsx | 222 ++++++++ .../hooks/useCreateMCPServerVersionModal.tsx | 356 ++++++++++++ .../useCreateMCPServerVersionMutation.ts | 50 ++ .../pages/MCPRegistryPage.test.tsx | 44 ++ .../mcp-registry/pages/MCPRegistryPage.tsx | 509 +++++++++--------- .../pages/MCPServerDetailPage.tsx | 17 +- .../server/js/src/mcp-registry/test-utils.ts | 13 + .../server/js/src/mcp-registry/utils.test.ts | 145 +++++ mlflow/server/js/src/mcp-registry/utils.ts | 68 ++- 11 files changed, 1270 insertions(+), 292 deletions(-) create mode 100644 mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.test.tsx create mode 100644 mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx create mode 100644 mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts diff --git a/.github/actions/check-component-ids/componentId-registry.js b/.github/actions/check-component-ids/componentId-registry.js index 9925be980e080..cac643d89e938 100644 --- a/.github/actions/check-component-ids/componentId-registry.js +++ b/.github/actions/check-component-ids/componentId-registry.js @@ -1837,52 +1837,34 @@ module.exports = { "mlflow.logged_models.table.source_run_link": "", // -- mlflow.mcp_registry -- - "mlflow.mcp_registry.binding_detail.actions": "", - "mlflow.mcp_registry.binding_detail.actions.delete": "", - "mlflow.mcp_registry.binding_detail.breadcrumb_back": "", - "mlflow.mcp_registry.binding_detail.copy_endpoint": "", - "mlflow.mcp_registry.binding_detail.copy_tooltip": "", - "mlflow.mcp_registry.binding_detail.delete_modal": "", - "mlflow.mcp_registry.binding_detail.edit": "", - "mlflow.mcp_registry.binding_detail.error": "", - "mlflow.mcp_registry.binding_detail.server_link": "", - "mlflow.mcp_registry.binding_detail.version_status": "", - "mlflow.mcp_registry.binding_modal": "", - "mlflow.mcp_registry.binding_modal.endpoint": "", - "mlflow.mcp_registry.binding_modal.error": "", - "mlflow.mcp_registry.binding_modal.server": "", - "mlflow.mcp_registry.binding_modal.target": "", - "mlflow.mcp_registry.binding_modal.transport": "", - "mlflow.mcp_registry.bindings.card": "", - "mlflow.mcp_registry.bindings.empty_state.create_server": "", - "mlflow.mcp_registry.bindings.error": "", - "mlflow.mcp_registry.bindings.grid.empty_state.create": "", - "mlflow.mcp_registry.bindings.list.empty_state.create_server": "", + "mlflow.mcp_registry.bindings.empty_state.create": "", + "mlflow.mcp_registry.bindings.header.endpoint": "", + "mlflow.mcp_registry.bindings.header.last_updated": "", + "mlflow.mcp_registry.bindings.header.server": "", + "mlflow.mcp_registry.bindings.header.transport": "", + "mlflow.mcp_registry.bindings.header.version": "", "mlflow.mcp_registry.bindings.search": "", - "mlflow.mcp_registry.bindings.table.copy_endpoint": "", - "mlflow.mcp_registry.bindings.table.copy_tooltip": "", - "mlflow.mcp_registry.bindings.table.edit_link": "", - "mlflow.mcp_registry.bindings.table.empty_state.create": "", - "mlflow.mcp_registry.bindings.table.endpoint_link": "", - "mlflow.mcp_registry.bindings.table.header": "", - "mlflow.mcp_registry.bindings.table.pagination": "", - "mlflow.mcp_registry.bindings.table.server_link": "", - "mlflow.mcp_registry.bindings.view_toggle": "", - "mlflow.mcp_registry.card": "", - "mlflow.mcp_registry.card_grid.pagination": "", - "mlflow.mcp_registry.create_binding_button": "", + "mlflow.mcp_registry.card.link": "", + "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_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.add_binding": "", - "mlflow.mcp_registry.detail.binding.card": "", - "mlflow.mcp_registry.detail.binding.delete": "", - "mlflow.mcp_registry.detail.binding.edit": "", - "mlflow.mcp_registry.detail.binding.transport": "", - "mlflow.mcp_registry.detail.bindings_error": "", + "mlflow.mcp_registry.detail.bindings.endpoint": "", + "mlflow.mcp_registry.detail.bindings.last_updated": "", + "mlflow.mcp_registry.detail.bindings.target": "", + "mlflow.mcp_registry.detail.bindings.transport": "", "mlflow.mcp_registry.detail.breadcrumb_back": "", "mlflow.mcp_registry.detail.create_version": "", - "mlflow.mcp_registry.detail.delete_binding_modal": "", "mlflow.mcp_registry.detail.delete_server_modal": "", "mlflow.mcp_registry.detail.delete_version": "", "mlflow.mcp_registry.detail.delete_version_modal": "", @@ -1899,11 +1881,11 @@ module.exports = { "mlflow.mcp_registry.detail.version_status": "", "mlflow.mcp_registry.detail.version_status_tag": "", "mlflow.mcp_registry.detail.versions.header": "", - "mlflow.mcp_registry.detail.versions_error": "", "mlflow.mcp_registry.detail.view_toggle": "", "mlflow.mcp_registry.detail.website": "", "mlflow.mcp_registry.empty_state.create_server": "", "mlflow.mcp_registry.error": "", + "mlflow.mcp_registry.grid.pagination": "", "mlflow.mcp_registry.search": "", "mlflow.mcp_registry.table.empty_state.create_server": "", "mlflow.mcp_registry.table.header": "", diff --git a/mlflow/server/js/src/lang/default/en.json b/mlflow/server/js/src/lang/default/en.json index d04c5c604b6ac..39247acb814c0 100644 --- a/mlflow/server/js/src/lang/default/en.json +++ b/mlflow/server/js/src/lang/default/en.json @@ -2215,6 +2215,10 @@ "defaultMessage": "LLM Connections", "description": "Settings content section title: LLM connections" }, + "9m/8+5": { + "defaultMessage": "Add tag", + "description": "Tooltip for add tag button in create MCP server modal" + }, "9mAGv0": { "defaultMessage": "Model name", "description": "Text for form title on creating model in the model registry" @@ -2887,6 +2891,10 @@ "defaultMessage": "Line chart", "description": "Experiment tracking > runs charts > add chart menu > line chart" }, + "CzXzL8": { + "defaultMessage": "Tools:", + "description": "Label for tools field in create MCP server modal" + }, "D+UN8o": { "defaultMessage": "No metric charts", "description": "Experiment page > compare runs > no metric charts" @@ -4019,6 +4027,10 @@ "defaultMessage": "New status:", "description": "MCP server new status label in update modal" }, + "Jr4PZf": { + "defaultMessage": "server.json:", + "description": "Label for server.json field in create MCP server modal" + }, "Jrri/Y": { "defaultMessage": "Temperature", "description": "Experiment page > prompt lab > temperature parameter label" @@ -4071,6 +4083,10 @@ "defaultMessage": "Automatically log traces for CrewAI executions by calling the {code} function. For example:", "description": "Description of how to log traces for the CrewAI package using MLflow autologging. This message is followed by a code example." }, + "K8sPfc": { + "defaultMessage": "Create", + "description": "Label for the confirm button in the create MCP server version modal" + }, "K8ynGW": { "defaultMessage": "Search datasets", "description": "Placeholder for the search input on the V2 evaluation datasets list page" @@ -4155,6 +4171,10 @@ "defaultMessage": "Disabled", "description": "Runs charts > line chart > ignore outliers > disabled label" }, + "Kax91w": { + "defaultMessage": "Create MCP server", + "description": "Title for the create MCP server version modal" + }, "KcGozs": { "defaultMessage": "Endpoint:", "description": "Endpoint selector label" @@ -5327,6 +5347,10 @@ "defaultMessage": "Parallel Coordinates Plot", "description": "Tab text for parallel coordinates plot on the model comparison page" }, + "QumV2s": { + "defaultMessage": "https://github.com/org/repo", + "description": "Placeholder for source in create MCP server modal" + }, "Qv7cZx": { "defaultMessage": "Promote model", "description": "Button text to promote the model to a different registered model" @@ -6339,6 +6363,10 @@ "defaultMessage": "Aliased versions", "description": "Column title for aliased versions in the registered model page" }, + "WRPwF9": { + "defaultMessage": "Human-readable label for this server", + "description": "Placeholder for display name in create MCP server modal" + }, "WVUF2N": { "defaultMessage": "Are you sure you want to delete the prompt?", "description": "A content for the delete prompt confirmation modal" @@ -7003,6 +7031,10 @@ "defaultMessage": "Provider", "description": "Summary provider label" }, + "a3Ohkk": { + "defaultMessage": "Add tag", + "description": "Aria label for add tag button in create MCP server modal" + }, "a6aAez": { "defaultMessage": "Cleaning up workspace metadata…", "description": "Status line shown in the dataset delete modal while the post-delete propagation poll is running" @@ -7219,6 +7251,10 @@ "defaultMessage": "Edit the request body below and click Send request to call the endpoint.", "description": "Try it description in starter code modal" }, + "bAx8j3": { + "defaultMessage": "Draft", + "description": "Draft status option in create MCP server modal" + }, "bCdY2j": { "defaultMessage": "Add \"{label}\"", "description": "Evaluation review > assessments > add new custom value element" @@ -7327,6 +7363,10 @@ "defaultMessage": "Average Value", "description": "Column header for average value" }, + "bmH/Ju": { + "defaultMessage": "Source:", + "description": "Label for source field in create MCP server modal" + }, "bmHBO7": { "defaultMessage": "Sessions", "description": "Label for the chat sessions tab in the MLflow experiment navbar" @@ -7891,6 +7931,10 @@ "defaultMessage": "Add a judge to your experiment to measure your GenAI app quality", "description": "Title for the empty state when no judges exist" }, + "eZ8y48": { + "defaultMessage": "Create MCP server version", + "description": "Title for the create MCP server version modal when adding a version to an existing server" + }, "eZOxx1": { "defaultMessage": "Toggle the preview sidepane", "description": "Experiment page > control bar > expanded view toggle button tooltip" @@ -7995,6 +8039,10 @@ "defaultMessage": "Preview", "description": "MCP server detail preview tab" }, + "f6/YpY": { + "defaultMessage": "Cancel", + "description": "Label for the cancel button in the create MCP server version modal" + }, "fBB0xR": { "defaultMessage": "Assistant Not Available", "description": "Title shown when Assistant is not available for remote servers" @@ -9055,6 +9103,10 @@ "defaultMessage": "Operator", "description": "Usage overview > filter row > operator column label" }, + "kQpQFQ": { + "defaultMessage": "Tags:", + "description": "Label for tags field in create MCP server modal" + }, "kQw850": { "defaultMessage": "Test succeeded (HTTP {status})", "description": "Message informing the user that the webhook test succeeded" @@ -9403,6 +9455,10 @@ "defaultMessage": "Change password", "description": "Button to open the change password modal" }, + "mLqAKf": { + "defaultMessage": "Display name:", + "description": "Label for display name field in create MCP server modal" + }, "mLr9U7": { "defaultMessage": "Learn more.", "description": "Link text for learning more about feedback" @@ -9559,6 +9615,10 @@ "defaultMessage": "Yes, save and close", "description": "Key-value tag editor modal > Unsaved tag message > Yes, save and close button" }, + "mwR/4I": { + "defaultMessage": "Active", + "description": "Active status option in create MCP server modal" + }, "mzmlq2": { "defaultMessage": "Detects personally identifiable information such as names, emails, and phone numbers.", "description": "PII guardrail type description" @@ -9595,6 +9655,10 @@ "defaultMessage": "Use multiple models for load balancing by splitting traffic with weights", "description": "Gateway > Endpoint details > Description for adding more primary models" }, + "nAJlQl": { + "defaultMessage": "Deprecated", + "description": "Deprecated status option in create MCP server modal" + }, "nAhHpm": { "defaultMessage": "Cancel", "description": "Cancel button text for create workspace modal" @@ -9907,6 +9971,10 @@ "defaultMessage": "Compact view", "description": "Run page > artifact view > logged table view > compact view toggle button" }, + "odrnWp": { + "defaultMessage": "Status:", + "description": "Label for status field in create MCP server modal" + }, "ofr72S": { "defaultMessage": "Version", "description": "Column label for logged model" @@ -11131,6 +11199,10 @@ "defaultMessage": "X axis", "description": "Label for X axis in scatter chart configurator in compare runs chart config modal" }, + "vBUTRa": { + "defaultMessage": "Enter your MCP server definition", + "description": "Placeholder for server.json in create MCP server modal" + }, "vCUglz": { "defaultMessage": "Suggested actions", "description": "Evaluation review > assessments > suggested actions > title" @@ -11379,6 +11451,10 @@ "defaultMessage": "Are you sure you want to save and close without adding \"{tag}\"", "description": "Key-value tag editor modal > Unsaved tag message" }, + "wcXwcq": { + "defaultMessage": "Type a value", + "description": "Placeholder for tag value input in create MCP server modal" + }, "wciksY": { "defaultMessage": "Here's how to optimize your prompt with your dataset in your Python code:", "description": "Description of how to optimize a prompt with a dataset in Python" 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..c4d52fb6b0b3c --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx @@ -0,0 +1,356 @@ +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 && ( + <> + + + + )} + {!isVersionMode && ( + <> + + + + 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' }} + /> + + + + +
+
+
+ +
+
+ +
+
+ + + +
+ {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..43e4509eecf8a --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { MCPRegistryApi } from '../api'; +import type { MCPServerVersion, MCPStatus, MCPTool, ServerJSONPayload } from '../types'; + +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, + status, + source, + tools, + }); + + 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) { + await Promise.all( + Object.entries(tags).map(([key, value]) => MCPRegistryApi.setMCPServerTag(name, { key, value })), + ); + } + + return version; + }, + onSuccess: () => { + queryClient.invalidateQueries(['mcp_servers_list']); + }, + }); +}; 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..ff9e13f27c3c4 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', () => { @@ -363,4 +365,46 @@ 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), + getMockedCreateMCPServerVersionResponse(), + getMockedUpdateMCPServerResponse(), + ); + renderPage(); + await waitFor(() => { + expect(screen.getByText('Server 1')).toBeInTheDocument(); + }); + + const createButton = screen.getAllByText('Create MCP server')[0]; + expect(createButton.closest('button')).not.toBeDisabled(); + + await userEvent.click(createButton); + + await waitFor(() => { + expect(screen.getByText('Display name:')).toBeInTheDocument(); + expect(screen.getByText(/server\.json:/)).toBeInTheDocument(); + }); + }); + + it('opens create modal from empty state button', async () => { + server.use( + getMockedSearchMCPServersResponse([]), + getMockedCreateMCPServerVersionResponse(), + getMockedUpdateMCPServerResponse(), + ); + renderPage(); + 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..f8a0f0b14909c 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx @@ -22,14 +22,15 @@ 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 { useCreateMCPServerVersionModal } from '../hooks/useCreateMCPServerVersionModal'; import { useMCPAccessBindingsListQuery } from '../hooks/useMCPAccessBindingsListQuery'; import { MCPServerCardGrid } from '../components/MCPServerCardGrid'; -import { MCPServerListTable } from '../components/MCPServerListTable'; +import { MCPServerListTable, emptyCenterStyles } from '../components/MCPServerListTable'; +import MCPRegistryRoutes from '../routes'; import type { MCPAccessBinding } from '../types'; -import { emptyCenterStyles } from '../utils'; import { MCPAccessBindingCardGrid } from '../components/MCPAccessBindingCardGrid'; import { MCPAccessBindingListTable } from '../components/MCPAccessBindingListTable'; import { AccessBindingModal } from '../components/AccessBindingModal'; @@ -78,6 +79,11 @@ const MCPRegistryPage = () => { enabled: activeTab === 'bindings', }); + const navigate = useNavigate(); + const { CreateMCPServerVersionModal, openModal } = useCreateMCPServerVersionModal({ + onSuccess: ({ name }) => navigate(MCPRegistryRoutes.getMCPServerDetailRoute(name)), + }); + const handleTabChange = useCallback( (e: RadioChangeEvent) => { const value = e.target.value as ActiveTab; @@ -108,7 +114,7 @@ const MCPRegistryPage = () => { ) : !isServersEmpty ? ( - ) : null; @@ -130,7 +136,7 @@ const MCPRegistryPage = () => { componentId="mlflow.mcp_registry.empty_state.create_server" type="primary" icon={} - disabled + onClick={openModal} > @@ -140,274 +146,277 @@ 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' ? ( -
- + + setSearchFilter(e.target.value)} + suffix={null} /> - } - description={ - - } - 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} + ); }; diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx index 02f38f3e6f4d2..f2c5c51693c62 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx @@ -32,6 +32,7 @@ import { import { useDeleteMCPServer } from '../hooks/useMCPServerVersionMutations'; import { useDeleteAccessBindingMutation } from '../hooks/useAccessBindingMutation'; import type { MCPAccessBinding } from '../types'; +import { useCreateMCPServerVersionModal } from '../hooks/useCreateMCPServerVersionModal'; import { MCPServerVersionList } from '../components/MCPServerVersionList'; import { MCPServerVersionDetail } from '../components/MCPServerVersionDetail'; import { AccessBindingModal } from '../components/AccessBindingModal'; @@ -96,6 +97,15 @@ const MCPServerDetailPage = () => { await Promise.all([refetchServer(), refetchVersions()]); }, [refetchServer, refetchVersions]); + const { CreateMCPServerVersionModal, openModal: openCreateVersionModal } = useCreateMCPServerVersionModal({ + serverName: decodedServerName, + latestVersion: currentVersion, + onSuccess: async ({ version }) => { + await refetchAll(); + setSelectedVersion(version); + }, + }); + const { EditAliasesModal, showEditAliasesModal } = useEditAliasesModal({ aliases: server?.aliases ?? [], onSuccess: refetchAll, @@ -198,7 +208,11 @@ const MCPServerDetailPage = () => { - ) : ( <> - {aliases.map((alias) => ( - - ))} + {aliases.map((alias) => { + const color = aliasColors?.[alias] ?? (highlightedAliases?.includes(alias) ? 'turquoise' : undefined); + const tooltip = aliasTooltips?.[alias]; + const tag = ; + return tooltip ? ( + + {tag} + + ) : ( + + ); + })} ); }; +const MCPServerLatestVersionCell = ({ row: { original } }: CellContext) => { + const { data: latestVersion } = useLatestMCPServerVersionQuery(original.name); + return original.latest_version || latestVersion?.version || '—'; +}; + const useMCPServerTableColumns = () => { const intl = useIntl(); return useMemo(() => { @@ -126,6 +121,14 @@ const useMCPServerTableColumns = () => { id: 'name', cell: MCPServerNameCell, }, + { + header: intl.formatMessage({ + defaultMessage: 'Latest version', + description: 'Header for the latest version column in the MCP servers table', + }), + id: 'latestVersion', + cell: MCPServerLatestVersionCell, + }, { header: intl.formatMessage({ defaultMessage: 'Last modified', diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.tsx index 06b90a492730a..039e59201aa96 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.tsx @@ -19,13 +19,7 @@ type UpdateTagsPayload = { const tagsRecordToArray = (tags: Record) => Object.entries(tags).map(([key, value]) => ({ key, value })); -export const MCPServerTagsBox = ({ - server, - onTagsUpdated, -}: { - server?: MCPServer; - onTagsUpdated?: () => void; -}) => { +export const MCPServerTagsBox = ({ server, onTagsUpdated }: { server?: MCPServer; onTagsUpdated?: () => void }) => { const intl = useIntl(); const { theme } = useDesignSystemTheme(); @@ -46,7 +40,13 @@ export const MCPServerTagsBox = ({ if (!entity.name) return reject(); updateMutation.mutate( { serverName: entity.name, toAdd: addedOrModifiedTags, toDelete: deletedTags }, - { onSuccess: () => { resolve(); onTagsUpdated?.(); }, onError: reject }, + { + onSuccess: () => { + resolve(); + onTagsUpdated?.(); + }, + onError: reject, + }, ); }); }, diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.tsx new file mode 100644 index 0000000000000..803cf4aab0a3b --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.tsx @@ -0,0 +1,250 @@ +import { useMemo } from 'react'; +import { + Button, + ExpandMoreIcon, + Spacer, + Tag, + Tooltip, + Typography, + useDesignSystemTheme, +} from '@databricks/design-system'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { diffWords } from '../../experiment-tracking/pages/prompts/diff'; + +import type { TagColors } from '@databricks/design-system'; +import type { MCPServerVersion } from '../types'; +import { STATUS_TAG_COLOR } from '../utils'; +import { AliasTag } from '../../common/components/AliasTag'; +import { KeyValueTag } from '../../common/components/KeyValueTag'; +import Utils from '../../common/utils/Utils'; + +const VersionMetadataGrid = ({ + version, + serverName, + aliasesByVersion, + aliasColors, +}: { + version?: MCPServerVersion; + serverName: string; + aliasesByVersion: Record; + aliasColors?: Record; +}) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + + if (!version) return null; + + return ( +
+ + + + + + {version.status} + + + + + + +
+ {(aliasesByVersion[version.version] ?? []).length > 0 ? ( + (aliasesByVersion[version.version] ?? []).map((alias) => ( + + )) + ) : ( + + )} +
+ + + + + + {version.creation_timestamp ? Utils.formatTimestamp(version.creation_timestamp, intl) : '—'} + + + {Object.keys(version.tags).length > 0 && ( + <> + + + +
+ {Object.entries(version.tags).map(([key, value]) => ( + + ))} +
+ + )} +
+ ); +}; + +export const MCPServerVersionCompare = ({ + baselineVersion, + comparedVersion, + serverName, + aliasesByVersion, + aliasColors, + onSwitchSides, +}: { + baselineVersion?: MCPServerVersion; + comparedVersion?: MCPServerVersion; + serverName: string; + aliasesByVersion: Record; + aliasColors?: Record; + onSwitchSides: () => void; +}) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + + const baselineJson = useMemo( + () => (baselineVersion?.server_json ? JSON.stringify(baselineVersion.server_json, null, 2) : ''), + [baselineVersion], + ); + const comparedJson = useMemo( + () => (comparedVersion?.server_json ? JSON.stringify(comparedVersion.server_json, null, 2) : ''), + [comparedVersion], + ); + + const diff = useMemo(() => diffWords(baselineJson, comparedJson) ?? [], [baselineJson, comparedJson]); + + const colors = useMemo( + () => ({ + addedBackground: theme.isDarkMode ? theme.colors.green700 : theme.colors.green300, + removedBackground: theme.isDarkMode ? theme.colors.red700 : theme.colors.red300, + }), + [theme], + ); + + return ( +
+ + + + +
+
+ +
+
+
+
+
+ +
+
+ + + +
+
+          {baselineJson || 'Empty'}
+        
+ +
+ + } + side="top" + > +
+ +
+          
+            {diff.map((part, index) => (
+              
+                {part.value}
+              
+            ))}
+          
+        
+
+
+ ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx index 8741009485df3..8044f4c49c478 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx @@ -181,35 +181,41 @@ export const MCPServerVersionDetail = ({ setDescriptionModalVisible(true); }} > - + ); })()}
- {onSetLatest && (() => { - const button = ( - - ); - return setLatestDisabled ? ( - - {button} - - ) : button; - })()} + {onSetLatest && + (() => { + const button = ( + + ); + return setLatestDisabled ? ( + + {button} + + ) : ( + button + ); + })()}
+ + ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionList.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionList.tsx index 8a3f07d51ba32..7cc59c249f631 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionList.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionList.tsx @@ -19,6 +19,8 @@ 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'; @@ -90,7 +92,10 @@ const MCPServerVersionCell: ColumnDef['cell'] = ({ export const MCPServerVersionList = ({ versions, selectedVersion, + comparedVersion, + mode, onSelectVersion, + onSelectComparedVersion, isLoading, serverName, serverDisplayName, @@ -100,7 +105,10 @@ export const MCPServerVersionList = ({ }: { versions?: MCPServerVersion[]; selectedVersion?: string; + comparedVersion?: string; + mode: MCPServerDetailViewMode; onSelectVersion: (version: string) => void; + onSelectComparedVersion?: (version: string) => void; isLoading?: boolean; serverName: string; serverDisplayName: string; @@ -110,6 +118,7 @@ export const MCPServerVersionList = ({ }) => { const { theme } = useDesignSystemTheme(); const intl = useIntl(); + const isCompareMode = mode === MCPServerDetailViewMode.COMPARE; const columns = useMemo[]>( () => [ @@ -158,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/UpdateVersionStatusModal.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx index 677f53a2ff518..a30ad49f6ab70 100644 --- a/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx +++ b/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx @@ -108,11 +108,7 @@ export const UpdateVersionStatusModal = ({ {(['draft', 'active', 'deprecated'] as MCPStatus[]) .filter((status) => status !== currentStatus) .map((status) => ( - + {capitalize(status)} ))} diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx index 4be2029215151..f236c62f3076e 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx +++ b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionModal.tsx @@ -267,7 +267,14 @@ export const useCreateMCPServerVersionModal = ({ /> - + {isVersionMode ? ( + + ) : ( + + )} { 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 }); + : (key: string, value: string) => + MCPRegistryApi.setMCPServerVersionTag(name, version.version, { key, value }); await Promise.all(Object.entries(tags).map(([key, value]) => setTag(key, value))); } diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts index 3a1fa24c81f58..ff43b9afc31d0 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts @@ -9,7 +9,6 @@ import type { } from '../types'; import { MCP_QUERY_KEYS } from '../utils'; - export const useMCPServerQuery = (name: string) => { return useQuery([MCP_QUERY_KEYS.SERVER, name], { queryFn: () => MCPRegistryApi.getMCPServer(name), 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..88b1e9fac5a42 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.ts @@ -0,0 +1,85 @@ +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': + return { ...state, comparedVersion: action.comparedVersion }; + case 'setSelectedVersion': + 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 12921a57e3a8e..d1974e8ec122c 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts @@ -51,8 +51,7 @@ export const useSetLatestVersion = (serverName: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (version: string | null) => - MCPRegistryApi.updateMCPServer(serverName, { latest_version: version }), + mutationFn: (version: string | null) => MCPRegistryApi.updateMCPServer(serverName, { latest_version: version }), onSuccess: () => { queryClient.invalidateQueries(['mcp_server', serverName]); queryClient.invalidateQueries(['mcp_server_versions', serverName]); 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..61f2e997921ce 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} 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 ff9e13f27c3c4..f79d437a85e25 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx @@ -370,21 +370,19 @@ describe('MCPRegistryPage', () => { const servers = [createMockMCPServer({ name: 's1', display_name: 'Server 1' })]; server.use( getMockedSearchMCPServersResponse(servers), + getMockedSearchMCPAccessBindingsAllResponse([]), getMockedCreateMCPServerVersionResponse(), getMockedUpdateMCPServerResponse(), ); - renderPage(); + renderPage(['/?tab=servers']); await waitFor(() => { - expect(screen.getByText('Server 1')).toBeInTheDocument(); + expect(screen.getByText('MCP Registry')).toBeInTheDocument(); }); const createButton = screen.getAllByText('Create MCP server')[0]; - expect(createButton.closest('button')).not.toBeDisabled(); - await userEvent.click(createButton); await waitFor(() => { - expect(screen.getByText('Display name:')).toBeInTheDocument(); expect(screen.getByText(/server\.json:/)).toBeInTheDocument(); }); }); @@ -392,10 +390,11 @@ describe('MCPRegistryPage', () => { it('opens create modal from empty state button', async () => { server.use( getMockedSearchMCPServersResponse([]), + getMockedSearchMCPAccessBindingsAllResponse([]), getMockedCreateMCPServerVersionResponse(), getMockedUpdateMCPServerResponse(), ); - renderPage(); + renderPage(['/?tab=servers']); await waitFor(() => { expect(screen.getByText('Create and manage MCP servers using MLflow.')).toBeInTheDocument(); }); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx index 2fb49b511513b..9316cc596ecdf 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx @@ -31,7 +31,8 @@ import { useMCPServersListQuery } from '../hooks/useMCPServersListQuery'; import { useCreateMCPServerVersionModal } from '../hooks/useCreateMCPServerVersionModal'; import { useMCPAccessBindingsListQuery } from '../hooks/useMCPAccessBindingsListQuery'; import { MCPServerCardGrid } from '../components/MCPServerCardGrid'; -import { MCPServerListTable, emptyCenterStyles } from '../components/MCPServerListTable'; +import { MCPServerListTable } from '../components/MCPServerListTable'; +import { emptyCenterStyles } from '../utils'; import { MCPRegistryApi } from '../api'; import MCPRegistryRoutes from '../routes'; import type { MCPAccessBinding } from '../types'; @@ -111,7 +112,13 @@ const MCPRegistryPage = () => { return new Promise((resolve, reject) => { updateTagsMutation.mutate( { serverName: entity.name, toAdd: addedOrModifiedTags, toDelete: deletedTags }, - { onSuccess: () => { resolve(); refetch(); }, onError: reject }, + { + onSuccess: () => { + resolve(); + refetch(); + }, + onError: reject, + }, ); }); }, 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 54704303e109b..ac4b2a7b04d86 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx @@ -136,7 +136,9 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - const editButton = document.querySelector('[data-component-id="mlflow.mcp_registry.detail.edit_status"]') as HTMLElement; + const editButton = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.edit_status"]', + ) as HTMLElement; await userEvent.click(editButton); await waitFor(() => { expect(screen.getByText('Update version status')).toBeInTheDocument(); @@ -211,7 +213,7 @@ describe('MCPServerDetailPage', () => { }); }); - 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', @@ -225,10 +227,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(); }); }); @@ -239,7 +240,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', @@ -258,10 +259,9 @@ 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(); }); }); @@ -283,7 +283,9 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - const editButton = document.querySelector('[data-component-id="mlflow.mcp_registry.detail.edit_status"]') as HTMLElement; + const editButton = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.edit_status"]', + ) as HTMLElement; await userEvent.click(editButton); await waitFor(() => { expect(screen.getByText(/terminal state/)).toBeInTheDocument(); @@ -324,10 +326,7 @@ describe('MCPServerDetailPage', () => { status: 'active', server_json: { name: 'dev.mainline/mcp', version: '2.0.0' }, }); - server.use( - getMockedSearchMCPServerVersionsResponse([mockVersion, version2]), - getMockedUpdateMCPServerResponse(), - ); + server.use(getMockedSearchMCPServerVersionsResponse([mockVersion, version2]), getMockedUpdateMCPServerResponse()); renderPage(); await waitFor(() => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); @@ -361,7 +360,9 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Viewing version 2')).toBeInTheDocument(); }); - const setLatestBtn = document.querySelector('[data-component-id="mlflow.mcp_registry.detail.set_latest"]') as HTMLButtonElement; + const setLatestBtn = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.set_latest"]', + ) as HTMLButtonElement; expect(setLatestBtn).toBeDisabled(); }); }); @@ -374,7 +375,9 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('A test server')).toBeInTheDocument(); }); - const editBtn = document.querySelector('[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]') as HTMLElement; + const editBtn = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]', + ) as HTMLElement; expect(editBtn).toBeInTheDocument(); }); @@ -399,7 +402,9 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('A test server')).toBeInTheDocument(); }); - const editBtn = document.querySelector('[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]') as HTMLElement; + const editBtn = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]', + ) as HTMLElement; await userEvent.click(editBtn); await waitFor(() => { expect(screen.getByText('Edit description')).toBeInTheDocument(); @@ -413,7 +418,9 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('A test server')).toBeInTheDocument(); }); - const editBtn = document.querySelector('[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]') as HTMLElement; + const editBtn = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]', + ) as HTMLElement; await userEvent.click(editBtn); await waitFor(() => { expect(screen.getByText('Edit description')).toBeInTheDocument(); @@ -490,4 +497,95 @@ describe('MCPServerDetailPage', () => { expect(resetItem).toBeDefined(); }); }); + + 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 344b2871b8318..e2e24a1b2a10e 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, @@ -39,9 +39,10 @@ import { useUpdateMCPServerVersionMetadataModal } from '../hooks/useUpdateMCPSer import { MCPServerVersionList } from '../components/MCPServerVersionList'; import { MCPServerVersionDetail } from '../components/MCPServerVersionDetail'; import { AccessBindingModal } from '../components/AccessBindingModal'; -import { useSelectedMCPServerVersion } from '../hooks/useSelectedMCPServerVersion'; +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) => ( @@ -84,13 +85,31 @@ const MCPServerDetailPage = () => { 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); - const currentVersion = useMemo(() => { - if (!versions?.length) return undefined; - return versions.find((v) => v.version === selectedVersion) ?? versions[0]; - }, [versions, selectedVersion]); + useEffect(() => { + if (!versions?.length) { + setSelectedVersion(undefined); + return; + } + const currentStillValid = versions.some((v) => v.version === selectedVersion); + if (!currentStillValid) { + setSelectedVersion(versions[0].version); + } + if (viewState.comparedVersion && !versions.some((v) => v.version === viewState.comparedVersion)) { + setComparedVersion( + versions[0]?.version === selectedVersion ? (versions[1]?.version ?? '') : (versions[0]?.version ?? ''), + ); + } + }, [versions, selectedVersion, viewState.comparedVersion, setComparedVersion, setSelectedVersion]); useEffect(() => { setLatestMutation.reset(); @@ -100,6 +119,11 @@ const MCPServerDetailPage = () => { const resolvedLatestVersion = latestVersion?.version; + const comparedVersionEntity = useMemo( + () => versions?.find((v) => v.version === viewState.comparedVersion), + [versions, viewState.comparedVersion], + ); + const aliasesByVersion = useMemo(() => { const result: Record = {}; server?.aliases?.forEach(({ alias, version }) => { @@ -119,6 +143,15 @@ const MCPServerDetailPage = () => { return result; }, [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>( @@ -126,7 +159,6 @@ const MCPServerDetailPage = () => { [isPinnedLatest], ); - const refetchAll = useCallback(async () => { await Promise.all([refetchServer(), refetchVersions(), refetchLatestVersion()]); }, [refetchServer, refetchVersions, refetchLatestVersion]); @@ -295,16 +327,20 @@ const MCPServerDetailPage = () => {
- +
- +
@@ -321,17 +357,20 @@ const MCPServerDetailPage = () => { closable={false} /> ) : ( - + )}
{ overflow: 'hidden', }} > - setAddBindingModalOpen(true)} - onEditBinding={(binding) => { - setEditingBinding(binding); - setAddBindingModalOpen(true); - }} - onDeleteBinding={setDeletingBinding} - onEditMetadata={showEditMetadataModal} - onSetLatest={handleSetLatest} - setLatestLoading={setLatestMutation.isLoading} - setLatestError={setLatestMutation.error as Error | null} - onClearLatestError={() => setLatestMutation.reset()} - resolvedLatestVersion={resolvedLatestVersion} - onUpdateDescription={async (description) => { - await MCPRegistryApi.updateMCPServer(decodedServerName, { description }); - await refetchAll(); - }} - /> + {viewState.mode === MCPServerDetailViewMode.COMPARE ? ( + + ) : ( + setAddBindingModalOpen(true)} + onEditBinding={(binding) => { + setEditingBinding(binding); + setAddBindingModalOpen(true); + }} + onDeleteBinding={setDeletingBinding} + onEditMetadata={showEditMetadataModal} + onSetLatest={handleSetLatest} + setLatestLoading={setLatestMutation.isLoading} + setLatestError={setLatestMutation.error as Error | null} + onClearLatestError={() => setLatestMutation.reset()} + resolvedLatestVersion={resolvedLatestVersion} + onUpdateDescription={async (description) => { + await MCPRegistryApi.updateMCPServer(serverName, { description }); + await refetchAll(); + }} + /> + )}
{EditAliasesModal} diff --git a/mlflow/server/js/src/mcp-registry/utils.test.ts b/mlflow/server/js/src/mcp-registry/utils.test.ts index 7a5faaf32c16b..ebab69a4dd70a 100644 --- a/mlflow/server/js/src/mcp-registry/utils.test.ts +++ b/mlflow/server/js/src/mcp-registry/utils.test.ts @@ -129,16 +129,16 @@ describe('STATUS_TAG_COLOR', () => { }); describe('STATUS_TRANSITIONS', () => { - it('draft can transition to active and deleted', () => { - expect(STATUS_TRANSITIONS.draft).toEqual(['active', 'deleted']); + it('draft can transition to active', () => { + expect(STATUS_TRANSITIONS.draft).toEqual(['active']); }); it('active can transition to draft and deprecated', () => { expect(STATUS_TRANSITIONS.active).toEqual(['draft', 'deprecated']); }); - it('deprecated can transition to active and deleted', () => { - expect(STATUS_TRANSITIONS.deprecated).toEqual(['active', 'deleted']); + it('deprecated can transition to active', () => { + expect(STATUS_TRANSITIONS.deprecated).toEqual(['active']); }); it('deleted has no transitions', () => { diff --git a/mlflow/server/js/src/model-registry/components/aliases/ModelVersionTableAliasesCell.tsx b/mlflow/server/js/src/model-registry/components/aliases/ModelVersionTableAliasesCell.tsx index 1df5b26315155..c45bb5e89c225 100644 --- a/mlflow/server/js/src/model-registry/components/aliases/ModelVersionTableAliasesCell.tsx +++ b/mlflow/server/js/src/model-registry/components/aliases/ModelVersionTableAliasesCell.tsx @@ -1,4 +1,4 @@ -import { Button, PencilIcon, Tooltip, useDesignSystemTheme } from '@databricks/design-system'; +import { Button, PencilIcon, useDesignSystemTheme } from '@databricks/design-system'; import type { TagColors } from '@databricks/design-system'; import { AliasTag } from '../../../common/components/AliasTag'; import { FormattedMessage } from 'react-intl'; @@ -11,7 +11,6 @@ interface ModelVersionTableAliasesCellProps { className?: string; highlightedAliases?: string[]; aliasColors?: Record; - aliasTooltips?: Record; } export const ModelVersionTableAliasesCell = ({ @@ -20,7 +19,6 @@ export const ModelVersionTableAliasesCell = ({ className, highlightedAliases, aliasColors, - aliasTooltips, }: ModelVersionTableAliasesCellProps) => { const { theme } = useDesignSystemTheme(); @@ -55,19 +53,7 @@ export const ModelVersionTableAliasesCell = ({ <> {aliases.map((alias) => { const color = aliasColors?.[alias] ?? (highlightedAliases?.includes(alias) ? 'turquoise' : undefined); - const tooltip = aliasTooltips?.[alias]; - const tag = ; - return tooltip ? ( - - {tag} - - ) : ( - - ); + return ; })} {showPopoverMessage ? ( - + ) : ( ( type="primary" onClick={saveTags} > - {intl.formatMessage({ - defaultMessage: 'Save tags', - description: 'Key-value tag editor modal > Manage Tag save button', - })} + {saveButtonLabel ?? + intl.formatMessage({ + defaultMessage: 'Save tags', + description: 'Key-value tag editor modal > Manage Tag save button', + })} )} @@ -304,10 +312,12 @@ function UnsavedTagPopoverTrigger({ isLoading, formValues, onSaveTask, + saveButtonLabel, }: { isLoading: boolean; formValues: any; onSaveTask: () => void; + saveButtonLabel?: string; }) { const intl = useIntl(); const { theme } = useDesignSystemTheme(); @@ -334,10 +344,11 @@ function UnsavedTagPopoverTrigger({ loading={isLoading} type="primary" > - {intl.formatMessage({ - defaultMessage: 'Save tags', - description: 'Key-value tag editor modal > Manage Tag save button', - })} + {saveButtonLabel ?? + intl.formatMessage({ + defaultMessage: 'Save tags', + description: 'Key-value tag editor modal > Manage Tag save button', + })} diff --git a/mlflow/server/js/src/lang/default/en.json b/mlflow/server/js/src/lang/default/en.json index d9560df1ab447..ab3b52f427009 100644 --- a/mlflow/server/js/src/lang/default/en.json +++ b/mlflow/server/js/src/lang/default/en.json @@ -263,10 +263,6 @@ "defaultMessage": "Input data or prompt template have changed since last evaluation of the output", "description": "Experiment page > new run modal > dirty output (out of sync with new data)" }, - "/Lbpxe": { - "defaultMessage": "Edit description", - "description": "MCP server version edit description modal title" - }, "/LfvdB": { "defaultMessage": "Average", "description": "Experiment page > group by runs control > average aggregate function" @@ -379,6 +375,10 @@ "defaultMessage": "{totalTokens} total tokens", "description": "Experiment page > artifact compare view > results table > total number of evaluated tokens" }, + "/pGvl1": { + "defaultMessage": "Edit status", + "description": "Aria label for edit status button" + }, "/qIHh7": { "defaultMessage": "Traces", "description": "Label for the scorer evaluation scope selection" @@ -619,6 +619,10 @@ "defaultMessage": "View Examples", "description": "Experiment page > new run modal > prompt examples button" }, + "147Q2W": { + "defaultMessage": "Edit description", + "description": "Aria label for edit description button" + }, "16BvfJ": { "defaultMessage": "{timeSince, plural, =1 {1 year} other {# years}} ago", "description": "Text for time in years since given date for MLflow views" @@ -1175,6 +1179,10 @@ "defaultMessage": "Compare", "description": "Runs charts > components > config > RunsChartsConfigureDifferenceChart > Compare config section" }, + "47HcTC": { + "defaultMessage": "Empty", + "description": "Fallback for empty server JSON in compare view" + }, "47JmSp": { "defaultMessage": "Configure new model", "description": "Option to configure new model" @@ -3867,6 +3875,10 @@ "defaultMessage": "Edit", "description": "Text for the edit button next to the description section title on\n the model version view page" }, + "IkH/5t": { + "defaultMessage": "Edit metadata", + "description": "Aria label for edit metadata button" + }, "Ikj6rr": { "defaultMessage": "Quick start", "description": "Gateway > Endpoints > Compact quick start section label" @@ -6539,6 +6551,10 @@ "defaultMessage": "Input an artifact location (optional)", "description": "Input placeholder to enter artifact location for create experiment" }, + "X+11OS": { + "defaultMessage": "Edit display name", + "description": "Aria label for edit display name button" + }, "X+boXI": { "defaultMessage": "Copied", "description": "Tooltip text shown when copy operation completes" @@ -10871,6 +10887,10 @@ "defaultMessage": "Description", "description": "Roles table description header" }, + "sYN+/j": { + "defaultMessage": "Edit server description", + "description": "MCP server edit server-level description modal title" + }, "sYkXR1": { "defaultMessage": "JSON body for the MLflow Invocations API. Use \"messages\" for chat or \"input\" for embeddings.", "description": "Request body tooltip for unified MLflow Invocations API" diff --git a/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx index 64d024c3067e9..f3d7a62df0d3f 100644 --- a/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx +++ b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx @@ -34,6 +34,8 @@ export const AccessBindingModal = ({ editBinding, lockedServer, defaultVersion, + filterToVersion, + filterAliases, }: { visible: boolean; onCancel: () => void; @@ -41,6 +43,8 @@ export const AccessBindingModal = ({ editBinding?: MCPAccessBinding; lockedServer?: string; defaultVersion?: string; + filterToVersion?: string; + filterAliases?: string[]; }) => { const { theme } = useDesignSystemTheme(); const intl = useIntl(); @@ -73,6 +77,7 @@ export const AccessBindingModal = ({ setSelectedTarget(defaultVersion ? `${VERSION_PREFIX}${defaultVersion}` : `${ALIAS_PREFIX}latest`); setTransportType('streamable-http'); } + setUrlTouched(false); createMutation.reset(); updateMutation.reset(); } @@ -80,8 +85,10 @@ export const AccessBindingModal = ({ const aliases = server?.aliases ?? []; const isSubmitting = activeMutation.isLoading; + const [urlTouched, setUrlTouched] = useState(false); const isValidUrl = isValidEndpointUrl(endpointUrl); + const showUrlError = urlTouched && endpointUrl.trim() && !isValidUrl; const isFormValid = Boolean(selectedServer && isValidUrl && selectedTarget); const handleSubmit = () => { @@ -212,14 +219,15 @@ export const AccessBindingModal = ({ componentId="mlflow.mcp_registry.binding_modal.endpoint" value={endpointUrl} onChange={(e) => setEndpointUrl(e.target.value)} + onBlur={() => setUrlTouched(true)} disabled={isSubmitting} placeholder={intl.formatMessage({ defaultMessage: 'https://mcp.example.com/server', description: 'MCP registry binding modal endpoint placeholder', })} - validationState={endpointUrl.trim() && !isValidUrl ? 'error' : undefined} + validationState={showUrlError ? 'error' : undefined} /> - {endpointUrl.trim() && !isValidUrl && ( + {showUrlError && ( setSelectedTarget(target.value)} disabled={!selectedServer || isSubmitting} > - - - - - {aliases.map((a) => ( - - @{a.alias} - - ))} - - {versions && versions.length > 0 && ( - - {versions.map((v) => ( - - {v.version} - - ))} - - )} + {(() => { + const filteredAliases = filterToVersion + ? aliases.filter((a) => a.version === filterToVersion) + : filterAliases + ? aliases.filter((a) => filterAliases.includes(a.alias)) + : aliases; + const filteredVersions = filterToVersion + ? versions?.filter((v) => v.version === filterToVersion) + : versions; + const showLatest = filterAliases ? filterAliases.includes('latest') : true; + + return ( + <> + + {showLatest && ( + + + + )} + {filteredAliases.map((a) => ( + + @{a.alias} + + ))} + + {filteredVersions && filteredVersions.length > 0 && ( + + {filteredVersions.map((v) => ( + + {v.version} + + ))} + + )} + + ); + })()} diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx index 34cd7350e814e..3cdecf2cfa894 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx @@ -119,6 +119,7 @@ export const MCPServerAccessBindings = ({ onAddBinding, onEditBinding, onDeleteBinding, + hideTitle, }: { server?: MCPServer; bindings?: MCPAccessBinding[]; @@ -127,24 +128,40 @@ export const MCPServerAccessBindings = ({ onAddBinding?: () => void; onEditBinding?: (binding: MCPAccessBinding) => void; onDeleteBinding?: (binding: MCPAccessBinding) => void; + hideTitle?: boolean; }) => { const { theme } = useDesignSystemTheme(); return (
-
- - - - -
+ {!hideTitle ? ( +
+ + + + {onAddBinding && ( + + )} +
+ ) : onAddBinding ? ( +
+ +
+ ) : null} {error ? ( { const { theme } = useDesignSystemTheme(); const intl = useIntl(); - const { data: latestVersion } = useLatestMCPServerVersionQuery(server.name); + const { data: latestVersion } = useLatestMCPServerVersionQuery(server.name, !server.latest_version); const displayName = resolveDisplayName(server); const latestVersionDisplay = server.latest_version || latestVersion?.version; @@ -30,13 +30,13 @@ export const MCPServerCard = ({ server }: { server: MCPServer }) => { -
-
+
+
{displayName} {latestVersionDisplay && ( - + {latestVersionDisplay} )} diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerListTable.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerListTable.tsx index e6497cf28cc86..6303d3e359ff3 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerListTable.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerListTable.tsx @@ -24,7 +24,7 @@ import { FormattedMessage, useIntl } from 'react-intl'; import type { MCPServer } from '../types'; import MCPRegistryRoutes from '../routes'; -import { emptyCenterStyles, resolveDisplayName } from '../utils'; +import { emptyCenterStyles, resolveDisplayName, tagsRecordToArray } from '../utils'; import { useLatestMCPServerVersionQuery } from '../hooks/useMCPServerDetailQuery'; import { Link } from '../../common/utils/RoutingUtils'; import { KeyValueTag } from '../../common/components/KeyValueTag'; @@ -59,7 +59,7 @@ const MCPServerTagsCell = ({ const intl = useIntl(); const { theme } = useDesignSystemTheme(); const { onEditTags } = (meta as MCPServerTableMeta) || {}; - const tags = Object.entries(original.tags).map(([key, value]) => ({ key, value })); + const tags = tagsRecordToArray(original.tags); const containsTags = tags.length > 0; return ( @@ -104,7 +104,7 @@ const MCPServerTagsCell = ({ }; const MCPServerLatestVersionCell = ({ row: { original } }: CellContext) => { - const { data: latestVersion } = useLatestMCPServerVersionQuery(original.name); + const { data: latestVersion } = useLatestMCPServerVersionQuery(original.name, !original.latest_version); return original.latest_version || latestVersion?.version || '—'; }; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.test.tsx new file mode 100644 index 0000000000000..b0db96b1a470c --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { DesignSystemProvider } from '@databricks/design-system'; +import { QueryClient, QueryClientProvider } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { MCPServerTagsBox } from './MCPServerTagsBox'; +import { createMockMCPServer } from '../test-utils'; + +const renderTagsBox = (props: Partial> = {}) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + + , + ); +}; + +describe('MCPServerTagsBox', () => { + it('renders "Add tags" button when server has no tags', () => { + const server = createMockMCPServer({ tags: {} }); + renderTagsBox({ server }); + expect(screen.getByText('Add tags')).toBeInTheDocument(); + }); + + it('renders tag values when server has tags', () => { + const server = createMockMCPServer({ tags: { env: 'prod', team: 'ml' } }); + renderTagsBox({ server }); + expect(screen.getByText('env')).toBeInTheDocument(); + expect(screen.getByText('team')).toBeInTheDocument(); + }); + + it('renders edit icon button when tags exist', () => { + const server = createMockMCPServer({ tags: { env: 'prod' } }); + renderTagsBox({ server }); + expect(screen.getByRole('button', { name: 'Edit tags' })).toBeInTheDocument(); + }); + + it('renders Add tags button when server is undefined', () => { + renderTagsBox({ server: undefined }); + expect(screen.getByText('Add tags')).toBeInTheDocument(); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.tsx index 039e59201aa96..4644fa95c8705 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerTagsBox.tsx @@ -1,64 +1,14 @@ import { Button, PencilIcon, useDesignSystemTheme } from '@databricks/design-system'; import { FormattedMessage, useIntl } from 'react-intl'; -import { useCallback } from 'react'; -import { useMutation } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; -import { useEditKeyValueTagsModal } from '../../common/hooks/useEditKeyValueTagsModal'; -import { diffCurrentAndNewTags } from '../../common/utils/TagUtils'; import { KeyValueTag } from '../../common/components/KeyValueTag'; -import { MCPRegistryApi } from '../api'; 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 }[]; -}; - -const tagsRecordToArray = (tags: Record) => - Object.entries(tags).map(([key, value]) => ({ key, value })); +import { tagsRecordToArray } from '../utils'; +import { useUpdateMCPServerTags } from '../hooks/useUpdateMCPServerTags'; export const MCPServerTagsBox = ({ server, onTagsUpdated }: { server?: MCPServer; onTagsUpdated?: () => void }) => { const intl = useIntl(); const { theme } = useDesignSystemTheme(); - - const updateMutation = useMutation({ - mutationFn: async ({ toAdd, toDelete, serverName }) => { - return Promise.all([ - ...toAdd.map(({ key, value }) => MCPRegistryApi.setMCPServerTag(serverName, { key, value })), - ...toDelete.map(({ key }) => MCPRegistryApi.deleteMCPServerTag(serverName, key)), - ]); - }, - }); - - const { EditTagsModal, showEditTagsModal } = useEditKeyValueTagsModal({ - valueRequired: true, - saveTagsHandler: (entity, currentTags, newTags) => { - const { addedOrModifiedTags, deletedTags } = diffCurrentAndNewTags(currentTags, newTags); - return new Promise((resolve, reject) => { - if (!entity.name) return reject(); - updateMutation.mutate( - { serverName: entity.name, toAdd: addedOrModifiedTags, toDelete: deletedTags }, - { - onSuccess: () => { - resolve(); - onTagsUpdated?.(); - }, - onError: reject, - }, - ); - }); - }, - }); - - const handleEdit = useCallback(() => { - if (!server) return; - showEditTagsModal({ - name: server.name, - tags: tagsRecordToArray(server.tags), - }); - }, [server, showEditTagsModal]); + const { EditTagsModal, showEditServerTagsModal } = useUpdateMCPServerTags({ onSuccess: onTagsUpdated }); const visibleTags = server ? tagsRecordToArray(server.tags) : []; const containsTags = visibleTags.length > 0; @@ -82,7 +32,7 @@ export const MCPServerTagsBox = ({ server, onTagsUpdated }: { server?: MCPServer componentId="mlflow.mcp_registry.detail.tags.edit" size="small" icon={!containsTags ? undefined : } - onClick={handleEdit} + onClick={() => server && showEditServerTagsModal(server)} aria-label={intl.formatMessage({ defaultMessage: 'Edit tags', description: 'Label for the edit tags button on the MCP server detail page', diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.test.tsx new file mode 100644 index 0000000000000..c5e2b0440af54 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.test.tsx @@ -0,0 +1,75 @@ +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 { MCPServerVersionCompare } from './MCPServerVersionCompare'; +import { createMockMCPServerVersion } from '../test-utils'; + +const v1 = createMockMCPServerVersion({ + version: '1', + status: 'active', + server_json: { name: 'test', version: '1.0', title: 'Version 1', description: 'First version' }, + tags: { env: 'prod' }, +}); +const v2 = createMockMCPServerVersion({ + version: '2', + status: 'draft', + server_json: { name: 'test', version: '2.0', title: 'Version 2', description: 'Second version' }, + tags: {}, +}); + +const renderCompare = (props: Partial> = {}) => + render( + + + + + , + ); + +describe('MCPServerVersionCompare', () => { + it('renders comparing heading with version numbers', () => { + renderCompare(); + expect(screen.getByText(/Comparing version 1 with version 2/)).toBeInTheDocument(); + }); + + it('renders status tags for both versions', () => { + renderCompare(); + expect(screen.getByText('active')).toBeInTheDocument(); + expect(screen.getByText('draft')).toBeInTheDocument(); + }); + + it('renders JSON content for both versions', () => { + renderCompare(); + expect(screen.getByText(/"version": "1.0"/)).toBeInTheDocument(); + }); + + it('calls onSwitchSides when switch button is clicked', async () => { + const onSwitchSides = jest.fn(); + renderCompare({ onSwitchSides }); + await userEvent.click(screen.getByRole('button', { name: /Switch sides/ })); + expect(onSwitchSides).toHaveBeenCalledTimes(1); + }); + + it('renders Empty fallback when baseline has no server_json', () => { + const emptyVersion = createMockMCPServerVersion({ + version: '0', + server_json: undefined as any, + }); + renderCompare({ baselineVersion: emptyVersion }); + expect(screen.getByText('Empty')).toBeInTheDocument(); + }); + + it('renders metadata tags when present', () => { + renderCompare(); + expect(screen.getByText('env')).toBeInTheDocument(); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.tsx index 803cf4aab0a3b..a05a5d29045ca 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionCompare.tsx @@ -20,12 +20,10 @@ import Utils from '../../common/utils/Utils'; const VersionMetadataGrid = ({ version, - serverName, aliasesByVersion, aliasColors, }: { version?: MCPServerVersion; - serverName: string; aliasesByVersion: Record; aliasColors?: Record; }) => { @@ -38,7 +36,7 @@ const VersionMetadataGrid = ({
- {Object.keys(version.tags).length > 0 && ( + {Object.keys(version.tags ?? {}).length > 0 && ( <>
- {Object.entries(version.tags).map(([key, value]) => ( + {Object.entries(version.tags ?? {}).map(([key, value]) => ( ))}
@@ -110,11 +108,11 @@ export const MCPServerVersionCompare = ({ const baselineJson = useMemo( () => (baselineVersion?.server_json ? JSON.stringify(baselineVersion.server_json, null, 2) : ''), - [baselineVersion], + [baselineVersion?.server_json], ); const comparedJson = useMemo( () => (comparedVersion?.server_json ? JSON.stringify(comparedVersion.server_json, null, 2) : ''), - [comparedVersion], + [comparedVersion?.server_json], ); const diff = useMemo(() => diffWords(baselineJson, comparedJson) ?? [], [baselineJson, comparedJson]); @@ -127,6 +125,21 @@ export const MCPServerVersionCompare = ({ [theme], ); + const jsonPanelStyles = useMemo( + () => ({ + flex: 1, + margin: 0, + padding: theme.spacing.md, + backgroundColor: theme.colors.backgroundSecondary, + borderRadius: theme.borders.borderRadiusSm, + overflow: 'auto' as const, + fontSize: theme.typography.fontSizeSm, + whiteSpace: 'pre-wrap' as const, + wordBreak: 'break-word' as const, + }), + [theme], + ); + return (
@@ -164,7 +176,6 @@ export const MCPServerVersionCompare = ({
@@ -174,20 +185,14 @@ export const MCPServerVersionCompare = ({
-
-          {baselineJson || 'Empty'}
+        
+          
+            {baselineJson ||
+              intl.formatMessage({
+                defaultMessage: 'Empty',
+                description: 'Fallback for empty server JSON in compare view',
+              })}
+          
         
@@ -213,19 +218,7 @@ export const MCPServerVersionCompare = ({
-
+        
           
             {diff.map((part, index) => (
                void;
   onDeleteBinding?: (binding: MCPAccessBinding) => void;
   onEditMetadata?: (version: MCPServerVersion) => void;
-  onSetLatest?: (version: string | null) => void;
-  setLatestLoading?: boolean;
-  setLatestError?: Error | null;
-  onClearLatestError?: () => void;
   resolvedLatestVersion?: string;
-  onUpdateDescription?: (description: string | null) => Promise;
 }) => {
   const { theme } = useDesignSystemTheme();
   const intl = useIntl();
-  const [statusModalVisible, setStatusModalVisible] = useState(false);
-  const [displayNameModalVisible, setDisplayNameModalVisible] = useState(false);
+  const [editVersionModalVisible, setEditVersionModalVisible] = useState(false);
+  const [editVersionDisplayName, setEditVersionDisplayName] = useState('');
+  const [editVersionStatus, setEditVersionStatus] = useState('draft');
+  const [editVersionAliases, setEditVersionAliases] = useState([]);
+  const [editVersionToolsText, setEditVersionToolsText] = useState('');
   const [deleteModalVisible, setDeleteModalVisible] = useState(false);
-  const [descriptionModalVisible, setDescriptionModalVisible] = useState(false);
-  const [descriptionDraft, setDescriptionDraft] = useState('');
-  const [descriptionSaving, setDescriptionSaving] = useState(false);
-  const [descriptionError, setDescriptionError] = useState(null);
+  const [toolsValidationError, setToolsValidationError] = useState(null);
 
-  const updateStatusMutation = useUpdateMCPServerVersionStatus(server.name);
-  const updateDisplayNameMutation = useUpdateMCPServerVersionDisplayName(server.name);
+  const updateVersionMutation = useUpdateMCPServerVersion(server.name);
   const deleteVersionMutation = useDeleteMCPServerVersion(server.name);
 
   if (!version) {
@@ -112,21 +102,6 @@ export const MCPServerVersionDetail = ({
   const versionDisplayName = version.display_name || version.server_json?.title;
   const showVersionDisplayName = versionDisplayName && versionDisplayName !== displayName;
 
-  const isPinned = server.latest_version === version.version;
-  const isResolvedLatest = resolvedLatestVersion === version.version;
-  const isDraftVersion = version.status === 'draft';
-  const setLatestDisabled = !isPinned && !isResolvedLatest && isDraftVersion;
-
-  const setLatestLabel = isPinned ? (
-    
-  ) : isResolvedLatest && !server.latest_version ? (
-    
-  ) : (
-    
-  );
-
-  const setLatestOnClick = isPinned ? () => onSetLatest?.(null) : () => onSetLatest?.(version.version);
-
   return (
     
@@ -139,83 +114,35 @@ export const MCPServerVersionDetail = ({ /> {showVersionDisplayName && ( -
- - {versionDisplayName} - -
+ + {versionDisplayName} + + )} + {version.server_json?.description && ( + + {version.server_json.description} + )} - {(() => { - const description = server.description; - return description ? ( -
- {description} -
- ) : ( - - ); - })()}
- {onSetLatest && - (() => { - const button = ( - - ); - return setLatestDisabled ? ( - - {button} - - ) : ( - button - ); - })()} +
- {setLatestError && ( - <> - - - - )}
@@ -290,12 +205,6 @@ export const MCPServerVersionDetail = ({ {version.status} -
- {version.server_json && } +
+ {version.server_json && ( + + + + } + defaultCollapsed + componentId="mlflow.mcp_registry.detail.configuration_section" + > +
+              {JSON.stringify(version.server_json, null, 2)}
+            
+
+ )} + {version.tools && version.tools.length > 0 && ( + + {chunks}, + }} + /> + + } + defaultCollapsed + componentId="mlflow.mcp_registry.detail.tools_section" + > + + + +
+ {version.tools.map((tool) => ( +
+ + {tool.name} + + {tool.description && ( + + {tool.description} + + )} +
+ ))} +
+
+ )} - - + + + + } + defaultCollapsed + componentId="mlflow.mcp_registry.detail.bindings_section" + > + + +
- { - updateStatusMutation.mutate( - { version: version.version, status: newStatus }, - { onSuccess: () => setStatusModalVisible(false) }, - ); - }} - onCancel={() => { - updateStatusMutation.reset(); - setStatusModalVisible(false); - }} - /> + + } + visible={editVersionModalVisible} + destroyOnClose + confirmLoading={updateVersionMutation.isLoading} + okText={} + onOk={() => { + const payload: Parameters[0] = { version: version.version }; + + if (editVersionDisplayName !== (version.display_name || version.server_json?.title || '')) { + payload.displayName = editVersionDisplayName; + } + if (editVersionStatus !== version.status) { + payload.status = editVersionStatus; + } + + setToolsValidationError(null); + const currentToolsJson = version.tools?.length ? JSON.stringify(version.tools, null, 2) : ''; + if (editVersionToolsText !== currentToolsJson) { + if (editVersionToolsText.trim()) { + const toolsResult = validateToolsJson(editVersionToolsText); + if (!toolsResult.valid) { + setToolsValidationError(toolsResult.error ?? 'Invalid tools JSON'); + return; + } + payload.tools = toolsResult.parsed ?? null; + } else { + payload.tools = []; + } + } + + const existingAliases = (aliasesByVersion[version.version] ?? []).filter((a) => a !== LATEST_ALIAS); + const addedAliases = editVersionAliases.filter((a) => !existingAliases.includes(a)); + const deletedAliases = existingAliases.filter((a) => !editVersionAliases.includes(a)); + if (addedAliases.length > 0 || deletedAliases.length > 0) { + payload.aliases = { add: addedAliases, remove: deletedAliases }; + } - { - updateDisplayNameMutation.mutate( - { version: version.version, displayName }, - { onSuccess: () => setDisplayNameModalVisible(false) }, - ); + updateVersionMutation.mutate(payload, { + onSuccess: () => setEditVersionModalVisible(false), + }); }} onCancel={() => { - updateDisplayNameMutation.reset(); - setDisplayNameModalVisible(false); + updateVersionMutation.reset(); + setToolsValidationError(null); + setEditVersionModalVisible(false); }} - /> + > + {updateVersionMutation.error && ( + updateVersionMutation.reset()} + message={(updateVersionMutation.error as Error).message} + css={{ marginBottom: theme.spacing.sm }} + /> + )} +
+
+ + + + 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', + })} + /> +
+
+
- - } - visible={descriptionModalVisible} - destroyOnClose - confirmLoading={descriptionSaving} - okText={ - - } - cancelText={ - - } - onOk={async () => { - if (onUpdateDescription) { - setDescriptionSaving(true); - setDescriptionError(null); - try { - await onUpdateDescription(descriptionDraft || null); - setDescriptionModalVisible(false); - } catch (e) { - setDescriptionError(e as Error); - } finally { - setDescriptionSaving(false); - } - } - }} - onCancel={() => { - setDescriptionError(null); - setDescriptionModalVisible(false); - }} - > - {descriptionError && ( - setDescriptionError(null)} - message={descriptionError.message} - css={{ marginBottom: theme.spacing.sm }} - /> - )} - setDescriptionDraft(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/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 index f92a695cd7d8f..5905c4fb3873f 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDiffSelectorButton.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDiffSelectorButton.tsx @@ -18,7 +18,14 @@ export const MCPServerVersionDiffSelectorButton = ({
-
+
{ - 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..14b69359678d8 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.test.tsx @@ -0,0 +1,90 @@ +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..96297c887a990 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.tsx @@ -0,0 +1,75 @@ +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 index 8f1c437d27a80..b82dc1d4eb942 100644 --- a/mlflow/server/js/src/mcp-registry/components/UpdateVersionDisplayNameModal.tsx +++ b/mlflow/server/js/src/mcp-registry/components/UpdateVersionDisplayNameModal.tsx @@ -58,6 +58,10 @@ export const UpdateVersionDisplayNameModal = ({ componentId="mlflow.mcp_registry.detail.display_name_input" value={draft} onChange={(e) => 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 a30ad49f6ab70..0000000000000 --- a/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx +++ /dev/null @@ -1,122 +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)} - > - {(['draft', 'active', 'deprecated'] as MCPStatus[]) - .filter((status) => status !== currentStatus) - .map((status) => ( - - {capitalize(status)} - - ))} - -
- - )} -
-
- ); -}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts index 647c391f3f861..004d63498c094 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts @@ -1,6 +1,7 @@ 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; @@ -26,32 +27,36 @@ export const useCreateMCPServerVersionMutation = () => { tools, }); - if (isNewServer) { - const serverDisplayName = displayName || serverJson.title; - if (serverDisplayName || serverJson.description) { - await MCPRegistryApi.updateMCPServer(name, { - display_name: serverDisplayName || undefined, - description: serverJson.description || undefined, - }); + 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))); + 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 (e) { + console.warn('Version created but secondary metadata/tag operation failed:', e); } return version; }, onSuccess: (_data, { serverJson }) => { const name = serverJson.name; - queryClient.invalidateQueries(['mcp_servers_list']); - queryClient.invalidateQueries(['mcp_server', name]); - queryClient.invalidateQueries(['mcp_server_versions', name]); - queryClient.invalidateQueries(['mcp_server_latest_version', 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 ff43b9afc31d0..06489d71614e1 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts @@ -30,17 +30,20 @@ export const useMCPServerVersionsQuery = (name: string) => { }; }; -export const useLatestMCPServerVersionQuery = (name: string) => { - return useQuery(['mcp_server_latest_version', name], { +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 { - return undefined; + } 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: Boolean(name) && enabled, }); }; 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 index 88b1e9fac5a42..b9e986f75e83c 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailViewState.ts @@ -32,8 +32,14 @@ const viewStateReducer = (state: State, action: ViewAction): State => { 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 }; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts index d1974e8ec122c..c99a663e1daf0 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,32 +9,54 @@ 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_server_latest_version', 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 }), - onSuccess: () => invalidate(serverName), - }); -}; + 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; + } -export const useUpdateMCPServerVersionDisplayName = (serverName: string) => { - const queryClient = useQueryClient(); + const promises: Promise[] = []; - return useMutation({ - mutationFn: ({ version, displayName }: { version: string; displayName: string }) => - MCPRegistryApi.updateMCPServerVersion(serverName, version, { display_name: displayName || null }), - onSuccess: () => { - queryClient.invalidateQueries(['mcp_server', serverName]); - queryClient.invalidateQueries(['mcp_server_versions', serverName]); - queryClient.invalidateQueries(['mcp_servers_list']); + 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), }); }; @@ -47,17 +69,13 @@ export const useDeleteMCPServerVersion = (serverName: string) => { }); }; -export const useSetLatestVersion = (serverName: string) => { - const queryClient = useQueryClient(); +export const useUpdateMCPServerDisplayName = (serverName: string) => { + const invalidate = useInvalidateServerQueries(); return useMutation({ - mutationFn: (version: string | null) => MCPRegistryApi.updateMCPServer(serverName, { latest_version: version }), - onSuccess: () => { - queryClient.invalidateQueries(['mcp_server', serverName]); - queryClient.invalidateQueries(['mcp_server_versions', serverName]); - queryClient.invalidateQueries(['mcp_server_latest_version', serverName]); - queryClient.invalidateQueries(['mcp_servers_list']); - }, + mutationFn: (displayName: string | null) => + MCPRegistryApi.updateMCPServer(serverName, { display_name: displayName }), + onSuccess: () => invalidate(serverName), }); }; 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 index c4f127b7b2d4d..932cfb695c405 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useUpdateMCPServerVersionMetadataModal.tsx +++ b/mlflow/server/js/src/mcp-registry/hooks/useUpdateMCPServerVersionMetadataModal.tsx @@ -2,9 +2,10 @@ import { useMutation, useQueryClient } from '@mlflow/mlflow/src/common/utils/rea 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 } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import type { KeyValueEntity } from '../../common/types'; type UpdateMCPServerVersionMetadataPayload = { @@ -22,6 +23,7 @@ export const useUpdateMCPServerVersionMetadataModal = ({ onSuccess?: () => void; }) => { const queryClient = useQueryClient(); + const intl = useIntl(); const updateMutation = useMutation({ mutationFn: async ({ serverName: name, version, toAdd, toDelete }) => { @@ -39,10 +41,14 @@ export const useUpdateMCPServerVersionMetadataModal = ({ } = 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); @@ -57,7 +63,9 @@ export const useUpdateMCPServerVersionMetadataModal = ({ }, { onSuccess: () => { - queryClient.invalidateQueries(['mcp_server_versions', serverName]); + 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?.(); }, @@ -72,7 +80,7 @@ export const useUpdateMCPServerVersionMetadataModal = ({ (version: MCPServerVersion) => showEditTagsModal({ version: version.version, - tags: Object.entries(version.tags).map(([key, value]) => ({ key, value })), + tags: tagsRecordToArray(version.tags), }), [showEditTagsModal], ); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx index 61f2e997921ce..77feaadabed38 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx @@ -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 f79d437a85e25..8d3ac37c3a99c 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx @@ -102,6 +102,7 @@ describe('MCPRegistryPage', () => { }); it('converts plain text search into a valid name filter', async () => { + jest.setTimeout(15000); let capturedFilterString: string | null = null; server.use( rest.get(getAjaxUrl('ajax-api/3.0/mlflow/mcp-servers'), (req, res, ctx) => { @@ -119,9 +120,12 @@ describe('MCPRegistryPage', () => { 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("name LIKE '%raw%'"); + }, + { timeout: 10000 }, + ); }); it('shows Create MCP server button when servers exist', async () => { diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx index 9316cc596ecdf..d5fd476f98ced 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx @@ -24,16 +24,13 @@ import { withErrorBoundary } from '../../common/utils/withErrorBoundary'; import ErrorUtils from '../../common/utils/ErrorUtils'; import { useNavigate, useSearchParams } from '../../common/utils/RoutingUtils'; import { ModelSearchInputHelpTooltip } from '../../model-registry/components/model-list/ModelListFilters'; -import { useMutation } from '../../common/utils/reactQueryHooks'; -import { useEditKeyValueTagsModal } from '../../common/hooks/useEditKeyValueTagsModal'; -import { diffCurrentAndNewTags } from '../../common/utils/TagUtils'; 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 { emptyCenterStyles } from '../utils'; -import { MCPRegistryApi } from '../api'; import MCPRegistryRoutes from '../routes'; import type { MCPAccessBinding } from '../types'; import { MCPAccessBindingCardGrid } from '../components/MCPAccessBindingCardGrid'; @@ -91,49 +88,10 @@ const MCPRegistryPage = () => { onSuccess: ({ name }) => navigate(MCPRegistryRoutes.getMCPServerDetailRoute(name)), }); - type MCPServerTagEntity = { name: string; tags?: { key: string; value: string }[] }; - - const updateTagsMutation = useMutation< - unknown, - Error, - { serverName: string; toAdd: { key: string; value: string }[]; toDelete: { key: string }[] } - >({ - mutationFn: async ({ serverName, toAdd, toDelete }) => - Promise.all([ - ...toAdd.map(({ key, value }) => MCPRegistryApi.setMCPServerTag(serverName, { key, value })), - ...toDelete.map(({ key }) => MCPRegistryApi.deleteMCPServerTag(serverName, key)), - ]), - }); - - const { EditTagsModal, showEditTagsModal } = useEditKeyValueTagsModal({ - valueRequired: true, - saveTagsHandler: (entity, currentTags, newTags) => { - const { addedOrModifiedTags, deletedTags } = diffCurrentAndNewTags(currentTags, newTags); - return new Promise((resolve, reject) => { - updateTagsMutation.mutate( - { serverName: entity.name, toAdd: addedOrModifiedTags, toDelete: deletedTags }, - { - onSuccess: () => { - resolve(); - refetch(); - }, - onError: reject, - }, - ); - }); - }, + const { EditTagsModal, showEditServerTagsModal: handleEditTags } = useUpdateMCPServerTags({ + onSuccess: refetch, }); - const handleEditTags = useCallback( - (server: MCPServer) => { - showEditTagsModal({ - name: server.name, - tags: Object.entries(server.tags).map(([key, value]) => ({ key, value })), - }); - }, - [showEditTagsModal], - ); - const handleTabChange = useCallback( (e: RadioChangeEvent) => { const value = e.target.value as ActiveTab; 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 ac4b2a7b04d86..4f789e7b22481 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'; @@ -22,8 +22,6 @@ import { getMockedUpdateMCPServerErrorResponse, getMockedSetMCPServerTagResponse, getMockedDeleteMCPServerTagResponse, - getMockedSetMCPServerAliasResponse, - getMockedDeleteMCPServerAliasResponse, } from '../test-utils'; const mockServer = createMockMCPServer({ @@ -100,7 +98,7 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); expect(screen.getAllByText('dev.mainline/mcp').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('A test server')).toBeInTheDocument(); + expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); }); it('renders error state when server not found', async () => { @@ -111,13 +109,13 @@ describe('MCPServerDetailPage', () => { }); }); - it('expands JSON viewer', async () => { + it('expands configuration section', async () => { renderPage(); await waitFor(() => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - await userEvent.click(screen.getByText('View full configuration')); + await userEvent.click(screen.getByText('Configuration')); await waitFor(() => { expect(screen.getByText(/"name": "dev.mainline\/mcp"/)).toBeInTheDocument(); }); @@ -125,24 +123,29 @@ describe('MCPServerDetailPage', () => { 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 status button 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(); }); - const editButton = document.querySelector( - '[data-component-id="mlflow.mcp_registry.detail.edit_status"]', + const editBtn = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.edit_version"]', ) as HTMLElement; - await userEvent.click(editButton); + 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(); }); }); @@ -208,6 +211,11 @@ 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(); }); @@ -265,7 +273,7 @@ describe('MCPServerDetailPage', () => { }); }); - 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', @@ -283,88 +291,14 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); }); - const editButton = document.querySelector( - '[data-component-id="mlflow.mcp_registry.detail.edit_status"]', + const editBtn = document.querySelector( + '[data-component-id="mlflow.mcp_registry.detail.edit_version"]', ) as HTMLElement; - await userEvent.click(editButton); + await userEvent.click(editBtn); await waitFor(() => { - expect(screen.getByText(/terminal state/)).toBeInTheDocument(); - }); - }); - - describe('set-as-latest flow', () => { - it('shows "Pin as latest" for the resolved latest version when not pinned', async () => { - server.use(getMockedUpdateMCPServerResponse()); - renderPage(); - await waitFor(() => { - expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); - }); - - expect(screen.getByText('Pin as latest')).toBeInTheDocument(); - }); - - it('shows "Unpin latest" when version is pinned as latest', async () => { - const pinnedServer = createMockMCPServer({ - name: 'dev.mainline/mcp', - display_name: 'Mainline', - description: 'A test server', - latest_version: '1', - }); - server.use(getMockedGetMCPServerResponse(pinnedServer), getMockedUpdateMCPServerResponse()); - renderPage(); - await waitFor(() => { - expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); - }); - - expect(screen.getByText('Unpin latest')).toBeInTheDocument(); - }); - - it('shows "Set as latest" for a non-latest version', async () => { - const version2 = createMockMCPServerVersion({ - name: 'dev.mainline/mcp', - version: '2', - status: 'active', - server_json: { name: 'dev.mainline/mcp', version: '2.0.0' }, - }); - server.use(getMockedSearchMCPServerVersionsResponse([mockVersion, version2]), getMockedUpdateMCPServerResponse()); - renderPage(); - await waitFor(() => { - expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByText('2')); - await waitFor(() => { - expect(screen.getByText('Viewing version 2')).toBeInTheDocument(); - }); - expect(screen.getByText('Set as latest')).toBeInTheDocument(); - }); - - it('disables set-as-latest for draft versions that are not latest', async () => { - const draftVersion = createMockMCPServerVersion({ - name: 'dev.mainline/mcp', - version: '2', - status: 'draft', - server_json: { name: 'dev.mainline/mcp', version: '2.0.0' }, - }); - server.use( - getMockedSearchMCPServerVersionsResponse([mockVersion, draftVersion]), - getMockedUpdateMCPServerResponse(), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByText('2')); - await waitFor(() => { - expect(screen.getByText('Viewing version 2')).toBeInTheDocument(); - }); - - const setLatestBtn = document.querySelector( - '[data-component-id="mlflow.mcp_registry.detail.set_latest"]', - ) as HTMLButtonElement; - expect(setLatestBtn).toBeDisabled(); + expect(screen.getByText('Edit version details')).toBeInTheDocument(); }); + expect(screen.getByText('Status')).toBeInTheDocument(); }); describe('server description editing', () => { @@ -372,7 +306,7 @@ describe('MCPServerDetailPage', () => { server.use(getMockedUpdateMCPServerResponse()); renderPage(); await waitFor(() => { - expect(screen.getByText('A test server')).toBeInTheDocument(); + expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); }); const editBtn = document.querySelector( @@ -386,7 +320,17 @@ describe('MCPServerDetailPage', () => { name: 'dev.mainline/mcp', display_name: 'Mainline', }); - server.use(getMockedGetMCPServerResponse(noDescServer)); + const noDescVersion = createMockMCPServerVersion({ + name: 'dev.mainline/mcp', + version: '1', + status: 'active', + server_json: { name: 'dev.mainline/mcp', version: '1.0.0', title: 'Mainline' }, + }); + server.use( + getMockedGetMCPServerResponse(noDescServer), + getMockedSearchMCPServerVersionsResponse([noDescVersion]), + getMockedGetLatestMCPServerVersionResponse(noDescVersion), + ); renderPage(); await waitFor(() => { expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); @@ -399,7 +343,7 @@ describe('MCPServerDetailPage', () => { server.use(getMockedUpdateMCPServerResponse()); renderPage(); await waitFor(() => { - expect(screen.getByText('A test server')).toBeInTheDocument(); + expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); }); const editBtn = document.querySelector( @@ -407,7 +351,7 @@ describe('MCPServerDetailPage', () => { ) as HTMLElement; await userEvent.click(editBtn); await waitFor(() => { - expect(screen.getByText('Edit description')).toBeInTheDocument(); + expect(screen.getByText('Edit server description')).toBeInTheDocument(); }); }); @@ -415,7 +359,7 @@ describe('MCPServerDetailPage', () => { server.use(getMockedUpdateMCPServerErrorResponse(500, 'Server error')); renderPage(); await waitFor(() => { - expect(screen.getByText('A test server')).toBeInTheDocument(); + expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); }); const editBtn = document.querySelector( @@ -423,7 +367,7 @@ describe('MCPServerDetailPage', () => { ) as HTMLElement; await userEvent.click(editBtn); await waitFor(() => { - expect(screen.getByText('Edit description')).toBeInTheDocument(); + expect(screen.getByText('Edit server description')).toBeInTheDocument(); }); await userEvent.click(screen.getByText('Save')); @@ -483,21 +427,6 @@ describe('MCPServerDetailPage', () => { }); }); - describe('reset latest version', () => { - it('shows reset latest version in 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 resetItem = menuItems.find((item) => item.textContent === 'Reset latest version'); - expect(resetItem).toBeDefined(); - }); - }); - it('Compare toggle is disabled with a single version', async () => { renderPage(); await waitFor(() => { diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx index e2e24a1b2a10e..203b80ef4acef 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx @@ -18,7 +18,7 @@ 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'; @@ -31,7 +31,10 @@ import { useLatestMCPServerVersionQuery, useMCPAccessBindingsQuery, } from '../hooks/useMCPServerDetailQuery'; -import { useDeleteMCPServer, useSetLatestVersion } from '../hooks/useMCPServerVersionMutations'; +import { + useDeleteMCPServer, + useUpdateMCPServerDisplayName, +} from '../hooks/useMCPServerVersionMutations'; import { useDeleteAccessBindingMutation } from '../hooks/useAccessBindingMutation'; import type { MCPAccessBinding } from '../types'; import { useCreateMCPServerVersionModal } from '../hooks/useCreateMCPServerVersionModal'; @@ -58,18 +61,17 @@ const MCPServerDetailPage = () => { 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 [serverDisplayNameSaving, setServerDisplayNameSaving] = useState(false); - const [serverDisplayNameError, setServerDisplayNameError] = useState(null); const deleteServerMutation = useDeleteMCPServer(); + const updateDisplayNameMutation = useUpdateMCPServerDisplayName(serverName); const deleteBindingMutation = useDeleteAccessBindingMutation(); - const setLatestMutation = useSetLatestVersion(serverName); - const { data: server, isLoading: serverLoading, @@ -102,18 +104,27 @@ const MCPServerDetailPage = () => { } const currentStillValid = versions.some((v) => v.version === selectedVersion); if (!currentStillValid) { - setSelectedVersion(versions[0].version); + const urlVersion = + versionFromUrl && versions.some((v) => v.version === versionFromUrl) ? versionFromUrl : undefined; + setSelectedVersion(urlVersion ?? versions[0].version); } - if (viewState.comparedVersion && !versions.some((v) => v.version === viewState.comparedVersion)) { + 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, viewState.comparedVersion, setComparedVersion, setSelectedVersion]); - - useEffect(() => { - setLatestMutation.reset(); - }, [selectedVersion]); // eslint-disable-line react-hooks/exhaustive-deps + }, [ + versions, + selectedVersion, + versionFromUrl, + viewState.comparedVersion, + viewState.mode, + setComparedVersion, + setSelectedVersion, + setPreviewMode, + ]); const currentVersion = versions?.find((v) => v.version === selectedVersion); @@ -163,13 +174,6 @@ const MCPServerDetailPage = () => { await Promise.all([refetchServer(), refetchVersions(), refetchLatestVersion()]); }, [refetchServer, refetchVersions, refetchLatestVersion]); - const handleSetLatest = useCallback( - (version: string | null) => { - setLatestMutation.mutate(version); - }, - [setLatestMutation], - ); - const { CreateMCPServerVersionModal, openModal: openCreateVersionModal } = useCreateMCPServerVersionModal({ serverName: serverName, latestVersion: currentVersion, @@ -289,16 +293,6 @@ const MCPServerDetailPage = () => { description="MCP server detail edit server display name action" /> - handleSetLatest(null)} - > - - setDeleteServerModalVisible(true)} @@ -409,15 +403,7 @@ const MCPServerDetailPage = () => { }} onDeleteBinding={setDeletingBinding} onEditMetadata={showEditMetadataModal} - onSetLatest={handleSetLatest} - setLatestLoading={setLatestMutation.isLoading} - setLatestError={setLatestMutation.error as Error | null} - onClearLatestError={() => setLatestMutation.reset()} resolvedLatestVersion={resolvedLatestVersion} - onUpdateDescription={async (description) => { - await MCPRegistryApi.updateMCPServer(serverName, { description }); - await refetchAll(); - }} /> )}
@@ -432,27 +418,23 @@ const MCPServerDetailPage = () => { editBinding={editingBinding} lockedServer={serverName} defaultVersion={currentVersion?.version} + filterToVersion={currentVersion?.version} + filterAliases={currentVersion ? aliasesByVersion[currentVersion.version] : undefined} /> {EditMCPServerVersionMetadataModal} {CreateMCPServerVersionModal} { - setServerDisplayNameSaving(true); - setServerDisplayNameError(null); - MCPRegistryApi.updateMCPServer(serverName, { display_name: newDisplayName || null }) - .then(() => { - setEditServerDisplayNameVisible(false); - refetchAll(); - }) - .catch((e: Error) => setServerDisplayNameError(e)) - .finally(() => setServerDisplayNameSaving(false)); + updateDisplayNameMutation.mutate(newDisplayName || null, { + onSuccess: () => setEditServerDisplayNameVisible(false), + }); }} onCancel={() => { - setServerDisplayNameError(null); + updateDisplayNameMutation.reset(); setEditServerDisplayNameVisible(false); }} /> diff --git a/mlflow/server/js/src/mcp-registry/test-utils.ts b/mlflow/server/js/src/mcp-registry/test-utils.ts index 34db04ddbc4ad..af1ec4a77e6d1 100644 --- a/mlflow/server/js/src/mcp-registry/test-utils.ts +++ b/mlflow/server/js/src/mcp-registry/test-utils.ts @@ -112,8 +112,4 @@ export const getMockedSetMCPServerTagResponse = () => export const getMockedDeleteMCPServerTagResponse = () => rest.delete(getAjaxUrl(`${BASE_URL}/:name/tags/:key`), (_req, res, ctx) => res(ctx.json({}))); -export const getMockedSetMCPServerAliasResponse = () => - rest.post(getAjaxUrl(`${BASE_URL}/:name/aliases`), (_req, res, ctx) => res(ctx.json({}))); -export const getMockedDeleteMCPServerAliasResponse = () => - rest.delete(getAjaxUrl(`${BASE_URL}/:name/aliases/:alias`), (_req, res, ctx) => res(ctx.json({}))); diff --git a/mlflow/server/js/src/mcp-registry/utils.test.ts b/mlflow/server/js/src/mcp-registry/utils.test.ts index ebab69a4dd70a..ff7aec6dcdbe8 100644 --- a/mlflow/server/js/src/mcp-registry/utils.test.ts +++ b/mlflow/server/js/src/mcp-registry/utils.test.ts @@ -80,23 +80,24 @@ describe('buildSearchFilterClause', () => { expect(buildSearchFilterClause('', 'name')).toBeUndefined(); }); - it('wraps plain text in ILIKE clause', () => { - expect(buildSearchFilterClause('test', 'name')).toBe("name ILIKE '%test%'"); + it('wraps plain text in LIKE clause', () => { + expect(buildSearchFilterClause('test', 'name')).toBe("name LIKE '%test%'"); }); it('uses the specified field name', () => { - expect(buildSearchFilterClause('test', 'server_name')).toBe("server_name ILIKE '%test%'"); + expect(buildSearchFilterClause('test', 'server_name')).toBe("server_name LIKE '%test%'"); }); it('escapes single quotes in the search term', () => { - expect(buildSearchFilterClause("it's", 'name')).toBe("name ILIKE '%it''s%'"); + expect(buildSearchFilterClause("it's", 'name')).toBe("name LIKE '%it''s%'"); }); it('passes through explicit SQL filter syntax', () => { expect(buildSearchFilterClause("status = 'active'", 'name')).toBe("status = 'active'"); }); - it('passes through ILIKE expressions', () => { + it('passes through LIKE and ILIKE expressions', () => { + expect(buildSearchFilterClause("name LIKE '%foo%'", 'name')).toBe("name LIKE '%foo%'"); expect(buildSearchFilterClause("name ILIKE '%foo%'", 'name')).toBe("name ILIKE '%foo%'"); }); @@ -115,7 +116,8 @@ describe('formatTransportType', () => { }); it('returns raw value for unknown types', () => { - expect(formatTransportType('unknown' as any)).toBe('unknown'); + // @ts-expect-error testing unknown transport type + expect(formatTransportType('unknown')).toBe('unknown'); }); }); diff --git a/mlflow/server/js/src/mcp-registry/utils.ts b/mlflow/server/js/src/mcp-registry/utils.ts index 8593516047159..b86c2243434ad 100644 --- a/mlflow/server/js/src/mcp-registry/utils.ts +++ b/mlflow/server/js/src/mcp-registry/utils.ts @@ -1,5 +1,5 @@ import type { TagProps } from '@databricks/design-system'; -import type { MCPRemoteTransportType, MCPStatus, ServerJSONPayload } from './types'; +import type { MCPRemoteTransportType, MCPStatus, MCPTool, ServerJSONPayload } from './types'; export const STATUS_TAG_COLOR: Record = { draft: 'charcoal', @@ -39,6 +39,7 @@ export const MCP_QUERY_KEYS = { SERVERS_LIST: 'mcp_servers_list', SERVER: 'mcp_server', SERVER_VERSIONS: 'mcp_server_versions', + SERVER_LATEST_VERSION: 'mcp_server_latest_version', SERVER_BINDINGS: 'mcp_server_bindings', BINDINGS_LIST: 'mcp_bindings_list', BINDING_DETAIL: 'mcp_binding_detail', @@ -51,6 +52,9 @@ export const resolveDisplayName = (server: { display_name?: string; name: string return server.display_name || server.name; }; +export const tagsRecordToArray = (tags: Record): { key: string; value: string }[] => + Object.entries(tags).map(([key, value]) => ({ key, value })); + export const resolveVersionDisplayName = ( version: { display_name?: string; server_json?: { title?: string } } | null | undefined, fallback: string, @@ -78,7 +82,7 @@ export const buildSearchFilterClause = (searchFilter: string | undefined, field: if (sqlKeywordPattern.test(searchFilter)) { return searchFilter; } - return `${field} ILIKE '%${searchFilter.replace(/'/g, "''")}%'`; + return `${field} LIKE '%${searchFilter.replace(/'/g, "''")}%'`; }; export const isValidEndpointUrl = (url: string): boolean => { @@ -131,7 +135,7 @@ export const validateServerJson = (value: string): ServerJsonValidationResult => return { valid: true, parsed: obj as ServerJSONPayload }; }; -export const validateToolsJson = (value: string): { valid: boolean; error?: string; parsed?: unknown[] } => { +export const validateToolsJson = (value: string): { valid: boolean; error?: string; parsed?: MCPTool[] } => { const trimmed = value?.trim(); if (!trimmed) { return { valid: true }; @@ -158,5 +162,5 @@ export const validateToolsJson = (value: string): { valid: boolean; error?: stri } } - return { valid: true, parsed: parsed as unknown[] }; + return { valid: true, parsed: parsed as MCPTool[] }; }; From 1750be9b3832b5d5fed6c3d8b95e076c27cf2b6b Mon Sep 17 00:00:00 2001 From: Nana Nosirova <10577112+nananosirova@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:48:03 -0400 Subject: [PATCH 5/5] Add structured server.json sections: tools, expandable remotes, schema toggle --- .../componentId-registry.js | 48 +- mlflow/server/js/src/lang/default/en.json | 256 +++-- .../components/AccessBindingModal.test.tsx | 1 + .../components/MCPServerAccessBindings.tsx | 5 +- .../components/MCPServerVersionDetail.tsx | 173 +--- .../components/ServerJSONSection.tsx | 948 ++++++++++++++++++ .../UpdateDescriptionModal.test.tsx | 7 +- .../components/UpdateDescriptionModal.tsx | 4 +- .../useCreateMCPServerVersionMutation.ts | 4 +- .../hooks/useMCPServerVersionMutations.ts | 4 +- .../pages/MCPRegistryPage.test.tsx | 10 +- .../pages/MCPServerDetailPage.test.tsx | 160 +-- .../pages/MCPServerDetailPage.tsx | 5 +- .../server/js/src/mcp-registry/test-utils.ts | 2 - mlflow/server/js/src/mcp-registry/types.ts | 1 + 15 files changed, 1328 insertions(+), 300 deletions(-) create mode 100644 mlflow/server/js/src/mcp-registry/components/ServerJSONSection.tsx diff --git a/.github/actions/check-component-ids/componentId-registry.js b/.github/actions/check-component-ids/componentId-registry.js index 363c47b0cba7c..69eed2544a209 100644 --- a/.github/actions/check-component-ids/componentId-registry.js +++ b/.github/actions/check-component-ids/componentId-registry.js @@ -1888,7 +1888,6 @@ module.exports = { "mlflow.mcp_registry.detail.actions": "", "mlflow.mcp_registry.detail.actions.delete": "", "mlflow.mcp_registry.detail.actions.edit_display_name": "", - "mlflow.mcp_registry.detail.actions.reset_latest": "", "mlflow.mcp_registry.detail.add_binding": "", "mlflow.mcp_registry.detail.binding.card": "", "mlflow.mcp_registry.detail.binding.delete": "", @@ -1902,30 +1901,53 @@ module.exports = { "mlflow.mcp_registry.detail.delete_version": "", "mlflow.mcp_registry.detail.delete_version_modal": "", "mlflow.mcp_registry.detail.display_name_input": "", - "mlflow.mcp_registry.detail.edit_status": "", + "mlflow.mcp_registry.detail.edit_version": "", "mlflow.mcp_registry.detail.error": "", "mlflow.mcp_registry.detail.repository": "", "mlflow.mcp_registry.detail.select_baseline.tooltip": "", "mlflow.mcp_registry.detail.select_compared.tooltip": "", - "mlflow.mcp_registry.detail.set_latest": "", - "mlflow.mcp_registry.detail.set_latest.tooltip": "", - "mlflow.mcp_registry.detail.set_latest_error": "", - "mlflow.mcp_registry.detail.status_select": "", "mlflow.mcp_registry.detail.tags.edit": "", - "mlflow.mcp_registry.detail.toggle_json": "", "mlflow.mcp_registry.detail.update_display_name_error": "", "mlflow.mcp_registry.detail.update_display_name_modal": "", - "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.version.add_description": "", + "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_description": "", - "mlflow.mcp_registry.detail.version.edit_display_name": "", + "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": "", diff --git a/mlflow/server/js/src/lang/default/en.json b/mlflow/server/js/src/lang/default/en.json index ab3b52f427009..5e32af62d7a3f 100644 --- a/mlflow/server/js/src/lang/default/en.json +++ b/mlflow/server/js/src/lang/default/en.json @@ -83,10 +83,6 @@ "defaultMessage": "Last updated", "description": "Header for the dataset last-updated column" }, - "+QPD70": { - "defaultMessage": "Add/Edit Version Metadata", - "description": "Title for a modal that allows the user to add or edit metadata tags on MCP server versions." - }, "+T+iqa": { "defaultMessage": "Select baseline run", "description": "Placeholder text for the baseline run selector dropdown" @@ -147,10 +143,18 @@ "defaultMessage": "Max", "description": "Column title for the column displaying the maximum metric values for a metric" }, + "+itf/D": { + "defaultMessage": "Save", + "description": "Save button" + }, "+jMswj": { "defaultMessage": "Target:", "description": "Binding card target label" }, + "+jTk/P": { + "defaultMessage": "Show less", + "description": "MCP server package show less env vars button" + }, "+khKtf": { "defaultMessage": "None", "description": "A short label for experiments with no experiment kind" @@ -219,6 +223,10 @@ "defaultMessage": "Does the app's response avoid harmful or toxic content?", "description": "Hint for Safety template" }, + "/Adc0h": { + "defaultMessage": "Copy identifier", + "description": "Tooltip for copy package identifier button" + }, "/Aup8Q": { "defaultMessage": "Request time", "description": "Column label for request time" @@ -319,10 +327,6 @@ "defaultMessage": "Service unavailable error", "description": "Request failed due to service being available (HTTP STATUS 503) generic error message" }, - "/YXV/m": { - "defaultMessage": "Pin as latest", - "description": "MCP server pin as latest version button" - }, "/ZsbLo": { "defaultMessage": "Add feedback", "description": "Label for the button to add new feedback" @@ -375,10 +379,6 @@ "defaultMessage": "{totalTokens} total tokens", "description": "Experiment page > artifact compare view > results table > total number of evaluated tokens" }, - "/pGvl1": { - "defaultMessage": "Edit status", - "description": "Aria label for edit status button" - }, "/qIHh7": { "defaultMessage": "Traces", "description": "Label for the scorer evaluation scope selection" @@ -619,10 +619,6 @@ "defaultMessage": "View Examples", "description": "Experiment page > new run modal > prompt examples button" }, - "147Q2W": { - "defaultMessage": "Edit description", - "description": "Aria label for edit description button" - }, "16BvfJ": { "defaultMessage": "{timeSince, plural, =1 {1 year} other {# years}} ago", "description": "Text for time in years since given date for MLflow views" @@ -763,6 +759,10 @@ "defaultMessage": "MCP Registry", "description": "Sidebar link for MCP Registry page" }, + "1oaTIy": { + "defaultMessage": "secret", + "description": "Header secret badge" + }, "1oofcq": { "defaultMessage": "Show less", "description": "Link that collapses an expanded list when clicked" @@ -887,14 +887,14 @@ "defaultMessage": "Search", "description": "Search placeholder" }, + "2ScZy5": { + "defaultMessage": "Transport:", + "description": "MCP server package transport label" + }, "2Us2jl": { "defaultMessage": "X axis", "description": "Label for X axis in Contour chart configurator in compare runs chart config modal" }, - "2VOGgj": { - "defaultMessage": "Unpin latest", - "description": "MCP server unpin latest version button" - }, "2WOIGG": { "defaultMessage": "Prompt created", "description": "Webhook event label" @@ -1255,6 +1255,10 @@ "defaultMessage": "Expectations", "description": "Section title for expectations in the V2 dataset record side panel" }, + "4QTnoO": { + "defaultMessage": "View raw server.json", + "description": "MCP server version detail view raw JSON button" + }, "4Qft47": { "defaultMessage": "{nodeCount, plural, =0 {} one {# node} other {# nodes}}", "description": "Count of selected nodes displayed in the node level metric charts node selector" @@ -1287,10 +1291,6 @@ "defaultMessage": "Close", "description": "Close button for tag details modal" }, - "4eVj/Q": { - "defaultMessage": "Update version status", - "description": "MCP server update version status modal title" - }, "4g2H7S": { "defaultMessage": "Prompt content is required", "description": "A validation state for the chat prompt content in the prompt creation modal" @@ -1363,10 +1363,18 @@ "defaultMessage": "Click to pin the run", "description": "A tooltip for the pin icon button in the runs chart tooltip next to the not pinned run" }, + "59sogb": { + "defaultMessage": "Enter tools JSON array", + "description": "Placeholder for version tools input" + }, "5B5dhT": { "defaultMessage": "Validate the model before deployment", "description": "Heading text for validating the model before deploying it for serving" }, + "5F/eep": { + "defaultMessage": "Tools", + "description": "Version edit tools label" + }, "5GCYzy": { "defaultMessage": "Experiment Runs - Databricks", "description": "Title on a page used to manage MLflow experiments runs" @@ -1959,6 +1967,10 @@ "defaultMessage": "Type a key", "description": "Key-value tag editor modal > Tag dropdown > Tag input placeholder" }, + "8CXWXX": { + "defaultMessage": "Tools ({count})", + "description": "MCP server version detail tools subsection heading" + }, "8DoNdT": { "defaultMessage": "Save", "description": "Save button text for edit endpoint name modal" @@ -2051,6 +2063,10 @@ "defaultMessage": "Enable Usage Tracking in the Overview tab to view logs", "description": "Tooltip shown on disabled Logs tab explaining that usage tracking must be enabled first" }, + "8mdDKH": { + "defaultMessage": "Copy URL", + "description": "Tooltip for copy remote URL button" + }, "8oh0iW": { "defaultMessage": "Clear filters", "description": "Usage overview > clear metrics filters button" @@ -2315,6 +2331,10 @@ "defaultMessage": "Reset period", "description": "Budget reset period label" }, + "9yiunK": { + "defaultMessage": "Copy JSON", + "description": "Tooltip for copy raw server.json button" + }, "9zZeT2": { "defaultMessage": "Delete records", "description": "Title for the V2 dataset records bulk-delete confirmation modal" @@ -2791,6 +2811,10 @@ "defaultMessage": "System Metrics", "description": "Experiment tracking > runs charts > cards > RunsChartsDifferenceChartCard > system metrics heading" }, + "CHdcpF": { + "defaultMessage": "Show {count} more", + "description": "MCP server version detail show more packages button" + }, "CO81il": { "defaultMessage": "No usage data available", "description": "Empty state title" @@ -2803,10 +2827,6 @@ "defaultMessage": "GenAI apps & agents", "description": "A short label for custom experiments automatically identified as being focused on generative AI app and agent development" }, - "CPq0qA": { - "defaultMessage": "This version is in a terminal state and cannot be transitioned.", - "description": "MCP server terminal status warning" - }, "CRr6Tx": { "defaultMessage": "Create and manage judges", "description": "Title for the empty state of the judges page" @@ -3119,6 +3139,10 @@ "defaultMessage": "{count} selected", "description": "Label for item selector showing count of selected items" }, + "ED/0qk": { + "defaultMessage": "Packages ({count})", + "description": "MCP server version detail packages subsection heading" + }, "ED1+Xu": { "defaultMessage": "Prompts & versions", "description": "Label for the versions section in the MLflow experiment navbar" @@ -3191,10 +3215,18 @@ "defaultMessage": "Metrics", "description": "Label for the ungrouped metrics column group in the logged model column selector" }, + "Ehnbx0": { + "defaultMessage": "Environment Variables ({count})", + "description": "MCP server package environment variables heading with count" + }, "EiFPFP": { "defaultMessage": "Custom Guardrail", "description": "Custom guardrail type name" }, + "EiV1ml": { + "defaultMessage": "Edit version details", + "description": "MCP server version edit details modal title" + }, "EjPmMR": { "defaultMessage": "Workspace Manager", "description": "Roles table column header for the workspace-admin marker (multi-tenant)" @@ -3263,10 +3295,6 @@ "defaultMessage": "Stop Sequences", "description": "Experiment page > prompt lab > stop parameter label" }, - "FAilrQ": { - "defaultMessage": "Reset latest version", - "description": "MCP server detail reset pinned latest version action" - }, "FBR3Q/": { "defaultMessage": "Overview", "description": "Label for the overview tab on the logged model details page" @@ -3491,6 +3519,10 @@ "defaultMessage": "Run evaluation", "description": "Home page quick action title for running evaluations" }, + "GeC7uZ": { + "defaultMessage": "Show less", + "description": "MCP server version detail show less tools button" + }, "Gg/vLZ": { "defaultMessage": "Delete Model", "description": "Title text for delete model modal on model view page" @@ -3551,6 +3583,10 @@ "defaultMessage": "null", "description": "Null value in the evaluations table." }, + "H3W2Hz": { + "defaultMessage": "URL Variables", + "description": "MCP server remote URL variables heading" + }, "H7JwOl": { "defaultMessage": "Delete version", "description": "A label for a button to delete prompt version on the prompt details page" @@ -4055,6 +4091,10 @@ "defaultMessage": "STRING", "description": "Header label for string assessment type" }, + "Je8178": { + "defaultMessage": "Hide raw server.json", + "description": "MCP server version detail hide raw JSON button" + }, "JfFfzy": { "defaultMessage": "Sample rate:", "description": "Sample rate label for scorer" @@ -4087,10 +4127,6 @@ "defaultMessage": "Delete", "description": "Bulk-delete button on the users table (no rows selected)" }, - "JqEZzt": { - "defaultMessage": "New status:", - "description": "MCP server new status label in update modal" - }, "Jr4PZf": { "defaultMessage": "server.json:", "description": "Label for server.json field in create MCP server modal" @@ -4139,6 +4175,10 @@ "defaultMessage": "Experiment renamed", "description": "Partial save status message when experiment rename succeeds" }, + "K8AByX": { + "defaultMessage": "Output Schema", + "description": "MCP tool output schema toggle" + }, "K8JcAN": { "defaultMessage": "Datasets", "description": "Label for the datasets filter dropdown in the logged model list page" @@ -4263,10 +4303,6 @@ "defaultMessage": "The dataset may have been deleted, or you may not have access to it.", "description": "Body shown when the V2 evaluation dataset detail page fails to fetch its dataset" }, - "KjmpxQ": { - "defaultMessage": "View full configuration", - "description": "MCP server detail toggle JSON viewer" - }, "Kkr/RI": { "defaultMessage": "Configure charts", "description": "Experiment page > view controls > global settings for line chart view > dropdown button label" @@ -4939,6 +4975,10 @@ "defaultMessage": "Expectations", "description": "Column selector label for the expectations column" }, + "OHUKmu": { + "defaultMessage": "Add/Edit Metadata", + "description": "Title for a modal that allows the user to add or edit metadata tags on MCP server versions." + }, "OLZyxe": { "defaultMessage": "{errorCount, plural, one {# error} other {# errors}}", "description": "Error count in tooltip" @@ -5199,14 +5239,14 @@ "defaultMessage": "GenAI apps & agents", "description": "Label for experiments focused on generative AI model development" }, + "Pgl9HJ": { + "defaultMessage": "Hide raw JSON", + "description": "MCP server version detail hide raw tools JSON button" + }, "Pgxfhl": { "defaultMessage": "Role", "description": "Roles table column header for the role name" }, - "PitYnw": { - "defaultMessage": "Current status:", - "description": "MCP server current status label in update modal" - }, "PlP56F": { "defaultMessage": "Enter value", "description": "Usage overview > filter row > value input placeholder" @@ -5319,6 +5359,10 @@ "defaultMessage": "Total", "description": "Label for total token usage" }, + "QL5BPA": { + "defaultMessage": "{action} tool {name}", + "description": "Aria label for expanding/collapsing a tool row" + }, "QLRaAz": { "defaultMessage": "Select all rows on this page", "description": "Aria label for the select-all checkbox in the V2 dataset records table header" @@ -5827,10 +5871,6 @@ "defaultMessage": "Line Smoothness", "description": "Label for the smoothness slider for the graph plot for metrics" }, - "T9MvMc": { - "defaultMessage": "Set as latest", - "description": "MCP server set as latest version button" - }, "TBX+Gs": { "defaultMessage": "Add/Edit tags", "description": "Key-value tag editor modal > Title of the update tags modal" @@ -5927,6 +5967,10 @@ "defaultMessage": "Inputs", "description": "Table section name for schema inputs in the model comparison page" }, + "Tj7qTS": { + "defaultMessage": "Copy JSON", + "description": "Tooltip for copy JSON button" + }, "Tl63jz": { "defaultMessage": "All runs are filtered", "description": "Empty state title text for experiment runs page when all runs have been filtered out" @@ -6379,6 +6423,10 @@ "defaultMessage": "Search chat sessions by input", "description": "Placeholder text for the search input in the chat sessions table" }, + "W7JOu2": { + "defaultMessage": "required", + "description": "Header required badge" + }, "W8A4OF": { "defaultMessage": "Configure OpenTelemetry export", "description": "Step 1 title for OpenTelemetry traces onboarding" @@ -6551,10 +6599,6 @@ "defaultMessage": "Input an artifact location (optional)", "description": "Input placeholder to enter artifact location for create experiment" }, - "X+11OS": { - "defaultMessage": "Edit display name", - "description": "Aria label for edit display name button" - }, "X+boXI": { "defaultMessage": "Copied", "description": "Tooltip text shown when copy operation completes" @@ -6647,6 +6691,10 @@ "defaultMessage": "Learn more", "description": "Link text in column header tooltip that opens documentation in a new tab" }, + "XWtgGm": { + "defaultMessage": "Status", + "description": "Version edit status label" + }, "XX8+x1": { "defaultMessage": "View prompt template", "description": "Experiment page > artifact compare view > run column header prompt metadata > \"view prompt template\" button label" @@ -6831,10 +6879,6 @@ "defaultMessage": "Start:", "description": "Label for the start time of a span" }, - "YY9VcI": { - "defaultMessage": "Add description", - "description": "MCP server version add description button" - }, "YZDyOo": { "defaultMessage": "Path:", "description": "Label to display the full path of where the artifact of the experiment runs is located" @@ -6911,10 +6955,6 @@ "defaultMessage": "Create", "description": "Create budget policy button text" }, - "Z+69vv": { - "defaultMessage": "Update", - "description": "MCP server update version status modal confirm button" - }, "Z+tEhr": { "defaultMessage": "Compare selected runs", "description": "Tooltip for the compare button when enabled" @@ -7443,6 +7483,10 @@ "defaultMessage": "Run ID:", "description": "Text for run ID header in the main table in the model comparison page" }, + "bjH2oX": { + "defaultMessage": "Display name", + "description": "Aria label for display name input" + }, "bjoGjg": { "defaultMessage": "Last hour", "description": "Option for the start select dropdown to filter runs from the last hour" @@ -7507,6 +7551,10 @@ "defaultMessage": "Type", "description": "Run page > Overview > Experiment ID section label" }, + "by0DzI": { + "defaultMessage": "JSON content", + "description": "Aria label for JSON code block" + }, "byhyEj": { "defaultMessage": "Re-Run judge", "description": "Button text for re-running judge" @@ -7655,6 +7703,10 @@ "defaultMessage": "Something went wrong", "description": "Generic title used when a dataset action surfaces an unexpected error" }, + "cTwsmx": { + "defaultMessage": "Copy JSON", + "description": "Tooltip for copy raw tools JSON button" + }, "cXhWaG": { "defaultMessage": "Select Model", "description": "Header for the model selection step in issue detection modal" @@ -7743,6 +7795,10 @@ "defaultMessage": "Last updated:", "description": "Binding detail last updated label" }, + "d2ZU/7": { + "defaultMessage": "secret", + "description": "MCP server package env var secret badge" + }, "d2b9xa": { "defaultMessage": "Enable Usage Tracking in the Overview tab to configure guardrails", "description": "Tooltip shown on disabled Guardrails tab explaining that usage tracking must be enabled first" @@ -7791,6 +7847,10 @@ "defaultMessage": "{count} {count, plural, one {hour} other {hours}}", "description": "Formatted trace archival retention in hours" }, + "dDbtkI": { + "defaultMessage": "Tools ({count})", + "description": "MCP server version detail tools tab" + }, "dEGUt+": { "defaultMessage": "Use this button to automatically detect quality issues in your traces using AI. This feature helps you identify problems like correctness, latency, and other quality concerns in your agent.", "description": "Detect issues guidance popover message" @@ -7887,6 +7947,10 @@ "defaultMessage": "You cannot select rows when comparing runs", "description": "Tooltip message for the select button when comparing runs" }, + "dsPent": { + "defaultMessage": "Headers ({count})", + "description": "MCP server remote headers heading with count" + }, "dt3hj5": { "defaultMessage": "Add tags", "description": "Run page > Overview > Tags cell > 'Add' button label" @@ -8835,6 +8899,10 @@ "defaultMessage": "My webhook", "description": "Webhook name placeholder" }, + "iWh6qQ": { + "defaultMessage": "Version:", + "description": "MCP server package version label" + }, "iXb99e": { "defaultMessage": "Box Plot", "description": "Tab pane title for box plot on the compare runs page" @@ -8883,6 +8951,10 @@ "defaultMessage": "Failed to duplicate some endpoints. Please try again.", "description": "Gateway > Endpoints list > Duplicate error message" }, + "ik/j6P": { + "defaultMessage": "Show {count} more", + "description": "MCP server package show more env vars button" + }, "imKpxw": { "defaultMessage": "ID", "description": "Label for the ID section" @@ -9051,6 +9123,10 @@ "defaultMessage": "All runs are hidden. Select at least one run to view charts.", "description": "Experiment tracking > runs charts > indication displayed when no runs are selected for comparison" }, + "jZVaFN": { + "defaultMessage": "Input Schema", + "description": "MCP tool input schema toggle" + }, "jcJXyE": { "defaultMessage": "Summarization", "description": "LLM template option" @@ -9091,10 +9167,18 @@ "defaultMessage": "Pending", "description": "Label for pending state of a experiment logged model" }, + "joZkJS": { + "defaultMessage": "Remotes ({count})", + "description": "MCP server version detail remotes subsection heading" + }, "joh88n": { "defaultMessage": "{numValue} for run \"{runName}\"", "description": "Error/null assessment tooltip" }, + "jp25Wv": { + "defaultMessage": "Save metadata", + "description": "Button label for saving metadata in the MCP server version metadata modal" + }, "jpvRrY": { "defaultMessage": "Pre-LLM Guardrails", "description": "Pipeline BEFORE stage label" @@ -9543,6 +9627,10 @@ "defaultMessage": "Last modified", "description": "Last modified column header" }, + "mDb6cp": { + "defaultMessage": "{action} remote {url}", + "description": "Aria label for expanding/collapsing a remote row" + }, "mGUpJv": { "defaultMessage": "Failed to create record", "description": "Generic fallback save-error text for the dataset record side panel (create mode)" @@ -9791,6 +9879,10 @@ "defaultMessage": "Pass", "description": "The label for a passing asseessment above a bar-chart in the summary stats." }, + "nE+gTQ": { + "defaultMessage": "Show less", + "description": "MCP server version detail show less packages button" + }, "nEnDEu": { "defaultMessage": "Cancel", "description": "Cancel create evaluation dataset button text" @@ -9803,6 +9895,10 @@ "defaultMessage": "Machine learning", "description": "Label for custom experiments focused on machine learning" }, + "nGF/Tf": { + "defaultMessage": "Show {count} more", + "description": "MCP server version detail show more tools button" + }, "nIk08y": { "defaultMessage": "Trace view", "description": "Tooltip for traces preview mode toggle in evaluation runs table controls" @@ -10011,6 +10107,10 @@ "defaultMessage": "Description", "description": "Title text for the description section under details tab on the model\n view page" }, + "oHglm6": { + "defaultMessage": "Configuration", + "description": "MCP server version detail configuration tab" + }, "oKNOju": { "defaultMessage": "Conversational tool call efficiency", "description": "LLM template option" @@ -10103,6 +10203,10 @@ "defaultMessage": "PII Detection", "description": "PII guardrail type name" }, + "oj/VFA": { + "defaultMessage": "Display name", + "description": "Version edit display name label" + }, "ojLGIx": { "defaultMessage": "Enter a feedback name", "description": "Placeholder for the feedback name typeahead" @@ -10963,6 +11067,10 @@ "defaultMessage": "Dataset metadata", "description": "Title for the V2 evaluation dataset metadata modal" }, + "t+UhZI": { + "defaultMessage": "required", + "description": "MCP server package env var required badge" + }, "t+aG6y": { "defaultMessage": "User", "description": "Display text for the 'user' role in a GenAI chat message." @@ -11427,10 +11535,6 @@ "defaultMessage": "Back to experiment list", "description": "Tooltip for experiments button" }, - "vr2aGt": { - "defaultMessage": "Draft versions cannot be set as latest", - "description": "Tooltip explaining why set as latest is disabled for draft versions" - }, "vwD2zW": { "defaultMessage": "Unified APIs", "description": "Unified APIs tab title" @@ -11479,6 +11583,10 @@ "defaultMessage": "Use a custom model name", "description": "Label for custom model input section" }, + "w5RsIq": { + "defaultMessage": "{action} package {identifier}", + "description": "Aria label for expanding/collapsing a package row" + }, "w80phd": { "defaultMessage": "Error", "description": "Title for error fallback component in experiment evaluation runs UI" @@ -11583,6 +11691,10 @@ "defaultMessage": "Here's how to optimize your prompt with your dataset in your Python code:", "description": "Description of how to optimize a prompt with a dataset in Python" }, + "wdBU9n": { + "defaultMessage": "View raw JSON", + "description": "MCP server version detail view raw tools JSON button" + }, "wh+Eos": { "defaultMessage": "Create prompt", "description": "A header for the create prompt modal in the prompt management UI" @@ -11731,6 +11843,10 @@ "defaultMessage": "Latency (AVG)", "description": "Column header for average latency" }, + "xRb5P+": { + "defaultMessage": "Edit", + "description": "MCP server edit version button" + }, "xRw7H2": { "defaultMessage": "Created by", "description": "Column title text for creator username in model version table" @@ -12015,6 +12131,10 @@ "defaultMessage": "Source run", "description": "Label for the column indicating a run being the source of the logged model's metric (i.e. source run). Displayed in the logged model details metrics table." }, + "z4Cxm8": { + "defaultMessage": "Select version for comparison", + "description": "Aria label for the version comparison radio group" + }, "z4aclU": { "defaultMessage": "Discard", "description": "Confirm-button text for the discard-unsaved-changes prompt" @@ -12179,6 +12299,10 @@ "defaultMessage": "Adherence", "description": "Issue category title for adherence" }, + "znbl+r": { + "defaultMessage": "Access Bindings", + "description": "MCP server version detail access bindings tab" + }, "zp4MBw": { "defaultMessage": "Token count", "description": "Label for the token count section" @@ -12207,6 +12331,10 @@ "defaultMessage": "An error occurred while attempting to delete traces. Please refresh the page and try again.", "description": "Experiment page > traces view controls > Delete traces modal > Error message" }, + "zznpND": { + "defaultMessage": "Aliases", + "description": "Version edit aliases label" + }, "zzuzri": { "defaultMessage": "Description", "description": "Webhook description field label" diff --git a/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.test.tsx b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.test.tsx index 4415dbe45b48a..b0e732f89240c 100644 --- a/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.test.tsx +++ b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.test.tsx @@ -83,6 +83,7 @@ describe('AccessBindingModal', () => { renderModal(); const input = screen.getByPlaceholderText('https://mcp.example.com/server'); await userEvent.type(input, 'not-a-url'); + await userEvent.tab(); await waitFor(() => { expect(screen.getByText('Enter a valid HTTP or HTTPS URL')).toBeInTheDocument(); }); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx index 3cdecf2cfa894..09282555e4251 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx @@ -146,7 +146,10 @@ export const MCPServerAccessBindings = ({ onClick={onAddBinding} size="small" > - + )}
diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx index aa5e13af7ccae..38e9dca8693d3 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx @@ -7,6 +7,7 @@ import { Modal, PencilIcon, Spacer, + Tabs, Tag, SimpleSelect, SimpleSelectOption, @@ -20,14 +21,11 @@ import { FormattedMessage, useIntl } from 'react-intl'; import type { MCPAccessBinding, MCPServer, MCPServerVersion } from '../types'; import { STATUS_TAG_COLOR, STATUS_TRANSITIONS, resolveDisplayName } from '../utils'; import type { MCPStatus } from '../types'; -import { CollapsibleSection } from '../../common/components/CollapsibleSection'; import { MCPServerAccessBindings } from './MCPServerAccessBindings'; +import { ServerJSONSection, ToolsSection } from './ServerJSONSection'; import { ConfirmationModal } from '../../admin/ConfirmationModal'; import { ModelVersionTableAliasesCell } from '../../model-registry/components/aliases/ModelVersionTableAliasesCell'; -import { - useUpdateMCPServerVersion, - useDeleteMCPServerVersion, -} from '../hooks/useMCPServerVersionMutations'; +import { useUpdateMCPServerVersion, useDeleteMCPServerVersion } from '../hooks/useMCPServerVersionMutations'; import { KeyValueTag } from '../../common/components/KeyValueTag'; import { AliasSelect } from '../../common/components/AliasSelect'; import { LATEST_ALIAS, RESERVED_ALIASES, validateToolsJson } from '../utils'; @@ -73,7 +71,6 @@ export const MCPServerVersionDetail = ({ const [editVersionToolsText, setEditVersionToolsText] = useState(''); const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [toolsValidationError, setToolsValidationError] = useState(null); - const updateVersionMutation = useUpdateMCPServerVersion(server.name); const deleteVersionMutation = useDeleteMCPServerVersion(server.name); @@ -123,9 +120,7 @@ export const MCPServerVersionDetail = ({ )} {version.server_json?.description && ( - - {version.server_json.description} - + {version.server_json.description} )}
@@ -280,122 +275,47 @@ export const MCPServerVersionDetail = ({
-
- {version.server_json && ( - - - - } - defaultCollapsed - componentId="mlflow.mcp_registry.detail.configuration_section" - > -
-              {JSON.stringify(version.server_json, null, 2)}
-            
-
- )} - {version.tools && version.tools.length > 0 && ( - - {chunks}, - }} - /> - - } - defaultCollapsed - componentId="mlflow.mcp_registry.detail.tools_section" - > - + + + + + + {version.tools && version.tools.length > 0 && ( + - -
- {version.tools.map((tool) => ( -
- - {tool.name} - - {tool.description && ( - - {tool.description} - - )} -
- ))} -
-
+ + )} + + + + + + + {version.server_json && } + + + {version.tools && version.tools.length > 0 && ( + + + )} - - - - } - defaultCollapsed - componentId="mlflow.mcp_registry.detail.bindings_section" - > + - -
+ + updateVersionMutation.reset()} - message={(updateVersionMutation.error as Error).message} + message={ + updateVersionMutation.error instanceof Error + ? updateVersionMutation.error.message + : String(updateVersionMutation.error) + } css={{ marginBottom: theme.spacing.sm }} /> )} @@ -583,7 +507,6 @@ export const MCPServerVersionDetail = ({ setDeleteModalVisible(false); }} /> -
); }; 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/UpdateDescriptionModal.test.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.test.tsx index 14b69359678d8..173579f11dbe9 100644 --- a/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.test.tsx +++ b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.test.tsx @@ -76,12 +76,7 @@ describe('UpdateDescriptionModal', () => { rerender( - + , ); diff --git a/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.tsx index 96297c887a990..fbc9fc8a4b893 100644 --- a/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.tsx +++ b/mlflow/server/js/src/mcp-registry/components/UpdateDescriptionModal.tsx @@ -41,9 +41,7 @@ export const UpdateDescriptionModal = ({ visible={visible} destroyOnClose confirmLoading={isLoading} - okText={ - - } + okText={} cancelText={ } diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts index 004d63498c094..6915a4651312b 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useCreateMCPServerVersionMutation.ts @@ -45,8 +45,8 @@ export const useCreateMCPServerVersionMutation = () => { MCPRegistryApi.setMCPServerVersionTag(name, version.version, { key, value }); await Promise.all(Object.entries(tags).map(([key, value]) => setTag(key, value))); } - } catch (e) { - console.warn('Version created but secondary metadata/tag operation failed:', e); + } catch { + // Version was created successfully; metadata/tag failures are non-fatal } return version; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts index c99a663e1daf0..2125fcd31b301 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts @@ -47,9 +47,7 @@ export const useUpdateMCPServerVersion = (serverName: string) => { if (aliases) { promises.push( - ...aliases.add.map((alias) => - MCPRegistryApi.setMCPServerAlias(serverName, { alias, version }), - ), + ...aliases.add.map((alias) => MCPRegistryApi.setMCPServerAlias(serverName, { alias, version })), ...aliases.remove.map((alias) => MCPRegistryApi.deleteMCPServerAlias(serverName, alias)), ); } 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 8d3ac37c3a99c..dac6dc0705856 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx @@ -102,7 +102,6 @@ describe('MCPRegistryPage', () => { }); it('converts plain text search into a valid name filter', async () => { - jest.setTimeout(15000); let capturedFilterString: string | null = null; server.use( rest.get(getAjaxUrl('ajax-api/3.0/mlflow/mcp-servers'), (req, res, ctx) => { @@ -117,16 +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 LIKE '%raw%'"); + 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' })]; 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 4f789e7b22481..e18b63902dd5b 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx @@ -38,6 +38,25 @@ 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' }], }, }); @@ -109,16 +128,74 @@ describe('MCPServerDetailPage', () => { }); }); - it('expands configuration section', 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 userEvent.click(screen.getByText('Configuration')); + 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 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 () => { @@ -301,80 +378,15 @@ describe('MCPServerDetailPage', () => { expect(screen.getByText('Status')).toBeInTheDocument(); }); - describe('server description editing', () => { - it('shows server description and edit button', async () => { - server.use(getMockedUpdateMCPServerResponse()); - renderPage(); - await waitFor(() => { - expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); - }); - - const editBtn = document.querySelector( - '[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]', - ) as HTMLElement; - expect(editBtn).toBeInTheDocument(); - }); - - it('shows "Add description" when no description exists', async () => { - const noDescServer = createMockMCPServer({ - name: 'dev.mainline/mcp', - display_name: 'Mainline', - }); - const noDescVersion = createMockMCPServerVersion({ - name: 'dev.mainline/mcp', - version: '1', - status: 'active', - server_json: { name: 'dev.mainline/mcp', version: '1.0.0', title: 'Mainline' }, - }); - server.use( - getMockedGetMCPServerResponse(noDescServer), - getMockedSearchMCPServerVersionsResponse([noDescVersion]), - getMockedGetLatestMCPServerVersionResponse(noDescVersion), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); - }); - - expect(screen.getByText('Add description')).toBeInTheDocument(); - }); - - it('opens description edit modal and saves', async () => { - server.use(getMockedUpdateMCPServerResponse()); - renderPage(); - await waitFor(() => { - expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); - }); - - const editBtn = document.querySelector( - '[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]', - ) as HTMLElement; - await userEvent.click(editBtn); - await waitFor(() => { - expect(screen.getByText('Edit server description')).toBeInTheDocument(); - }); + it('displays server description as read-only', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); }); - it('shows error in description modal on save failure', async () => { - server.use(getMockedUpdateMCPServerErrorResponse(500, 'Server error')); - renderPage(); - await waitFor(() => { - expect(screen.getByText('Gives your AI agent your story map.')).toBeInTheDocument(); - }); - - const editBtn = document.querySelector( - '[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]', - ) as HTMLElement; - await userEvent.click(editBtn); - await waitFor(() => { - expect(screen.getByText('Edit server description')).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByText('Save')); - await waitFor(() => { - expect(screen.getByText(/Server error/)).toBeInTheDocument(); - }); - }); + expect( + document.querySelector('[data-component-id="mlflow.mcp_registry.detail.version.edit_description"]'), + ).not.toBeInTheDocument(); }); describe('server display name editing', () => { diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx index 203b80ef4acef..4bd9e3256cfee 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx @@ -31,10 +31,7 @@ import { useLatestMCPServerVersionQuery, useMCPAccessBindingsQuery, } from '../hooks/useMCPServerDetailQuery'; -import { - useDeleteMCPServer, - useUpdateMCPServerDisplayName, -} from '../hooks/useMCPServerVersionMutations'; +import { useDeleteMCPServer, useUpdateMCPServerDisplayName } from '../hooks/useMCPServerVersionMutations'; import { useDeleteAccessBindingMutation } from '../hooks/useAccessBindingMutation'; import type { MCPAccessBinding } from '../types'; import { useCreateMCPServerVersionModal } from '../hooks/useCreateMCPServerVersionModal'; diff --git a/mlflow/server/js/src/mcp-registry/test-utils.ts b/mlflow/server/js/src/mcp-registry/test-utils.ts index af1ec4a77e6d1..85d9869f64b9a 100644 --- a/mlflow/server/js/src/mcp-registry/test-utils.ts +++ b/mlflow/server/js/src/mcp-registry/test-utils.ts @@ -111,5 +111,3 @@ export const getMockedSetMCPServerTagResponse = () => export const getMockedDeleteMCPServerTagResponse = () => rest.delete(getAjaxUrl(`${BASE_URL}/:name/tags/:key`), (_req, res, ctx) => res(ctx.json({}))); - - diff --git a/mlflow/server/js/src/mcp-registry/types.ts b/mlflow/server/js/src/mcp-registry/types.ts index 72f291f2e3fd7..fde0779cff51c 100644 --- a/mlflow/server/js/src/mcp-registry/types.ts +++ b/mlflow/server/js/src/mcp-registry/types.ts @@ -107,6 +107,7 @@ export interface ServerJSONPackage { registryBaseUrl?: string; version?: string; environmentVariables?: ServerJSONEnvironmentVariable[]; + runtimeHint?: string; [key: string]: unknown; }