From dbb2d7805b3e06f1bb8f630ecbc35f037c7ae3de Mon Sep 17 00:00:00 2001 From: Juntao Wang Date: Wed, 10 Jun 2026 00:55:37 -0400 Subject: [PATCH 1/3] Add Access Bindings tab with list/card views and shared components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Access Bindings tab on the MCP Registry page (RHOAIENG-65778) with full list/card view toggle, search, and pagination, following the same patterns as the Servers tab. New components: - MCPAccessBindingCard — Card component with ConnectIcon, resolved display name, description, and version/alias badge - MCPAccessBindingCardGrid — uses shared PaginatedCardGrid - MCPAccessBindingListTable — table with copy-to-clipboard endpoint, linked server name, formatted transport type, and pagination Shared infrastructure extracted: - PaginatedCardGrid — generic grid with loading/empty/pagination, reused by both MCPServerCardGrid and MCPAccessBindingCardGrid - useCursorPaginatedQuery — generic pagination hook with localStorage page size, reused by both list query hooks - utils.ts — buildSearchFilterClause, formatTransportType, resolveVersionDisplayName, resolveBindingDisplayName, emptyCenterStyles, DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS Additional changes: - Access Bindings is now the default tab (was Servers) - MCPServerCard migrated to design system Card component - Delete cascade invalidation — mutations now invalidate binding queries - Query error display — versions/bindings errors shown as Alert - MCPServerAccessBindings uses formatTransportType for consistency Signed-off-by: Juntao Wang --- .../componentId-registry.js | 24 +- mlflow/server/js/src/lang/default/en.json | 110 +++++--- .../components/MCPAccessBindingCard.test.tsx | 82 ++++++ .../components/MCPAccessBindingCard.tsx | 55 ++++ .../components/MCPAccessBindingCardGrid.tsx | 81 ++++++ .../MCPAccessBindingListTable.test.tsx | 114 +++++++++ .../components/MCPAccessBindingListTable.tsx | 242 ++++++++++++++++++ .../components/MCPServerAccessBindings.tsx | 15 +- .../mcp-registry/components/MCPServerCard.tsx | 39 +-- .../components/MCPServerCardGrid.tsx | 105 ++------ .../components/MCPServerListTable.tsx | 18 +- .../components/MCPServerVersionDetail.tsx | 8 +- .../components/PaginatedCardGrid.tsx | 104 ++++++++ .../hooks/useCursorPaginatedQuery.ts | 81 ++++++ .../hooks/useMCPAccessBindingsListQuery.ts | 18 ++ .../hooks/useMCPServerVersionMutations.ts | 5 + .../hooks/useMCPServersListQuery.ts | 104 +------- .../pages/MCPRegistryPage.test.tsx | 122 ++++++--- .../mcp-registry/pages/MCPRegistryPage.tsx | 230 +++++++++++------ .../pages/MCPServerDetailPage.tsx | 31 ++- .../server/js/src/mcp-registry/test-utils.ts | 5 + .../server/js/src/mcp-registry/utils.test.ts | 144 +++++++++++ mlflow/server/js/src/mcp-registry/utils.ts | 55 +++- 23 files changed, 1397 insertions(+), 395 deletions(-) create mode 100644 mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx create mode 100644 mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx create mode 100644 mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx create mode 100644 mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx create mode 100644 mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx create mode 100644 mlflow/server/js/src/mcp-registry/components/PaginatedCardGrid.tsx create mode 100644 mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts create mode 100644 mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts create mode 100644 mlflow/server/js/src/mcp-registry/utils.test.ts diff --git a/.github/actions/check-component-ids/componentId-registry.js b/.github/actions/check-component-ids/componentId-registry.js index f5d60c72e0c30..431f9ebf16d19 100644 --- a/.github/actions/check-component-ids/componentId-registry.js +++ b/.github/actions/check-component-ids/componentId-registry.js @@ -1837,14 +1837,21 @@ module.exports = { "mlflow.logged_models.table.source_run_link": "", // -- mlflow.mcp_registry -- - "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.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.search": "", - "mlflow.mcp_registry.card.link": "", + "mlflow.mcp_registry.bindings.table.copy_endpoint": "", + "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_server_button": "", "mlflow.mcp_registry.detail.actions": "", "mlflow.mcp_registry.detail.actions.delete": "", @@ -1853,6 +1860,7 @@ module.exports = { "mlflow.mcp_registry.detail.bindings.last_updated": "", "mlflow.mcp_registry.detail.bindings.target": "", "mlflow.mcp_registry.detail.bindings.transport": "", + "mlflow.mcp_registry.detail.bindings_error": "", "mlflow.mcp_registry.detail.breadcrumb_back": "", "mlflow.mcp_registry.detail.create_version": "", "mlflow.mcp_registry.detail.delete_server_modal": "", @@ -1871,11 +1879,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 57ec0822e7ff0..4e5bc0e4131ca 100644 --- a/mlflow/server/js/src/lang/default/en.json +++ b/mlflow/server/js/src/lang/default/en.json @@ -39,6 +39,10 @@ "defaultMessage": "Follow these steps to enable the AI Gateway feature for managing AI provider credentials.", "description": "AI Gateway setup guide > Subtitle" }, + "+8D/Xe": { + "defaultMessage": "No access bindings found", + "description": "Empty state when access binding search returns no results" + }, "+8uMvO": { "defaultMessage": "Enter new password", "description": "Placeholder for the new-password input" @@ -511,6 +515,10 @@ "defaultMessage": "Trace archival retention unit", "description": "Label for trace archival retention unit selector" }, + "0mvnEB": { + "defaultMessage": "Create endpoint", + "description": "Empty state title for access bindings table" + }, "0pY/4R": { "defaultMessage": "Usage", "description": "Tab label for endpoint usage metrics" @@ -755,6 +763,10 @@ "defaultMessage": "Trace archival retention updated", "description": "Partial save status message when experiment trace archival retention update succeeds" }, + "271P9S": { + "defaultMessage": "Create and manage direct access endpoints for your MCP servers.", + "description": "Empty state description for access bindings card grid" + }, "27XMby": { "defaultMessage": "Missing", "description": "Missing assessment label" @@ -871,6 +883,10 @@ "defaultMessage": "We couldn't find any models matching your search criteria. Try changing your search filters.", "description": "Empty state message displayed when all models are filtered out in the logged models list page" }, + "2edtUe": { + "defaultMessage": "Transport", + "description": "Header for the transport type column in the access bindings table" + }, "2f2qeb": { "defaultMessage": "Type", "description": "Text for type column in schema table in model version page" @@ -1371,10 +1387,6 @@ "defaultMessage": "Detailed assessments", "description": "Evaluation review > assessments > detailed assessments > title" }, - "5lDnPQ": { - "defaultMessage": "Create endpoint", - "description": "MCP Registry bindings empty state CTA button" - }, "5lsHqm": { "defaultMessage": "Cancel", "description": "Cancel button for the edit model config modal" @@ -1547,6 +1559,10 @@ "defaultMessage": "Total: {total}%", "description": "Total weight display" }, + "6hFDDi": { + "defaultMessage": "Create endpoint", + "description": "Access bindings table empty state CTA button" + }, "6i/EoY": { "defaultMessage": "Save", "description": "Save button text for edit workspace modal" @@ -1631,10 +1647,6 @@ "defaultMessage": "A unique name to identify this API key for reuse across endpoints", "description": "Hint text explaining API key name field" }, - "7BNPN4": { - "defaultMessage": "MCP Server", - "description": "Access bindings table header for server name" - }, "7BkVQ9": { "defaultMessage": "Run the following code to start an evaluation.", "description": "Instructions for running the evaluation code in OSS" @@ -2219,6 +2231,10 @@ "defaultMessage": "Delete records", "description": "Title for the V2 dataset records bulk-delete confirmation modal" }, + "9zm5R/": { + "defaultMessage": "Register an MCP server before creating access bindings.", + "description": "Empty state description for access bindings list when no servers exist" + }, "A+5KMV": { "defaultMessage": "Model ID", "description": "Label for the model ID of a logged model on the logged model details page" @@ -2935,6 +2951,10 @@ "defaultMessage": "Download attachment ({contentType})", "description": "Download link for trace attachment with unknown content type" }, + "DrEq7H": { + "defaultMessage": "No access bindings found", + "description": "Empty state when MCP access binding search returns no results" + }, "Dw+Z8L": { "defaultMessage": "Budget exceeded", "description": "Warning icon label for exceeded budget" @@ -4983,10 +5003,6 @@ "defaultMessage": "Track every version of your model to understand how quality changes over time. {learnMoreLink}", "description": "Empty state description displayed when no models are logged in the machine learning logged models list page" }, - "Pbi8zz": { - "defaultMessage": "Version/Alias", - "description": "Access bindings table header for version or alias" - }, "PdoaF7": { "defaultMessage": "Model", "description": "Run page > Overview > Model used for issue detection" @@ -5447,6 +5463,10 @@ "defaultMessage": "Search parameters", "description": "Run page > Overview > Parameters table > Filter input placeholder" }, + "SLYo1F": { + "defaultMessage": "Endpoint", + "description": "Header for the endpoint column in the access bindings table" + }, "SLjN1/": { "defaultMessage": "Description", "description": "Row heading in a table that contains the description of a function parameter." @@ -5571,6 +5591,10 @@ "defaultMessage": "Raw Schema JSON:", "description": "Label for the raw schema JSON in the experiment run dataset schema" }, + "T0GuxG": { + "defaultMessage": "Create MCP server", + "description": "Empty state title for access bindings list when no servers exist" + }, "T1sd79": { "defaultMessage": "Go to model", "description": "Run page > Header > Register model dropdown > Go to model button label" @@ -5963,6 +5987,10 @@ "defaultMessage": "Issues identified from traces will appear here.", "description": "Issue detection run details > Issues tab > Empty state description" }, + "VF3nK2": { + "defaultMessage": "Create endpoint", + "description": "Access bindings card grid empty state CTA button" + }, "VGJhVI": { "defaultMessage": "Add New Tag", "description": "Add new key-value tag modal > Modal title" @@ -7223,10 +7251,6 @@ "defaultMessage": "Filter by node", "description": "Filter button label" }, - "buaV2N": { - "defaultMessage": "Create and manage direct access endpoints for your MCP servers.", - "description": "Empty state description for MCP access bindings tab" - }, "buuQsF": { "defaultMessage": "A tag value is required", "description": "Key-value tag editor modal > Value required error message" @@ -7491,6 +7515,10 @@ "defaultMessage": "Toggle session grouping", "description": "Tooltip for the group by session button in the traces table toolbar" }, + "d7vMSs": { + "defaultMessage": "Loading access bindings...", + "description": "Loading state for MCP access bindings card grid" + }, "d8I3cy": { "defaultMessage": "Show only visible", "description": "Experiment page > compare runs tab > chart header > move down option" @@ -7555,6 +7583,10 @@ "defaultMessage": "Failed to load audio attachment", "description": "Error message when trace audio attachment fails to load" }, + "dScNPd": { + "defaultMessage": "MCP Server", + "description": "Header for the server name column in the access bindings table" + }, "dTHmzd": { "defaultMessage": "Saved API keys can be managed in LLM Connections under Settings.", "description": "Tooltip explaining where saved API keys can be found (LLM Connections section under Settings)" @@ -7711,10 +7743,6 @@ "defaultMessage": "Search runs", "description": "Placeholder text for the search input in the runs table on the logged model details page" }, - "eQaVED": { - "defaultMessage": "Last updated", - "description": "Access bindings table header for last updated" - }, "eQgPEi": { "defaultMessage": "{value} (step={step})", "description": "Formats a metric value along with the step number it corresponds to" @@ -8079,6 +8107,10 @@ "defaultMessage": "{length} matching {length, plural, =0 {runs} =1 {run} other {runs}}", "description": "Message for displaying how many runs match search criteria on experiment page" }, + "gNdiXX": { + "defaultMessage": "Create endpoint", + "description": "Empty state title for access bindings card grid" + }, "gOH/XL": { "defaultMessage": "No additional metadata is available for this dataset.", "description": "Body shown in the V2 evaluation dataset metadata modal when digest/schema/profile are all empty" @@ -9203,6 +9235,10 @@ "defaultMessage": "Metrics ({length})", "description": "Run page > Overview > Metrics table > Section title" }, + "m5qURv": { + "defaultMessage": "Last updated", + "description": "Header for the last updated column in the access bindings table" + }, "m8ztHR": { "defaultMessage": "Edited {timeSince} by {source}.", "description": "Evaluation review > assessments > tooltip > edited by human" @@ -9543,9 +9579,9 @@ "defaultMessage": "Show all parent spans", "description": "Checkbox label for a setting that enables showing all parent spans in the trace explorer regardless of filter conditions." }, - "na79Zq": { - "defaultMessage": "Endpoint", - "description": "Access bindings table header for endpoint" + "nZjAlt": { + "defaultMessage": "Register an MCP server before creating access bindings.", + "description": "Empty state description for access bindings tab when no servers exist" }, "naivho": { "defaultMessage": "of", @@ -9803,6 +9839,10 @@ "defaultMessage": "No group by columns selected", "description": "Experiment page > artifact compare view > empty state for no group by columns selected > title" }, + "ox4hKu": { + "defaultMessage": "Create MCP server", + "description": "Access bindings list empty state create server button" + }, "oy2LTl": { "defaultMessage": "Filter", "description": "Label for the span filters popover in the trace explorer." @@ -10111,14 +10151,14 @@ "defaultMessage": "Copied to clipboard", "description": "Success message after copying trace link" }, - "qgCEF1": { - "defaultMessage": "Create endpoint", - "description": "Empty state title for MCP access bindings tab" - }, "qhOwHa": { "defaultMessage": "Endpoints", "description": "Sidebar link for gateway endpoints" }, + "qhnfB3": { + "defaultMessage": "Create MCP server", + "description": "Access bindings empty state create server button" + }, "qkRBUr": { "defaultMessage": "Line smoothing", "description": "Runs charts > line chart > configuration > label for line smoothing slider control. The control allows changing data trace line smoothness from 1 to 100, where 1 is the original data trace and 100 is the smoothest trace. Line smoothing helps eliminate noise in the data." @@ -10303,6 +10343,10 @@ "defaultMessage": "Execution time (ms)", "description": "Column label for execution time with the unit suffix" }, + "rXwa8+": { + "defaultMessage": "Create MCP server", + "description": "Empty state title for access bindings tab when no servers exist" + }, "rY00Iw": { "defaultMessage": "Add filter", "description": "Button to add a new filter in the tags filter popover for experiments page search by tags" @@ -10607,6 +10651,10 @@ "defaultMessage": "Last updated by", "description": "Column selector label for the last-updated-by column" }, + "t12Gxp": { + "defaultMessage": "Version/Alias", + "description": "Header for the version or alias column in the access bindings table" + }, "t3mHNt": { "defaultMessage": "Errors", "description": "Title for the errors chart" @@ -11199,10 +11247,6 @@ "defaultMessage": "Columns", "description": "Evaluation review > evaluations list > filter dropdown button" }, - "wmEHaX": { - "defaultMessage": "Transport", - "description": "Access bindings table header for transport type" - }, "womPSY": { "defaultMessage": "Session", "description": "Column label for session" @@ -11375,6 +11419,10 @@ "defaultMessage": "Search prompts", "description": "Placeholder text for the search input in the prompts table on the logged model details page" }, + "xaiZn5": { + "defaultMessage": "Create and manage direct access endpoints for your MCP servers.", + "description": "Empty state description for access bindings table" + }, "xbfKJS": { "defaultMessage": "JSON body for the OpenAI-compatible Chat Completions API. Include \"model\" (endpoint name) and \"messages\".", "description": "Request body tooltip for unified Chat Completions API" diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx new file mode 100644 index 0000000000000..da2e08fc3773a --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx @@ -0,0 +1,82 @@ +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 { MCPAccessBindingCard } from './MCPAccessBindingCard'; +import { createMockMCPAccessBinding } from '../test-utils'; +import type { MCPAccessBinding } from '../types'; + +const renderCard = (binding: MCPAccessBinding) => + render( + + + + + , + ); + +describe('MCPAccessBindingCard', () => { + it('renders server_name as title when no resolved_version', () => { + renderCard(createMockMCPAccessBinding({ resolved_version: undefined })); + expect(screen.getByText('io.github.test/server')).toBeInTheDocument(); + }); + + it('renders resolved_version.display_name when available', () => { + renderCard( + createMockMCPAccessBinding({ + resolved_version: { + name: 'io.github.test/server', + version: '1.0.0', + server_json: { name: 'io.github.test/server', version: '1.0.0', title: 'JSON Title' }, + display_name: 'Custom Display Name', + status: 'active', + aliases: [], + tags: {}, + }, + }), + ); + expect(screen.getByText('Custom Display Name')).toBeInTheDocument(); + }); + + it('falls back to server_json.title when no display_name', () => { + renderCard( + createMockMCPAccessBinding({ + resolved_version: { + name: 'io.github.test/server', + version: '1.0.0', + server_json: { name: 'io.github.test/server', version: '1.0.0', title: 'Filesystem Server' }, + status: 'active', + aliases: [], + tags: {}, + }, + }), + ); + expect(screen.getByText('Filesystem Server')).toBeInTheDocument(); + }); + + it('renders version/alias target when set', () => { + renderCard(createMockMCPAccessBinding({ server_alias: 'production' })); + expect(screen.getByText('production')).toBeInTheDocument(); + }); + + it('renders description from resolved version', () => { + renderCard( + createMockMCPAccessBinding({ + resolved_version: { + name: 'io.github.test/server', + version: '1.0.0', + server_json: { name: 'io.github.test/server', version: '1.0.0', description: 'A helpful tool' }, + status: 'active', + aliases: [], + tags: {}, + }, + }), + ); + expect(screen.getByText('A helpful tool')).toBeInTheDocument(); + }); + + it('renders without description when resolved_version has none', () => { + renderCard(createMockMCPAccessBinding({ resolved_version: undefined })); + expect(screen.queryByText('A helpful tool')).not.toBeInTheDocument(); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx new file mode 100644 index 0000000000000..2d318c2b9199a --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx @@ -0,0 +1,55 @@ +import { Card, ConnectIcon, Typography, useDesignSystemTheme } from '@databricks/design-system'; + +import type { MCPAccessBinding } from '../types'; +import { resolveBindingDisplayName } from '../utils'; + +export const MCPAccessBindingCard = ({ binding }: { binding: MCPAccessBinding }) => { + const { theme } = useDesignSystemTheme(); + + const displayName = resolveBindingDisplayName(binding); + const description = binding.resolved_version?.server_json?.description; + const target = binding.server_alias || binding.server_version || undefined; + + return ( + +
+
+ + + {displayName} + + {target && ( + + {target} + + )} +
+ {description && ( + + {description} + + )} +
+
+ ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx new file mode 100644 index 0000000000000..e28e38d56dab6 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx @@ -0,0 +1,81 @@ +import type { CursorPaginationProps } from '@databricks/design-system'; +import { Button, Empty, PlusIcon } from '@databricks/design-system'; +import { FormattedMessage } from 'react-intl'; + +import type { MCPAccessBinding } from '../types'; +import { MCPAccessBindingCard } from './MCPAccessBindingCard'; +import { PaginatedCardGrid } from './PaginatedCardGrid'; + +export const MCPAccessBindingCardGrid = ({ + bindings, + isLoading, + isFiltered, + hasNextPage, + hasPreviousPage, + onNextPage, + onPreviousPage, + pageSizeSelect, +}: { + bindings?: MCPAccessBinding[]; + isLoading?: boolean; + isFiltered?: boolean; + hasNextPage: boolean; + hasPreviousPage: boolean; + onNextPage: () => void; + onPreviousPage: () => void; + pageSizeSelect?: CursorPaginationProps['pageSizeSelect']; +}) => ( + + } + noResultsMessage={ + + } + emptyState={ + + } + description={ + + } + button={ + + } + /> + } + renderItem={(binding) => } + getItemKey={(binding) => binding.binding_id} + /> +); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx new file mode 100644 index 0000000000000..48945130dc57a --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx @@ -0,0 +1,114 @@ +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 { testRoute, TestRouter } from '../../common/utils/RoutingTestUtils'; +import { MCPAccessBindingListTable } from './MCPAccessBindingListTable'; +import { createMockMCPAccessBinding } from '../test-utils'; + +const noop = () => {}; + +const renderTable = (props: Partial> = {}) => + render( + + + + , + '/', + ), + ]} + /> + , + ); + +describe('MCPAccessBindingListTable', () => { + it('renders column headers', () => { + renderTable(); + expect(screen.getByText('Endpoint')).toBeInTheDocument(); + expect(screen.getByText('MCP Server')).toBeInTheDocument(); + expect(screen.getByText('Version/Alias')).toBeInTheDocument(); + expect(screen.getByText('Transport')).toBeInTheDocument(); + expect(screen.getByText('Last updated')).toBeInTheDocument(); + }); + + it('renders binding rows with data', () => { + const bindings = [ + createMockMCPAccessBinding({ + binding_id: 1, + endpoint_url: 'https://mcp.example.com/fs', + server_name: 'io.test/server', + server_version: '1.0.0', + transport_type: 'streamable-http', + }), + ]; + renderTable({ bindings }); + expect(screen.getByText('https://mcp.example.com/fs')).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + expect(screen.getByText('Streamable HTTP')).toBeInTheDocument(); + }); + + it('renders resolved display name for MCP Server column', () => { + const bindings = [ + createMockMCPAccessBinding({ + binding_id: 1, + server_name: 'io.test/raw-name', + resolved_version: { + name: 'io.test/raw-name', + version: '1.0.0', + server_json: { name: 'io.test/raw-name', version: '1.0.0', title: 'Pretty Server' }, + status: 'active', + aliases: [], + tags: {}, + }, + }), + ]; + renderTable({ bindings }); + expect(screen.getByText('Pretty Server')).toBeInTheDocument(); + expect(screen.queryByText('io.test/raw-name')).not.toBeInTheDocument(); + }); + + it('formats transport type', () => { + const bindings = [createMockMCPAccessBinding({ binding_id: 1, transport_type: 'sse' })]; + renderTable({ bindings }); + expect(screen.getByText('SSE')).toBeInTheDocument(); + }); + + it('shows alias in version/alias column', () => { + const bindings = [ + createMockMCPAccessBinding({ binding_id: 1, server_alias: 'production', server_version: undefined }), + ]; + renderTable({ bindings }); + expect(screen.getByText('production')).toBeInTheDocument(); + }); + + it('renders empty state when no bindings and not filtered', () => { + renderTable({ bindings: [] }); + expect(screen.getByText('Create and manage direct access endpoints for your MCP servers.')).toBeInTheDocument(); + }); + + it('renders no-results state when filtered and empty', () => { + renderTable({ bindings: [], isFiltered: true }); + expect(screen.getByText('No access bindings found')).toBeInTheDocument(); + }); + + it('renders emptyStateOverride when provided', () => { + renderTable({ bindings: [], emptyStateOverride:
Custom empty
}); + expect(screen.getByText('Custom empty')).toBeInTheDocument(); + }); + + it('renders pagination controls', () => { + const bindings = [createMockMCPAccessBinding()]; + renderTable({ bindings, hasNextPage: true }); + expect(screen.getByText('Next')).toBeInTheDocument(); + expect(screen.getByText('Previous')).toBeInTheDocument(); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx new file mode 100644 index 0000000000000..100bc1287e462 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx @@ -0,0 +1,242 @@ +import { useMemo } from 'react'; +import { useReactTable_unverifiedWithReact18 as useReactTable } from '@databricks/web-shared/react-table'; +import type { CursorPaginationProps } from '@databricks/design-system'; +import { + CopyIcon, + CursorPagination, + Empty, + NoIcon, + Table, + TableCell, + TableHeader, + TableRow, + TableSkeletonRows, + Typography, + useDesignSystemTheme, + Button, + PlusIcon, +} from '@databricks/design-system'; +import type { ColumnDef } from '@tanstack/react-table'; +import { flexRender, getCoreRowModel } from '@tanstack/react-table'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import type { MCPAccessBinding } from '../types'; +import MCPRegistryRoutes from '../routes'; +import { emptyCenterStyles, formatTransportType, resolveBindingDisplayName } from '../utils'; +import { Link } from '../../common/utils/RoutingUtils'; +import { CopyButton } from '../../shared/building_blocks/CopyButton'; +import Utils from '../../common/utils/Utils'; + +const EndpointCell: ColumnDef['cell'] = ({ row: { original } }) => { + const { theme } = useDesignSystemTheme(); + return ( + + } + /> + + {original.endpoint_url} + + + ); +}; + +const ServerNameCell: ColumnDef['cell'] = ({ row: { original } }) => { + return ( + + {resolveBindingDisplayName(original)} + + ); +}; + +const useMCPAccessBindingTableColumns = () => { + const intl = useIntl(); + return useMemo(() => { + const columns: ColumnDef[] = [ + { + header: intl.formatMessage({ + defaultMessage: 'Endpoint', + description: 'Header for the endpoint column in the access bindings table', + }), + accessorKey: 'endpoint_url', + id: 'endpoint', + cell: EndpointCell, + }, + { + header: intl.formatMessage({ + defaultMessage: 'MCP Server', + description: 'Header for the server name column in the access bindings table', + }), + accessorKey: 'server_name', + id: 'server', + cell: ServerNameCell, + }, + { + header: intl.formatMessage({ + defaultMessage: 'Version/Alias', + description: 'Header for the version or alias column in the access bindings table', + }), + id: 'target', + accessorFn: (row) => row.server_alias || row.server_version || '—', + }, + { + header: intl.formatMessage({ + defaultMessage: 'Transport', + description: 'Header for the transport type column in the access bindings table', + }), + id: 'transport', + accessorFn: (row) => formatTransportType(row.transport_type), + }, + { + header: intl.formatMessage({ + defaultMessage: 'Last updated', + description: 'Header for the last updated column in the access bindings table', + }), + id: 'lastUpdated', + accessorFn: ({ last_updated_timestamp }) => + last_updated_timestamp ? Utils.formatTimestamp(last_updated_timestamp, intl) : '', + }, + ]; + return columns; + }, [intl]); +}; + +export const MCPAccessBindingListTable = ({ + bindings, + hasNextPage, + hasPreviousPage, + isLoading, + isFiltered, + onNextPage, + onPreviousPage, + pageSizeSelect, + emptyStateOverride, +}: { + bindings?: MCPAccessBinding[]; + hasNextPage: boolean; + hasPreviousPage: boolean; + isLoading?: boolean; + isFiltered?: boolean; + onNextPage: () => void; + onPreviousPage: () => void; + pageSizeSelect?: CursorPaginationProps['pageSizeSelect']; + emptyStateOverride?: React.ReactNode; +}) => { + const { theme } = useDesignSystemTheme(); + const columns = useMCPAccessBindingTableColumns(); + + const table = useReactTable('mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx', { + data: bindings ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row, index) => row.binding_id?.toString() ?? index.toString(), + }); + + const getEmptyState = () => { + const isEmptyList = !isLoading && (!bindings || bindings.length === 0); + if (isEmptyList && emptyStateOverride) { + return
{emptyStateOverride}
; + } + if (isEmptyList && isFiltered) { + return ( +
+ } + title={ + + } + description={null} + /> +
+ ); + } + if (isEmptyList) { + return ( +
+ + } + description={ + + } + button={ + + } + /> +
+ ); + } + return null; + }; + + return ( + + } + empty={getEmptyState()} + > + + {table.getLeafHeaders().map((header) => ( + + {flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + {isLoading ? ( + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} +
+ ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx index 3fc320497330d..5d600c132531f 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx @@ -1,4 +1,5 @@ import { + Alert, Button, PlusIcon, Spinner, @@ -12,14 +13,17 @@ import { import { FormattedMessage, useIntl } from 'react-intl'; import type { MCPAccessBinding } from '../types'; +import { formatTransportType } from '../utils'; import Utils from '../../common/utils/Utils'; export const MCPServerAccessBindings = ({ bindings, isLoading, + error, }: { bindings?: MCPAccessBinding[]; isLoading?: boolean; + error?: Error | null; }) => { const { theme } = useDesignSystemTheme(); const intl = useIntl(); @@ -35,7 +39,14 @@ export const MCPServerAccessBindings = ({ - {isLoading ? ( + {error ? ( + + ) : isLoading ? (
@@ -74,7 +85,7 @@ export const MCPServerAccessBindings = ({ {bindings.map((binding) => ( {binding.endpoint_url} - {binding.transport_type} + {formatTransportType(binding.transport_type)} {binding.server_alias || binding.server_version || '—'} {binding.last_updated_timestamp ? Utils.formatTimestamp(binding.last_updated_timestamp, intl) : '—'} diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx index 010b5c95e7fbe..7a4cc578e925b 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx @@ -1,10 +1,9 @@ -import { McpIcon, Typography, useDesignSystemTheme } from '@databricks/design-system'; +import { Card, McpIcon, Typography, useDesignSystemTheme } from '@databricks/design-system'; import { useIntl } from 'react-intl'; import type { MCPServer } from '../types'; import MCPRegistryRoutes from '../routes'; import { resolveDisplayName } from '../utils'; -import { Link } from '../../common/utils/RoutingUtils'; import Utils from '../../common/utils/Utils'; export const MCPServerCard = ({ server }: { server: MCPServer }) => { @@ -17,35 +16,13 @@ export const MCPServerCard = ({ server }: { server: MCPServer }) => { : undefined; return ( - -
+
@@ -73,6 +50,6 @@ export const MCPServerCard = ({ server }: { server: MCPServer }) => { )}
- + ); }; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerCardGrid.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerCardGrid.tsx index 3f85799ac15a3..86bee58e207ee 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerCardGrid.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerCardGrid.tsx @@ -1,9 +1,9 @@ import type { CursorPaginationProps } from '@databricks/design-system'; -import { CursorPagination, Empty, NoIcon, Spinner, useDesignSystemTheme } from '@databricks/design-system'; import { FormattedMessage } from 'react-intl'; import type { MCPServer } from '../types'; import { MCPServerCard } from './MCPServerCard'; +import { PaginatedCardGrid } from './PaginatedCardGrid'; export const MCPServerCardGrid = ({ servers, @@ -23,83 +23,26 @@ export const MCPServerCardGrid = ({ onNextPage: () => void; onPreviousPage: () => void; pageSizeSelect?: CursorPaginationProps['pageSizeSelect']; -}) => { - const { theme } = useDesignSystemTheme(); - - if (isLoading) { - return ( -
- - -
- ); - } - - if (!servers?.length && isFiltered) { - return ( -
- } - title={ - - } - description={null} - /> -
- ); - } - - if (!servers?.length) { - return null; - } - - return ( -
-
- {servers.map((server) => ( - - ))} -
-
- -
-
- ); -}; +}) => ( + + } + noResultsMessage={ + + } + renderItem={(server) => } + getItemKey={(server) => server.name} + /> +); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerListTable.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerListTable.tsx index b58a525bf4d71..458b82a0f5780 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerListTable.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerListTable.tsx @@ -21,26 +21,10 @@ import { FormattedMessage, useIntl } from 'react-intl'; import type { MCPServer } from '../types'; import MCPRegistryRoutes from '../routes'; -import { resolveDisplayName } from '../utils'; +import { emptyCenterStyles, resolveDisplayName } from '../utils'; import { Link } from '../../common/utils/RoutingUtils'; import Utils from '../../common/utils/Utils'; -export const emptyCenterStyles = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: '100%', - minHeight: 400, - width: '100%', - '& > div': { - height: '100%', - display: 'flex', - flexDirection: 'column' as const, - justifyContent: 'center', - alignItems: 'center', - }, -}; - const MCPServerNameCell = ({ getValue, row }: CellContext) => { const { theme } = useDesignSystemTheme(); const value = getValue() as string; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx index ed5b11ca16b4a..1e36ec635f661 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx @@ -13,7 +13,7 @@ import { import { FormattedMessage, useIntl } from 'react-intl'; import type { MCPAccessBinding, MCPServer, MCPServerVersion } from '../types'; -import { STATUS_TAG_COLOR, resolveDisplayName } from '../utils'; +import { STATUS_TAG_COLOR, resolveDisplayName, resolveVersionDisplayName } from '../utils'; import { ServerJSONViewer } from './ServerJSONViewer'; import { MCPServerAccessBindings } from './MCPServerAccessBindings'; import { UpdateVersionStatusModal } from './UpdateVersionStatusModal'; @@ -51,6 +51,7 @@ export const MCPServerVersionDetail = ({ version, bindings, bindingsLoading, + bindingsError, aliasesByVersion, showEditAliasesModal, }: { @@ -58,6 +59,7 @@ export const MCPServerVersionDetail = ({ version?: MCPServerVersion; bindings?: MCPAccessBinding[]; bindingsLoading?: boolean; + bindingsError?: Error | null; aliasesByVersion: Record; showEditAliasesModal?: (versionNumber: string) => void; }) => { @@ -158,7 +160,7 @@ export const MCPServerVersionDetail = ({ - {displayName} + {resolveVersionDisplayName(version, displayName)} @@ -247,7 +249,7 @@ export const MCPServerVersionDetail = ({ {version.server_json && } - + ({ + items, + isLoading, + isFiltered, + hasNextPage, + hasPreviousPage, + onNextPage, + onPreviousPage, + pageSizeSelect, + loadingMessage, + noResultsMessage, + emptyState, + renderItem, + getItemKey, +}: { + items?: T[]; + isLoading?: boolean; + isFiltered?: boolean; + hasNextPage: boolean; + hasPreviousPage: boolean; + onNextPage: () => void; + onPreviousPage: () => void; + pageSizeSelect?: CursorPaginationProps['pageSizeSelect']; + loadingMessage: React.ReactNode; + noResultsMessage: React.ReactNode; + emptyState?: React.ReactNode; + renderItem: (item: T) => React.ReactNode; + getItemKey: (item: T) => string | number; +}) => { + const { theme } = useDesignSystemTheme(); + + if (isLoading) { + return ( +
+ + {loadingMessage} +
+ ); + } + + if (!items?.length && isFiltered) { + return ( +
+ } title={noResultsMessage} description={null} /> +
+ ); + } + + if (!items?.length) { + return emptyState ?
{emptyState}
: null; + } + + return ( +
+
+ {items.map((item) => ( +
{renderItem(item)}
+ ))} +
+
+ +
+
+ ); +}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts new file mode 100644 index 0000000000000..3ef59d32fd584 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts @@ -0,0 +1,81 @@ +import { useQuery } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocalStorage } from '@databricks/web-shared/hooks'; +import type { CursorPaginationProps } from '@databricks/design-system'; +import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '../utils'; + +interface PaginatedResponse { + next_page_token?: string; +} + +export const useCursorPaginatedQuery = ({ + queryKeyPrefix, + searchFilter, + storageKey, + queryFn, + extractData, +}: { + queryKeyPrefix: string; + searchFilter?: string; + storageKey: string; + queryFn: (params: { searchFilter?: string; pageToken?: string; pageSize: number }) => Promise; + extractData: (response: TResponse) => TData | undefined; +}) => { + const previousPageTokens = useRef<(string | undefined)[]>([]); + const [currentPageToken, setCurrentPageToken] = useState(undefined); + + const [pageSize, setPageSize] = useLocalStorage({ + key: storageKey, + version: 0, + initialValue: DEFAULT_PAGE_SIZE, + }); + + useEffect(() => { + setCurrentPageToken(undefined); + previousPageTokens.current = []; + }, [searchFilter]); + + const pageSizeSelect = useMemo( + () => ({ + options: PAGE_SIZE_OPTIONS, + default: pageSize, + onChange(newPageSize) { + setPageSize(newPageSize); + setCurrentPageToken(undefined); + previousPageTokens.current = []; + }, + }), + [pageSize, setPageSize], + ); + + const queryResult = useQuery( + [queryKeyPrefix, { searchFilter, pageToken: currentPageToken, pageSize }], + { + queryFn: () => queryFn({ searchFilter, pageToken: currentPageToken, pageSize }), + retry: false, + keepPreviousData: true, + }, + ); + + const onNextPage = useCallback(() => { + previousPageTokens.current.push(currentPageToken); + setCurrentPageToken(queryResult.data?.next_page_token ?? undefined); + }, [queryResult.data?.next_page_token, currentPageToken]); + + const onPreviousPage = useCallback(() => { + const previousPageToken = previousPageTokens.current.pop(); + setCurrentPageToken(previousPageToken); + }, []); + + return { + data: queryResult.data ? extractData(queryResult.data) : undefined, + error: queryResult.error ?? undefined, + isLoading: queryResult.isLoading, + hasNextPage: Boolean(queryResult.data?.next_page_token), + hasPreviousPage: Boolean(currentPageToken), + onNextPage, + onPreviousPage, + pageSizeSelect, + refetch: queryResult.refetch, + }; +}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts new file mode 100644 index 0000000000000..69f5ff8e6c8f5 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts @@ -0,0 +1,18 @@ +import { MCPRegistryApi } from '../api'; +import { buildSearchFilterClause } from '../utils'; +import { useCursorPaginatedQuery } from './useCursorPaginatedQuery'; + +export const useMCPAccessBindingsListQuery = ({ searchFilter }: { searchFilter?: string } = {}) => { + return useCursorPaginatedQuery({ + queryKeyPrefix: 'mcp_bindings_list', + searchFilter, + storageKey: 'mcp_registry.bindings_page_size', + queryFn: ({ searchFilter: filter, pageToken, pageSize }) => + MCPRegistryApi.searchMCPAccessBindingsAll({ + filter_string: buildSearchFilterClause(filter, 'server_name'), + page_token: pageToken, + max_results: pageSize, + }), + extractData: (response) => response.mcp_access_bindings, + }); +}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts index a9b0a67f1947e..7071a68fb8b24 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts @@ -11,7 +11,9 @@ export const useUpdateMCPServerVersionStatus = (serverName: string) => { onSuccess: () => { queryClient.invalidateQueries(['mcp_server', serverName]); queryClient.invalidateQueries(['mcp_server_versions', serverName]); + queryClient.invalidateQueries(['mcp_server_bindings', serverName]); queryClient.invalidateQueries(['mcp_servers_list']); + queryClient.invalidateQueries(['mcp_bindings_list']); }, }); }; @@ -24,7 +26,9 @@ export const useDeleteMCPServerVersion = (serverName: string) => { onSuccess: () => { queryClient.invalidateQueries(['mcp_server', serverName]); queryClient.invalidateQueries(['mcp_server_versions', serverName]); + queryClient.invalidateQueries(['mcp_server_bindings', serverName]); queryClient.invalidateQueries(['mcp_servers_list']); + queryClient.invalidateQueries(['mcp_bindings_list']); }, }); }; @@ -36,6 +40,7 @@ export const useDeleteMCPServer = () => { mutationFn: (name: string) => MCPRegistryApi.deleteMCPServer(name), onSuccess: () => { queryClient.invalidateQueries(['mcp_servers_list']); + queryClient.invalidateQueries(['mcp_bindings_list']); }, }); }; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts index 590f71a6a45c1..219e610b20c2f 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts @@ -1,96 +1,18 @@ -import type { QueryFunctionContext } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; -import { useQuery } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useLocalStorage } from '@databricks/web-shared/hooks'; -import type { CursorPaginationProps } from '@databricks/design-system'; import { MCPRegistryApi } from '../api'; -import type { SearchMCPServersResponse } from '../types'; - -const DEFAULT_PAGE_SIZE = 25; -const STORE_KEY = 'mcp_registry.page_size'; - -type MCPServersListQueryKey = ['mcp_servers_list', { searchFilter?: string; pageToken?: string; pageSize: number }]; - -const buildSearchFilterClause = (searchFilter?: string): string | undefined => { - if (!searchFilter) { - return undefined; - } - - // Match existing MLflow list UIs: allow explicit filter syntax, otherwise treat - // the input as a simple name search. - const sqlKeywordPattern = /(\s+(ILIKE|LIKE|IN|IS)\s+)|=|!=|<=|>=|<|>/i; - if (sqlKeywordPattern.test(searchFilter)) { - return searchFilter; - } - - return `name ILIKE '%${searchFilter.replace(/'/g, "''")}%'`; -}; - -const queryFn = ({ queryKey }: QueryFunctionContext) => { - const [, { searchFilter, pageToken, pageSize }] = queryKey; - return MCPRegistryApi.searchMCPServers({ - filter_string: buildSearchFilterClause(searchFilter), - page_token: pageToken, - max_results: pageSize, - }); -}; +import { buildSearchFilterClause } from '../utils'; +import { useCursorPaginatedQuery } from './useCursorPaginatedQuery'; export const useMCPServersListQuery = ({ searchFilter }: { searchFilter?: string } = {}) => { - const previousPageTokens = useRef<(string | undefined)[]>([]); - const [currentPageToken, setCurrentPageToken] = useState(undefined); - - const [pageSize, setPageSize] = useLocalStorage({ - key: STORE_KEY, - version: 0, - initialValue: DEFAULT_PAGE_SIZE, + return useCursorPaginatedQuery({ + queryKeyPrefix: 'mcp_servers_list', + searchFilter, + storageKey: 'mcp_registry.page_size', + queryFn: ({ searchFilter: filter, pageToken, pageSize }) => + MCPRegistryApi.searchMCPServers({ + filter_string: buildSearchFilterClause(filter, 'name'), + page_token: pageToken, + max_results: pageSize, + }), + extractData: (response) => response.mcp_servers, }); - - useEffect(() => { - setCurrentPageToken(undefined); - previousPageTokens.current = []; - }, [searchFilter]); - - const pageSizeSelect = useMemo( - () => ({ - options: [10, 25, 50, 100], - default: pageSize, - onChange(newPageSize) { - setPageSize(newPageSize); - setCurrentPageToken(undefined); - previousPageTokens.current = []; - }, - }), - [pageSize, setPageSize], - ); - - const queryResult = useQuery( - ['mcp_servers_list', { searchFilter, pageToken: currentPageToken, pageSize }], - { - queryFn, - retry: false, - keepPreviousData: true, - }, - ); - - const onNextPage = useCallback(() => { - previousPageTokens.current.push(currentPageToken); - setCurrentPageToken(queryResult.data?.next_page_token ?? undefined); - }, [queryResult.data?.next_page_token, currentPageToken]); - - const onPreviousPage = useCallback(() => { - const previousPageToken = previousPageTokens.current.pop(); - setCurrentPageToken(previousPageToken); - }, []); - - return { - data: queryResult.data?.mcp_servers, - error: queryResult.error ?? undefined, - isLoading: queryResult.isLoading, - hasNextPage: Boolean(queryResult.data?.next_page_token), - hasPreviousPage: Boolean(currentPageToken), - onNextPage, - onPreviousPage, - pageSizeSelect, - refetch: queryResult.refetch, - }; }; 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 2af95a3755bec..7b19f57b5e2db 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx @@ -11,12 +11,14 @@ import { setupServer } from '../../common/utils/setup-msw'; import MCPRegistryPage from './MCPRegistryPage'; import { createMockMCPServer, + createMockMCPAccessBinding, getMockedSearchMCPServersResponse, getMockedSearchMCPServersErrorResponse, + getMockedSearchMCPAccessBindingsAllResponse, } from '../test-utils'; describe('MCPRegistryPage', () => { - const server = setupServer(getMockedSearchMCPServersResponse([])); + const server = setupServer(getMockedSearchMCPServersResponse([]), getMockedSearchMCPAccessBindingsAllResponse([])); const renderPage = (initialEntries = ['/']) => { const queryClient = new QueryClient(); @@ -45,12 +47,40 @@ describe('MCPRegistryPage', () => { await waitFor(() => { expect(screen.getByText('MCP Registry')).toBeInTheDocument(); }); - expect(screen.getByText('Servers')).toBeInTheDocument(); expect(screen.getByText('Access Bindings')).toBeInTheDocument(); + expect(screen.getByText('Servers')).toBeInTheDocument(); }); - it('renders empty state when no servers exist', async () => { + it('defaults to access bindings tab', async () => { renderPage(); + await waitFor(() => { + expect(screen.getByText('MCP Registry')).toBeInTheDocument(); + }); + expect(screen.getByPlaceholderText('Search access bindings')).toBeInTheDocument(); + }); + + it('shows create server empty state on access bindings tab when no servers exist', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Register an MCP server before creating access bindings.')).toBeInTheDocument(); + }); + }); + + it('switches to servers tab', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('MCP Registry')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Servers')); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Search MCP servers by name')).toBeInTheDocument(); + }); + }); + + it('renders empty state on servers tab when no servers exist', async () => { + renderPage(['/?tab=servers']); await waitFor(() => { expect(screen.getByText('Create and manage MCP servers using MLflow.')).toBeInTheDocument(); }); @@ -62,7 +92,7 @@ describe('MCPRegistryPage', () => { createMockMCPServer({ name: 'server-2', display_name: 'My Server 2' }), ]; server.use(getMockedSearchMCPServersResponse(servers)); - renderPage(); + renderPage(['/?tab=servers']); await waitFor(() => { expect(screen.getByText('My Server 1')).toBeInTheDocument(); expect(screen.getByText('My Server 2')).toBeInTheDocument(); @@ -82,7 +112,7 @@ describe('MCPRegistryPage', () => { ); }), ); - renderPage(); + renderPage(['/?tab=servers']); const searchInput = screen.getByPlaceholderText('Search MCP servers by name'); await userEvent.type(searchInput, 'raw'); @@ -95,34 +125,21 @@ describe('MCPRegistryPage', () => { it('shows Create MCP server button when servers exist', async () => { const servers = [createMockMCPServer({ name: 's1', display_name: 'Server 1' })]; server.use(getMockedSearchMCPServersResponse(servers)); - renderPage(); + renderPage(['/?tab=servers']); await waitFor(() => { expect(screen.getByText('Server 1')).toBeInTheDocument(); }); - expect(screen.getByText('Create MCP server')).toBeInTheDocument(); + expect(screen.getAllByText('Create MCP server').length).toBeGreaterThanOrEqual(1); }); it('renders error alert when API fails', async () => { server.use(getMockedSearchMCPServersErrorResponse(500, 'Something broke')); - renderPage(); + renderPage(['/?tab=servers']); await waitFor(() => { expect(screen.getByText('Something broke')).toBeInTheDocument(); }); }); - it('switches to access bindings tab', async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText('MCP Registry')).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByText('Access Bindings')); - - await waitFor(() => { - expect(screen.getByText('Create and manage direct access endpoints for your MCP servers.')).toBeInTheDocument(); - }); - }); - it('sends max_results query parameter to the API', async () => { let capturedMaxResults: string | null = null; server.use( @@ -150,14 +167,12 @@ describe('MCPRegistryPage', () => { ); }), ); - renderPage(); + renderPage(['/?tab=servers']); - // Wait for initial load (grid view has pagination now) await waitFor(() => { expect(screen.getByText('Test')).toBeInTheDocument(); }); - // Click next to go to page 2 await waitFor(() => { expect(screen.getByText('Next')).toBeInTheDocument(); }); @@ -167,7 +182,6 @@ describe('MCPRegistryPage', () => { expect(capturedPageTokens).toContain('token-abc'); }); - // Now type a search filter — should reset page_token to null const searchInput = screen.getByPlaceholderText('Search MCP servers by name'); await userEvent.type(searchInput, 'test'); @@ -184,7 +198,7 @@ describe('MCPRegistryPage', () => { res(ctx.json({ mcp_servers: servers, next_page_token: 'next-token' })), ), ); - renderPage(); + renderPage(['/?tab=servers']); await waitFor(() => { expect(screen.getByText('Server 1')).toBeInTheDocument(); @@ -196,7 +210,7 @@ describe('MCPRegistryPage', () => { it('renders page size selector in grid view', async () => { const servers = [createMockMCPServer({ name: 'server-1', display_name: 'Server 1' })]; server.use(getMockedSearchMCPServersResponse(servers)); - renderPage(); + renderPage(['/?tab=servers']); await waitFor(() => { expect(screen.getByText('Server 1')).toBeInTheDocument(); @@ -212,7 +226,7 @@ describe('MCPRegistryPage', () => { return res(ctx.json({ mcp_servers: [], next_page_token: undefined })); }), ); - renderPage(); + renderPage(['/?tab=servers']); const searchInput = screen.getByPlaceholderText('Search MCP servers by name'); await userEvent.type(searchInput, "status = 'active'"); @@ -230,19 +244,16 @@ describe('MCPRegistryPage', () => { return res(ctx.json({ mcp_servers: [], next_page_token: undefined })); }), ); - renderPage(); + renderPage(['/?tab=servers']); - // Wait for initial load await waitFor(() => { expect(callCount).toBeGreaterThanOrEqual(1); }); const initialCallCount = callCount; - // Type multiple characters quickly const searchInput = screen.getByPlaceholderText('Search MCP servers by name'); await userEvent.type(searchInput, 'abcdef'); - // Wait for debounce to settle (500ms) await waitFor( () => { expect(callCount).toBeGreaterThan(initialCallCount); @@ -270,7 +281,7 @@ describe('MCPRegistryPage', () => { return res(ctx.json({ mcp_servers: servers, next_page_token: undefined })); }), ); - renderPage(); + renderPage(['/?tab=servers']); await waitFor(() => { expect(screen.getByText('Original Server')).toBeInTheDocument(); @@ -279,12 +290,10 @@ describe('MCPRegistryPage', () => { const searchInput = screen.getByPlaceholderText('Search MCP servers by name'); await userEvent.type(searchInput, 'test'); - // Old data should still be visible during the loading period await waitFor(() => { expect(screen.getByText('Original Server')).toBeInTheDocument(); }); - // Eventually new data appears await waitFor( () => { expect(screen.getByText('Filtered Server')).toBeInTheDocument(); @@ -292,4 +301,47 @@ describe('MCPRegistryPage', () => { { timeout: 3000 }, ); }); + + // --- Access Bindings tab tests --- + + it('renders binding cards on access bindings tab', async () => { + const servers = [createMockMCPServer({ name: 'io.test/server', display_name: 'Test Server' })]; + const bindings = [ + createMockMCPAccessBinding({ + binding_id: 1, + server_name: 'io.test/server', + server_alias: 'production', + }), + ]; + server.use(getMockedSearchMCPServersResponse(servers), getMockedSearchMCPAccessBindingsAllResponse(bindings)); + renderPage(); + await waitFor(() => { + expect(screen.getByText('io.test/server')).toBeInTheDocument(); + }); + expect(screen.getByText('production')).toBeInTheDocument(); + }); + + it('shows create server empty state on bindings tab when no servers exist', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Register an MCP server before creating access bindings.')).toBeInTheDocument(); + }); + }); + + it('shows create endpoint empty state on bindings tab when servers exist but no bindings', async () => { + const servers = [createMockMCPServer({ name: 'io.test/server' })]; + server.use(getMockedSearchMCPServersResponse(servers), getMockedSearchMCPAccessBindingsAllResponse([])); + renderPage(); + await waitFor(() => { + expect(screen.getByText('Create and manage direct access endpoints for your MCP servers.')).toBeInTheDocument(); + }); + }); + + it('renders view toggle on bindings tab', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('MCP Registry')).toBeInTheDocument(); + }); + expect(screen.getByPlaceholderText('Search access bindings')).toBeInTheDocument(); + }); }); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx index a5317c22bf650..cda6d85a48a5c 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx @@ -11,9 +11,6 @@ import { SegmentedControlGroup, WrenchIcon, Spacer, - Table, - TableHeader, - TableRow, TableFilterInput, TableFilterLayout, useDesignSystemTheme, @@ -27,8 +24,12 @@ import ErrorUtils from '../../common/utils/ErrorUtils'; import { useSearchParams } from '../../common/utils/RoutingUtils'; import { ModelSearchInputHelpTooltip } from '../../model-registry/components/model-list/ModelListFilters'; import { useMCPServersListQuery } from '../hooks/useMCPServersListQuery'; +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 { MCPAccessBindingCardGrid } from '../components/MCPAccessBindingCardGrid'; +import { MCPAccessBindingListTable } from '../components/MCPAccessBindingListTable'; import { useDebounce } from 'use-debounce'; type ViewMode = 'list' | 'grid'; @@ -39,7 +40,7 @@ const MCPRegistryPage = () => { const intl = useIntl(); const [searchParams, setSearchParams] = useSearchParams(); const tabFromUrl = searchParams.get('tab'); - const activeTab: ActiveTab = tabFromUrl === 'bindings' ? 'bindings' : 'servers'; + const activeTab: ActiveTab = tabFromUrl === 'servers' ? 'servers' : 'bindings'; const [viewMode, setViewMode] = useState('grid'); const [searchFilter, setSearchFilter] = useState(''); const [debouncedSearchFilter] = useDebounce(searchFilter, 500); @@ -54,14 +55,25 @@ const MCPRegistryPage = () => { onNextPage, onPreviousPage, pageSizeSelect, - } = useMCPServersListQuery({ searchFilter: effectiveFilter }); + } = useMCPServersListQuery({ searchFilter: activeTab === 'servers' ? effectiveFilter : undefined }); + + const { + data: bindings, + isLoading: bindingsLoading, + error: bindingsError, + hasNextPage: bindingsHasNextPage, + hasPreviousPage: bindingsHasPreviousPage, + onNextPage: bindingsOnNextPage, + onPreviousPage: bindingsOnPreviousPage, + pageSizeSelect: bindingsPageSizeSelect, + } = useMCPAccessBindingsListQuery({ searchFilter: activeTab === 'bindings' ? effectiveFilter : undefined }); const handleTabChange = useCallback( (e: RadioChangeEvent) => { const value = e.target.value as ActiveTab; setSearchFilter(''); const next = new URLSearchParams(searchParams); - if (value === 'servers') { + if (value === 'bindings') { next.delete('tab'); } else { next.set('tab', value); @@ -133,12 +145,12 @@ const MCPRegistryPage = () => { onChange={handleTabChange} componentId="mlflow.mcp_registry.tabs" > - - - + + + {activeTab === 'servers' && ( @@ -216,86 +228,134 @@ const MCPRegistryPage = () => { )} {activeTab === 'bindings' && ( - <> -
- - setSearchFilter(e.target.value)} - suffix={null} - /> - +
+
+
+ + setSearchFilter(e.target.value)} + suffix={null} + /> + +
+ setViewMode(e.target.value as ViewMode)} + componentId="mlflow.mcp_registry.bindings.view_toggle" + > + } /> + } /> +
- - - } - description={ + {bindingsError?.message && ( + + )} + {isEmptyState && viewMode === 'grid' ? ( +
+ + } + description={ + + } + button={ + + } + /> +
+ ) : viewMode === 'grid' ? ( + + ) : ( + - - } - /> - - } - > - - - - - - - - - - - - - - - - - -
- + } + description={ + + } + button={ + + } + /> + ) : undefined + } + /> + )} +
)}
diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx index 4d15de5036bf6..14f31bb1e6101 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx @@ -59,9 +59,10 @@ const MCPServerDetailPage = () => { const { data: versions, isLoading: versionsLoading, + error: versionsError, refetch: refetchVersions, } = useMCPServerVersionsQuery(serverName); - const { data: bindings, isLoading: bindingsLoading } = useMCPAccessBindingsQuery(serverName); + const { data: bindings, isLoading: bindingsLoading, error: bindingsError } = useMCPAccessBindingsQuery(serverName); const [selectedVersion, setSelectedVersion] = useState(undefined); @@ -228,15 +229,24 @@ const MCPServerDetailPage = () => {
- + {versionsError ? ( + + ) : ( + + )}
{ version={currentVersion} bindings={bindings} bindingsLoading={bindingsLoading} + bindingsError={bindingsError} aliasesByVersion={aliasesByVersion} showEditAliasesModal={showEditAliasesModal} /> diff --git a/mlflow/server/js/src/mcp-registry/test-utils.ts b/mlflow/server/js/src/mcp-registry/test-utils.ts index 7cec63091af75..a12ad11c12984 100644 --- a/mlflow/server/js/src/mcp-registry/test-utils.ts +++ b/mlflow/server/js/src/mcp-registry/test-utils.ts @@ -68,3 +68,8 @@ export const getMockedDeleteMCPServerVersionResponse = () => export const getMockedDeleteMCPServerResponse = () => rest.delete(getAjaxUrl(`${BASE_URL}/:name`), (_req, res, ctx) => res(ctx.json({}))); + +export const getMockedSearchMCPAccessBindingsAllResponse = (bindings: MCPAccessBinding[] = []) => + rest.get(getAjaxUrl(`${BASE_URL}/bindings`), (_req, res, ctx) => + res(ctx.json({ mcp_access_bindings: bindings, next_page_token: undefined })), + ); diff --git a/mlflow/server/js/src/mcp-registry/utils.test.ts b/mlflow/server/js/src/mcp-registry/utils.test.ts new file mode 100644 index 0000000000000..6fba4c14b7fd0 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/utils.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from '@jest/globals'; +import { + resolveDisplayName, + resolveVersionDisplayName, + resolveBindingDisplayName, + buildSearchFilterClause, + formatTransportType, + STATUS_TAG_COLOR, + STATUS_TRANSITIONS, +} from './utils'; + +describe('resolveDisplayName', () => { + it('returns display_name when set', () => { + expect(resolveDisplayName({ display_name: 'My Server', name: 'io.test/server' })).toBe('My Server'); + }); + + it('falls back to name when display_name is undefined', () => { + expect(resolveDisplayName({ name: 'io.test/server' })).toBe('io.test/server'); + }); + + it('falls back to name when display_name is empty string', () => { + expect(resolveDisplayName({ display_name: '', name: 'io.test/server' })).toBe('io.test/server'); + }); +}); + +describe('resolveVersionDisplayName', () => { + it('returns version display_name first', () => { + expect( + resolveVersionDisplayName({ display_name: 'Custom Name', server_json: { title: 'Title' } }, 'fallback'), + ).toBe('Custom Name'); + }); + + it('falls back to server_json.title', () => { + expect(resolveVersionDisplayName({ server_json: { title: 'JSON Title' } }, 'fallback')).toBe('JSON Title'); + }); + + it('falls back to fallback when no display_name or title', () => { + expect(resolveVersionDisplayName({ server_json: {} }, 'fallback')).toBe('fallback'); + }); + + it('falls back to fallback when version is null', () => { + expect(resolveVersionDisplayName(null, 'fallback')).toBe('fallback'); + }); + + it('falls back to fallback when version is undefined', () => { + expect(resolveVersionDisplayName(undefined, 'fallback')).toBe('fallback'); + }); +}); + +describe('resolveBindingDisplayName', () => { + it('uses resolved_version.display_name first', () => { + expect( + resolveBindingDisplayName({ + server_name: 'io.test/server', + resolved_version: { display_name: 'Custom', server_json: { title: 'Title' } }, + }), + ).toBe('Custom'); + }); + + it('falls back to resolved_version.server_json.title', () => { + expect( + resolveBindingDisplayName({ + server_name: 'io.test/server', + resolved_version: { server_json: { title: 'Title' } }, + }), + ).toBe('Title'); + }); + + it('falls back to server_name when no resolved_version', () => { + expect(resolveBindingDisplayName({ server_name: 'io.test/server', resolved_version: null })).toBe('io.test/server'); + }); +}); + +describe('buildSearchFilterClause', () => { + it('returns undefined for empty filter', () => { + expect(buildSearchFilterClause(undefined, 'name')).toBeUndefined(); + expect(buildSearchFilterClause('', 'name')).toBeUndefined(); + }); + + it('wraps plain text in ILIKE clause', () => { + expect(buildSearchFilterClause('test', 'name')).toBe("name ILIKE '%test%'"); + }); + + it('uses the specified field name', () => { + expect(buildSearchFilterClause('test', 'server_name')).toBe("server_name ILIKE '%test%'"); + }); + + it('escapes single quotes in the search term', () => { + expect(buildSearchFilterClause("it's", 'name')).toBe("name ILIKE '%it''s%'"); + }); + + it('passes through explicit SQL filter syntax', () => { + expect(buildSearchFilterClause("status = 'active'", 'name')).toBe("status = 'active'"); + }); + + it('passes through ILIKE expressions', () => { + expect(buildSearchFilterClause("name ILIKE '%foo%'", 'name')).toBe("name ILIKE '%foo%'"); + }); + + it('passes through comparison operators', () => { + expect(buildSearchFilterClause('version != 1.0', 'name')).toBe('version != 1.0'); + }); +}); + +describe('formatTransportType', () => { + it('formats streamable-http', () => { + expect(formatTransportType('streamable-http')).toBe('Streamable HTTP'); + }); + + it('formats sse', () => { + expect(formatTransportType('sse')).toBe('SSE'); + }); + + it('returns raw value for unknown types', () => { + expect(formatTransportType('unknown' as any)).toBe('unknown'); + }); +}); + +describe('STATUS_TAG_COLOR', () => { + it('maps all statuses', () => { + expect(STATUS_TAG_COLOR.draft).toBe('charcoal'); + expect(STATUS_TAG_COLOR.active).toBe('lime'); + expect(STATUS_TAG_COLOR.deprecated).toBe('lemon'); + expect(STATUS_TAG_COLOR.deleted).toBe('coral'); + }); +}); + +describe('STATUS_TRANSITIONS', () => { + it('draft can transition to active and deleted', () => { + expect(STATUS_TRANSITIONS.draft).toEqual(['active', 'deleted']); + }); + + 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('deleted has no transitions', () => { + expect(STATUS_TRANSITIONS.deleted).toEqual([]); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/utils.ts b/mlflow/server/js/src/mcp-registry/utils.ts index f65f537adee84..d6847b15533db 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 { MCPStatus } from './types'; +import type { MCPRemoteTransportType, MCPStatus } from './types'; export const STATUS_TAG_COLOR: Record = { draft: 'charcoal', @@ -15,6 +15,59 @@ export const STATUS_TRANSITIONS: Record = { deleted: [], }; +export const emptyCenterStyles = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + minHeight: 400, + width: '100%', + '& > div': { + height: '100%', + display: 'flex', + flexDirection: 'column' as const, + justifyContent: 'center', + alignItems: 'center', + }, +}; + +export const DEFAULT_PAGE_SIZE = 25; +export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + export const resolveDisplayName = (server: { display_name?: string; name: string }): string => { return server.display_name || server.name; }; + +export const resolveVersionDisplayName = ( + version: { display_name?: string; server_json?: { title?: string } } | null | undefined, + fallback: string, +): string => { + return version?.display_name || version?.server_json?.title || fallback; +}; + +export const resolveBindingDisplayName = (binding: { + server_name: string; + resolved_version?: { display_name?: string; server_json?: { title?: string } } | null; +}): string => { + return resolveVersionDisplayName(binding.resolved_version, binding.server_name); +}; + +const TRANSPORT_LABELS: Record = { + 'streamable-http': 'Streamable HTTP', + sse: 'SSE', +}; + +export const buildSearchFilterClause = (searchFilter: string | undefined, field: string): string | undefined => { + if (!searchFilter) { + return undefined; + } + const sqlKeywordPattern = /(\s+(ILIKE|LIKE|IN|IS)\s+)|=|!=|<=|>=|<|>/i; + if (sqlKeywordPattern.test(searchFilter)) { + return searchFilter; + } + return `${field} ILIKE '%${searchFilter.replace(/'/g, "''")}%'`; +}; + +export const formatTransportType = (transport: MCPRemoteTransportType): string => { + return TRANSPORT_LABELS[transport] || transport; +}; From 573d005f1090e0d339398399c60d2f670f52154f Mon Sep 17 00:00:00 2001 From: Juntao Wang Date: Wed, 10 Jun 2026 12:37:17 -0400 Subject: [PATCH 2/3] Add Create/Edit Access Binding modal, binding cards, and code quality fixes - Add AccessBindingModal with create/edit modes, server/version/alias selection, and transport type configuration - Add MCPServerAccessBindings card-style binding display with edit/delete actions, transport tag, and clickable hover state using DS Card - Add useAccessBindingMutation hooks for create/update with cache invalidation - Add useSelectedMCPServerVersion hook for URL-driven version selection following the Prompts page pattern (?version= query param) - Update bindings table server links to deep-link to the correct version - Extract MCP_QUERY_KEYS constants to eliminate magic strings across hooks - Extract useInvalidateServerQueries to remove duplicate invalidation blocks - Add enabled option to useCursorPaginatedQuery for conditional fetching - Fix isEmptyState logic to use isServersEmpty for correct tab behavior - Fix unsafe error cast in AccessBindingModal - Type mutation generics to eliminate as Error casts - Replace hand-rolled BindingCard div with DS Card component - Add responsive column widths via flex metadata on bindings table - Add flexShrink: 0 on copy button to prevent squeeze by truncated text - Add 8 new tests covering version URL selection, binding links, and empty state edge cases (111 total) Signed-off-by: Juntao Wang --- .../componentId-registry.js | 17 +- mlflow/server/js/src/lang/default/en.json | 108 +++++-- .../components/AccessBindingModal.tsx | 298 ++++++++++++++++++ .../components/CardIconWrapper.tsx | 19 ++ .../components/MCPAccessBindingCard.tsx | 7 +- .../components/MCPAccessBindingCardGrid.tsx | 6 +- .../MCPAccessBindingListTable.test.tsx | 49 +++ .../components/MCPAccessBindingListTable.tsx | 90 ++++-- .../components/MCPServerAccessBindings.tsx | 152 ++++++--- .../mcp-registry/components/MCPServerCard.tsx | 7 +- .../components/MCPServerVersionDetail.tsx | 14 +- .../hooks/useAccessBindingMutation.ts | 35 ++ .../hooks/useCursorPaginatedQuery.ts | 3 + .../hooks/useMCPAccessBindingsListQuery.ts | 10 +- .../hooks/useMCPServerDetailQuery.ts | 7 +- .../hooks/useMCPServerVersionMutations.ts | 49 ++- .../hooks/useMCPServersListQuery.ts | 10 +- .../hooks/useSelectedMCPServerVersion.ts | 29 ++ .../pages/MCPRegistryPage.test.tsx | 19 ++ .../mcp-registry/pages/MCPRegistryPage.tsx | 64 +++- .../pages/MCPServerDetailPage.test.tsx | 54 ++++ .../pages/MCPServerDetailPage.tsx | 31 +- mlflow/server/js/src/mcp-registry/routes.ts | 8 +- mlflow/server/js/src/mcp-registry/utils.ts | 8 + 24 files changed, 939 insertions(+), 155 deletions(-) create mode 100644 mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx create mode 100644 mlflow/server/js/src/mcp-registry/components/CardIconWrapper.tsx create mode 100644 mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts create mode 100644 mlflow/server/js/src/mcp-registry/hooks/useSelectedMCPServerVersion.ts diff --git a/.github/actions/check-component-ids/componentId-registry.js b/.github/actions/check-component-ids/componentId-registry.js index 431f9ebf16d19..36492ce2d1b07 100644 --- a/.github/actions/check-component-ids/componentId-registry.js +++ b/.github/actions/check-component-ids/componentId-registry.js @@ -1837,6 +1837,12 @@ module.exports = { "mlflow.logged_models.table.source_run_link": "", // -- mlflow.mcp_registry -- + "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": "", @@ -1844,6 +1850,8 @@ module.exports = { "mlflow.mcp_registry.bindings.list.empty_state.create_server": "", "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": "", @@ -1852,14 +1860,15 @@ module.exports = { "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.create_server_button": "", "mlflow.mcp_registry.detail.actions": "", "mlflow.mcp_registry.detail.actions.delete": "", "mlflow.mcp_registry.detail.add_binding": "", - "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.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.breadcrumb_back": "", "mlflow.mcp_registry.detail.create_version": "", diff --git a/mlflow/server/js/src/lang/default/en.json b/mlflow/server/js/src/lang/default/en.json index 4e5bc0e4131ca..181cdde782fc8 100644 --- a/mlflow/server/js/src/lang/default/en.json +++ b/mlflow/server/js/src/lang/default/en.json @@ -139,6 +139,10 @@ "defaultMessage": "Max", "description": "Column title for the column displaying the maximum metric values for a metric" }, + "+jMswj": { + "defaultMessage": "Target:", + "description": "Binding card target label" + }, "+khKtf": { "defaultMessage": "None", "description": "A short label for experiments with no experiment kind" @@ -159,6 +163,10 @@ "defaultMessage": "An error occurred while attempting to fetch trace data (ID: {traceId}). Please ensure that the MLflow tracking server is running, and that the trace data exists. Error details:", "description": "An error message explaining that an error occurred while fetching trace data" }, + "+pqOTO": { + "defaultMessage": "Streamable HTTP", + "description": "MCP registry streamable HTTP transport option" + }, "+tURAJ": { "defaultMessage": "Cancel", "description": "Button text for canceling evaluation" @@ -255,6 +263,10 @@ "defaultMessage": "Use existing API key", "description": "Option to use existing API key" }, + "/PBJ8N": { + "defaultMessage": "Save", + "description": "MCP registry edit access binding modal save button" + }, "/Q7stq": { "defaultMessage": "Learn more", "description": "Model registry > OSS Promo modal for model version aliases > learn more link" @@ -875,6 +887,10 @@ "defaultMessage": "Cancel", "description": "Key-value tag editor modal > Manage Tag cancel button" }, + "2aFhNQ": { + "defaultMessage": "Version/Alias:", + "description": "MCP registry binding modal version/alias label" + }, "2cshW2": { "defaultMessage": "Resolved ({count})", "description": "Issue status filter > Resolved button label" @@ -1031,6 +1047,10 @@ "defaultMessage": "This tab displays all the traces logged to this logged model. MLflow supports automatic tracing for many popular generative AI frameworks. Follow the steps below to log your first trace. For more information about MLflow Tracing, visit the MLflow documentation.", "description": "Message that explains the function of the 'Traces' tab in logged model page. This message is followed by a tutorial explaining how to get started with MLflow Tracing." }, + "3Xy5rr": { + "defaultMessage": "Create access binding", + "description": "Access bindings card grid empty state CTA button" + }, "3YddwH": { "defaultMessage": "Traffic split percentages must total 100%", "description": "Tooltip shown when save button is disabled due to invalid traffic split total" @@ -1379,6 +1399,10 @@ "defaultMessage": "Cancel", "description": "Evaluation review > assessments > cancel assessment button label" }, + "5fUSTW": { + "defaultMessage": "Edit", + "description": "Edit access binding link" + }, "5iPi6b": { "defaultMessage": "No permissions", "description": "Empty-state title for the permissions table" @@ -1639,6 +1663,10 @@ "defaultMessage": "Off", "description": "Runs charts > line chart > ignore outliers > off setting label" }, + "78GhUd": { + "defaultMessage": "MCP Server:", + "description": "MCP registry binding modal server label" + }, "79dD5F": { "defaultMessage": "There was an error submitting your note.", "description": "Error message text when saving an editable note in MLflow" @@ -2759,6 +2787,10 @@ "defaultMessage": "Switch sides", "description": "A label for button used to switch prompt versions when in side-by-side comparison view" }, + "Cbwpjj": { + "defaultMessage": "Edit", + "description": "Edit access binding link in table" + }, "CbzDeZ": { "defaultMessage": "An error occurred while rendering this component.", "description": "Description for default error message in Trace V3 page" @@ -3115,6 +3147,10 @@ "defaultMessage": "Discard unsaved changes?", "description": "Title of the prompt shown when leaving the dataset record side panel with unsaved edits" }, + "EtmCKX": { + "defaultMessage": "Copy endpoint URL", + "description": "Tooltip for copy endpoint URL button" + }, "Eu0gxa": { "defaultMessage": "Capture and debug LLM interactions and agent workflows.", "description": "Feature card summary for tracing" @@ -3487,6 +3523,10 @@ "defaultMessage": "See detailed trace view", "description": "Evaluation review > see detailed trace view button" }, + "HUcFqG": { + "defaultMessage": "Create", + "description": "MCP registry create access binding modal create button" + }, "HUf9qJ": { "defaultMessage": "Are you sure you want to delete {modelName}? This cannot be undone.", "description": "Confirmation message for delete model modal on model view page" @@ -3603,10 +3643,6 @@ "defaultMessage": "Aliases", "description": "Aliases section in the metadata on model version page" }, - "I+4UF5": { - "defaultMessage": "Version/Alias", - "description": "MCP access bindings table header for version or alias" - }, "I1mNex": { "defaultMessage": "Select the events that will trigger this webhook.", "description": "Webhook events field description" @@ -3843,6 +3879,10 @@ "defaultMessage": "Record ID", "description": "Header for the dataset record id column" }, + "JLcT0X": { + "defaultMessage": "Versions", + "description": "MCP registry binding modal versions group label" + }, "JNS471": { "defaultMessage": "No results. Try using a different keyword or adjusting your filters.", "description": "Models table > no results after filtering" @@ -4991,6 +5031,10 @@ "defaultMessage": "Clear selection", "description": "Clear model selection" }, + "PXXPl4": { + "defaultMessage": "Transport Type:", + "description": "MCP registry binding modal transport label" + }, "PYGkI3": { "defaultMessage": "Install the MLflow tracing SDK for TypeScript using npm, then initialize it in your application.", "description": "Step 1 description for TypeScript traces onboarding" @@ -5147,6 +5191,10 @@ "defaultMessage": "Use other parameters or disable run grouping to continue.", "description": "Experiment page > compare runs > parallel coordinates chart > unsupported string values warning > description" }, + "QQyJYV": { + "defaultMessage": "https://mcp.example.com/server", + "description": "MCP registry binding modal endpoint placeholder" + }, "QRnRh3": { "defaultMessage": "No experiments found", "description": "Label for the empty state in the experiments table when no experiments are found" @@ -5299,6 +5347,10 @@ "defaultMessage": "Did the conversation avoid causing user frustration?", "description": "Hint for UserFrustration template" }, + "RSPGtl": { + "defaultMessage": "@latest", + "description": "MCP registry latest alias option" + }, "RUju4k": { "defaultMessage": "Charts", "description": "Tooltip for charts page mode toggle in evaluation runs table controls" @@ -5983,14 +6035,14 @@ "defaultMessage": "View model", "description": "Label for a button that opens a new tab to view the details of a logged ML model while registering a model version" }, + "VETJ8j": { + "defaultMessage": "Server-Sent Events (SSE)", + "description": "MCP registry SSE transport option" + }, "VF2V6v": { "defaultMessage": "Issues identified from traces will appear here.", "description": "Issue detection run details > Issues tab > Empty state description" }, - "VF3nK2": { - "defaultMessage": "Create endpoint", - "description": "Access bindings card grid empty state CTA button" - }, "VGJhVI": { "defaultMessage": "Add New Tag", "description": "Add new key-value tag modal > Modal title" @@ -6231,10 +6283,6 @@ "defaultMessage": "Cancel", "description": "A label for the cancel button in the prompt creation modal in the prompt management UI" }, - "WPtN5m": { - "defaultMessage": "Last updated", - "description": "MCP access bindings table header for last updated" - }, "WQwCH6": { "defaultMessage": "Aliased versions", "description": "Column title for aliased versions in the registered model page" @@ -6359,10 +6407,6 @@ "defaultMessage": "Off", "description": "Usage tracking disabled state" }, - "X504dD": { - "defaultMessage": "Transport", - "description": "MCP access bindings table header for transport" - }, "X6P8tX": { "defaultMessage": "No models found", "description": "Empty state title displayed when all models are filtered out in the logged models list page" @@ -8139,10 +8183,6 @@ "defaultMessage": "Add records to start evaluating your app. {sdkLink}", "description": "Description text for the V2 dataset records empty state" }, - "gRjxQl": { - "defaultMessage": "Endpoint", - "description": "MCP access bindings table header for endpoint" - }, "gRz1nB": { "defaultMessage": "{count, plural, one {Delete Trace} other {Delete Traces}}", "description": "Experiment page > traces view controls > Delete traces modal > Title" @@ -8191,6 +8231,10 @@ "defaultMessage": "Configure chart", "description": "Experiment page > compare runs > parallel coordinates chart > configure chart button" }, + "gb0v4d": { + "defaultMessage": "Edit access binding", + "description": "MCP registry edit access binding modal title" + }, "gc+GrI": { "defaultMessage": "Model, input data or prompt have changed since last evaluation of the output", "description": "Experiment page > new run modal > dirty output (out of sync with new data)" @@ -9231,6 +9275,10 @@ "defaultMessage": "Save", "description": "Evaluation review > assessments > save button" }, + "m30WmQ": { + "defaultMessage": "Aliases", + "description": "MCP registry binding modal aliases group label" + }, "m4159e": { "defaultMessage": "Metrics ({length})", "description": "Run page > Overview > Metrics table > Section title" @@ -9751,6 +9799,10 @@ "defaultMessage": "Rename", "description": "Label for the rename run button above the experiment runs table" }, + "oWmpyr": { + "defaultMessage": "Select an MCP server", + "description": "MCP registry binding modal server placeholder" + }, "oWtdfc": { "defaultMessage": "Failed Calls", "description": "Label for failed calls statistic" @@ -10123,6 +10175,10 @@ "defaultMessage": "Hide run", "description": "A tooltip for the visibility icon button in the runs table next to the visible run" }, + "qU/7Y0": { + "defaultMessage": "Create access binding", + "description": "MCP registry create access binding modal title" + }, "qVQYZH": { "defaultMessage": "Show less", "description": "Button to close alert description" @@ -10335,6 +10391,10 @@ "defaultMessage": "Labeling sessions", "description": "Label for the labeling sessions tab in the MLflow experiment navbar" }, + "rQLVyc": { + "defaultMessage": "Create access binding", + "description": "Button to create a new access binding" + }, "rRaThb": { "defaultMessage": "Select a provider first", "description": "Placeholder when no provider selected" @@ -11111,6 +11171,10 @@ "defaultMessage": "Status", "description": "Run page > Overview > Run status section label" }, + "vyiOXJ": { + "defaultMessage": "Endpoint URL:", + "description": "MCP registry binding modal endpoint label" + }, "w+Josc": { "defaultMessage": "Enter an expectation name", "description": "Placeholder for the expectation name typeahead" @@ -11207,6 +11271,10 @@ "defaultMessage": "Budget exceeded: {spend} of {limit} spent", "description": "Tooltip shown when current spend exceeds the budget limit" }, + "wROuT1": { + "defaultMessage": "Updated:", + "description": "Binding card updated label" + }, "wY58OE": { "defaultMessage": "Retrieval groundedness", "description": "Evaluation results > known type of evaluation result assessment > retrieval groundedness assessment. Used to indicate if the result is grounded in context of LLMs evaluation. Label displayed if user provided custom value, e.g. \"Retrieval groundedness: moderately grounded\"" diff --git a/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx new file mode 100644 index 0000000000000..1d3e1755083a1 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx @@ -0,0 +1,298 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Input, + Modal, + SimpleSelect, + SimpleSelectOption, + SimpleSelectOptionGroup, + Typography, + useDesignSystemTheme, +} from '@databricks/design-system'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import type { MCPAccessBinding, MCPRemoteTransportType } from '../types'; +import { useMCPServerQuery, useMCPServerVersionsQuery } from '../hooks/useMCPServerDetailQuery'; +import { useMCPServersListQuery } from '../hooks/useMCPServersListQuery'; +import { useCreateAccessBindingMutation, useUpdateAccessBindingMutation } from '../hooks/useAccessBindingMutation'; +import { resolveBindingDisplayName } from '../utils'; +import { FieldLabel } from '../../admin/components/FieldLabel'; + +const ALIAS_PREFIX = 'alias:'; +const VERSION_PREFIX = 'version:'; + +function bindingToTarget(binding: MCPAccessBinding): string { + if (binding.server_alias) return `${ALIAS_PREFIX}${binding.server_alias}`; + if (binding.server_version) return `${VERSION_PREFIX}${binding.server_version}`; + return ''; +} + +export const AccessBindingModal = ({ + visible, + onCancel, + onSuccess, + editBinding, + lockedServer, + defaultVersion, +}: { + visible: boolean; + onCancel: () => void; + onSuccess?: () => void; + editBinding?: MCPAccessBinding; + lockedServer?: string; + defaultVersion?: string; +}) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const isEditMode = Boolean(editBinding); + const isServerLocked = isEditMode || Boolean(lockedServer); + + const [selectedServer, setSelectedServer] = useState(''); + const [endpointUrl, setEndpointUrl] = useState(''); + const [selectedTarget, setSelectedTarget] = useState(`${ALIAS_PREFIX}latest`); + const [transportType, setTransportType] = useState('streamable-http'); + + const createMutation = useCreateAccessBindingMutation(); + const updateMutation = useUpdateAccessBindingMutation(); + const activeMutation = isEditMode ? updateMutation : createMutation; + + const { data: servers } = useMCPServersListQuery({ enabled: !isServerLocked }); + const { data: server } = useMCPServerQuery(selectedServer); + const { data: versions } = useMCPServerVersionsQuery(selectedServer); + + useEffect(() => { + if (visible) { + if (editBinding) { + setSelectedServer(editBinding.server_name); + setEndpointUrl(editBinding.endpoint_url); + setSelectedTarget(bindingToTarget(editBinding)); + setTransportType(editBinding.transport_type); + } else { + setSelectedServer(lockedServer || ''); + setEndpointUrl(''); + setSelectedTarget(defaultVersion ? `${VERSION_PREFIX}${defaultVersion}` : `${ALIAS_PREFIX}latest`); + setTransportType('streamable-http'); + } + createMutation.reset(); + updateMutation.reset(); + } + }, [visible, editBinding, lockedServer, defaultVersion]); // eslint-disable-line react-hooks/exhaustive-deps -- reset() creates new ref + + const aliases = server?.aliases ?? []; + const isSubmitting = activeMutation.isLoading; + const isFormValid = Boolean(selectedServer && endpointUrl.trim() && selectedTarget); + + const handleSubmit = () => { + if (!isFormValid) return; + const isAlias = selectedTarget.startsWith(ALIAS_PREFIX); + const targetValue = isAlias + ? selectedTarget.slice(ALIAS_PREFIX.length) + : selectedTarget.slice(VERSION_PREFIX.length); + + if (isEditMode && editBinding) { + updateMutation.mutate( + { + serverName: editBinding.server_name, + bindingId: editBinding.binding_id, + request: { + endpoint_url: endpointUrl.trim(), + server_alias: isAlias ? targetValue : null, + server_version: isAlias ? null : targetValue, + transport_type: transportType, + }, + }, + { + onSuccess: () => { + onCancel(); + onSuccess?.(); + }, + }, + ); + } else { + createMutation.mutate( + { + serverName: selectedServer, + request: { + endpoint_url: endpointUrl.trim(), + server_alias: isAlias ? targetValue : undefined, + server_version: isAlias ? undefined : targetValue, + transport_type: transportType, + }, + }, + { + onSuccess: () => { + onCancel(); + onSuccess?.(); + }, + }, + ); + } + }; + + return ( + + ) : ( + + ) + } + visible={visible} + onCancel={onCancel} + onOk={handleSubmit} + okText={ + isEditMode + ? intl.formatMessage({ + defaultMessage: 'Save', + description: 'MCP registry edit access binding modal save button', + }) + : intl.formatMessage({ + defaultMessage: 'Create', + description: 'MCP registry create access binding modal create button', + }) + } + confirmLoading={isSubmitting} + okButtonProps={{ disabled: !isFormValid || isSubmitting }} + > +
+ {activeMutation.error && ( + + )} + +
+ + + + {isServerLocked ? ( + {editBinding ? resolveBindingDisplayName(editBinding) : selectedServer} + ) : ( + { + setSelectedServer(target.value); + setSelectedTarget(`${ALIAS_PREFIX}latest`); + }} + disabled={isSubmitting} + placeholder={intl.formatMessage({ + defaultMessage: 'Select an MCP server', + description: 'MCP registry binding modal server placeholder', + })} + > + {servers?.map((s) => ( + + {s.display_name || s.name} + + ))} + + )} +
+ +
+ + + + setEndpointUrl(e.target.value)} + disabled={isSubmitting} + placeholder={intl.formatMessage({ + defaultMessage: 'https://mcp.example.com/server', + description: 'MCP registry binding modal endpoint placeholder', + })} + /> +
+ +
+ + + + setSelectedTarget(target.value)} + disabled={!selectedServer || isSubmitting} + > + + + + + {aliases.map((a) => ( + + @{a.alias} + + ))} + + {versions && versions.length > 0 && ( + + {versions.map((v) => ( + + {v.version} + + ))} + + )} + +
+ +
+ + + + setTransportType(target.value as MCPRemoteTransportType)} + disabled={isSubmitting} + > + + + + + + + +
+
+
+ ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/CardIconWrapper.tsx b/mlflow/server/js/src/mcp-registry/components/CardIconWrapper.tsx new file mode 100644 index 0000000000000..986169f1f6e6a --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/CardIconWrapper.tsx @@ -0,0 +1,19 @@ +import { useDesignSystemTheme } from '@databricks/design-system'; + +export const CardIconWrapper = ({ children }: { children: React.ReactNode }) => { + const { theme } = useDesignSystemTheme(); + return ( + + {children} + + ); +}; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx index 2d318c2b9199a..eff5614f72a0d 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx @@ -2,6 +2,7 @@ import { Card, ConnectIcon, Typography, useDesignSystemTheme } from '@databricks import type { MCPAccessBinding } from '../types'; import { resolveBindingDisplayName } from '../utils'; +import { CardIconWrapper } from './CardIconWrapper'; export const MCPAccessBindingCard = ({ binding }: { binding: MCPAccessBinding }) => { const { theme } = useDesignSystemTheme(); @@ -23,8 +24,10 @@ export const MCPAccessBindingCard = ({ binding }: { binding: MCPAccessBinding }) }} >
-
- +
+ + + {displayName} diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx index e28e38d56dab6..a95bc9ecc800c 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx @@ -15,6 +15,7 @@ export const MCPAccessBindingCardGrid = ({ onNextPage, onPreviousPage, pageSizeSelect, + onCreateBinding, }: { bindings?: MCPAccessBinding[]; isLoading?: boolean; @@ -24,6 +25,7 @@ export const MCPAccessBindingCardGrid = ({ onNextPage: () => void; onPreviousPage: () => void; pageSizeSelect?: CursorPaginationProps['pageSizeSelect']; + onCreateBinding?: () => void; }) => ( } - disabled + onClick={onCreateBinding} > diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx index 48945130dc57a..1338de35dfe9c 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx @@ -105,6 +105,55 @@ describe('MCPAccessBindingListTable', () => { expect(screen.getByText('Custom empty')).toBeInTheDocument(); }); + it('includes version in server detail link when binding has server_version', () => { + const bindings = [ + createMockMCPAccessBinding({ + binding_id: 1, + server_name: 'io.test/server', + server_version: '2.0.0', + }), + ]; + renderTable({ bindings }); + const link = screen.getByText('io.test/server').closest('a'); + expect(link?.getAttribute('href')).toContain('version=2.0.0'); + }); + + it('includes resolved version in server detail link for alias bindings', () => { + const bindings = [ + createMockMCPAccessBinding({ + binding_id: 1, + server_name: 'io.test/server', + server_alias: 'production', + server_version: undefined, + resolved_version: { + name: 'io.test/server', + version: '3.0.0', + server_json: { name: 'io.test/server', version: '3.0.0', title: 'Resolved Server' }, + status: 'active', + aliases: [], + tags: {}, + }, + }), + ]; + renderTable({ bindings }); + const link = screen.getByText('Resolved Server').closest('a'); + expect(link?.getAttribute('href')).toContain('version=3.0.0'); + }); + + it('server detail link has no version param when binding has neither version nor resolved_version', () => { + const bindings = [ + createMockMCPAccessBinding({ + binding_id: 1, + server_name: 'io.test/server', + server_version: undefined, + server_alias: undefined, + }), + ]; + renderTable({ bindings }); + const link = screen.getByText('io.test/server').closest('a'); + expect(link?.getAttribute('href')).not.toContain('version='); + }); + it('renders pagination controls', () => { const bindings = [createMockMCPAccessBinding()]; renderTable({ bindings, hasNextPage: true }); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx index 100bc1287e462..47a305a4f302b 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx @@ -11,6 +11,7 @@ import { TableHeader, TableRow, TableSkeletonRows, + Tooltip, Typography, useDesignSystemTheme, Button, @@ -24,21 +25,29 @@ import type { MCPAccessBinding } from '../types'; import MCPRegistryRoutes from '../routes'; import { emptyCenterStyles, formatTransportType, resolveBindingDisplayName } from '../utils'; import { Link } from '../../common/utils/RoutingUtils'; -import { CopyButton } from '../../shared/building_blocks/CopyButton'; +import { copyToClipboard } from '../../common/utils/copyToClipboard'; import Utils from '../../common/utils/Utils'; const EndpointCell: ColumnDef['cell'] = ({ row: { original } }) => { const { theme } = useDesignSystemTheme(); + const intl = useIntl(); return ( - } - /> + +
+
+ {serverDescription && ( + + {serverDescription} + + )} +
+ + + + {' '} + {target} + + + + + {' '} + {binding.last_updated_timestamp ? Utils.formatTimestamp(binding.last_updated_timestamp, intl) : '—'} + +
+
+ + ); +}; + export const MCPServerAccessBindings = ({ + server, bindings, isLoading, error, + onAddBinding, + onEditBinding, + onDeleteBinding, }: { + server?: MCPServer; bindings?: MCPAccessBinding[]; isLoading?: boolean; error?: Error | null; + onAddBinding?: () => void; + onEditBinding?: (binding: MCPAccessBinding) => void; + onDeleteBinding?: (binding: MCPAccessBinding) => void; }) => { const { theme } = useDesignSystemTheme(); - const intl = useIntl(); return (
@@ -34,7 +125,12 @@ export const MCPServerAccessBindings = ({ -
@@ -58,41 +154,17 @@ export const MCPServerAccessBindings = ({ /> ) : ( - - - - - - - - - - - - - - - +
{bindings.map((binding) => ( - - {binding.endpoint_url} - {formatTransportType(binding.transport_type)} - {binding.server_alias || binding.server_version || '—'} - - {binding.last_updated_timestamp ? Utils.formatTimestamp(binding.last_updated_timestamp, intl) : '—'} - - + ))} -
+
)} ); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx index 7a4cc578e925b..d6589c6dac428 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx @@ -4,6 +4,7 @@ import { useIntl } from 'react-intl'; import type { MCPServer } from '../types'; import MCPRegistryRoutes from '../routes'; import { resolveDisplayName } from '../utils'; +import { CardIconWrapper } from './CardIconWrapper'; import Utils from '../../common/utils/Utils'; export const MCPServerCard = ({ server }: { server: MCPServer }) => { @@ -22,8 +23,10 @@ export const MCPServerCard = ({ server }: { server: MCPServer }) => { href={`#${MCPRegistryRoutes.getMCPServerDetailRoute(server.name)}`} dangerouslyAppendEmotionCSS={{ height: '100%' }} > -
- +
+ + +
{displayName} diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx index 1e36ec635f661..b1b0cfa161353 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerVersionDetail.tsx @@ -54,6 +54,7 @@ export const MCPServerVersionDetail = ({ bindingsError, aliasesByVersion, showEditAliasesModal, + onAddBinding, }: { server: MCPServer; version?: MCPServerVersion; @@ -62,6 +63,7 @@ export const MCPServerVersionDetail = ({ bindingsError?: Error | null; aliasesByVersion: Record; showEditAliasesModal?: (versionNumber: string) => void; + onAddBinding?: () => void; }) => { const { theme } = useDesignSystemTheme(); const intl = useIntl(); @@ -249,13 +251,19 @@ export const MCPServerVersionDetail = ({ {version.server_json && } - + { updateStatusMutation.mutate( { version: version.version, status: newStatus }, @@ -283,7 +291,7 @@ export const MCPServerVersionDetail = ({ /> } isLoading={deleteVersionMutation.isLoading} - error={(deleteVersionMutation.error as Error | null)?.message ?? null} + error={deleteVersionMutation.error?.message ?? null} onConfirm={() => { deleteVersionMutation.mutate(version.version, { onSuccess: () => setDeleteModalVisible(false), diff --git a/mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts b/mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts new file mode 100644 index 0000000000000..2d6d57c4b7613 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { MCPRegistryApi } from '../api'; +import type { CreateMCPAccessBindingRequest, MCPAccessBinding, UpdateMCPAccessBindingRequest } from '../types'; +import { MCP_QUERY_KEYS } from '../utils'; + +export const useCreateAccessBindingMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ serverName, request }) => MCPRegistryApi.createMCPAccessBinding(serverName, request), + onSuccess: (_data, { serverName }) => { + queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDINGS_LIST]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_BINDINGS, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); + }, + }); +}; + +export const useUpdateAccessBindingMutation = () => { + const queryClient = useQueryClient(); + + return useMutation< + MCPAccessBinding, + Error, + { serverName: string; bindingId: number; request: UpdateMCPAccessBindingRequest } + >({ + mutationFn: ({ serverName, bindingId, request }) => + MCPRegistryApi.updateMCPAccessBinding(serverName, bindingId, request), + onSuccess: (_data, { serverName }) => { + queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDINGS_LIST]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_BINDINGS, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); + }, + }); +}; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts index 3ef59d32fd584..a3a3d21faf6c7 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts @@ -14,12 +14,14 @@ export const useCursorPaginatedQuery = Promise; extractData: (response: TResponse) => TData | undefined; + enabled?: boolean; }) => { const previousPageTokens = useRef<(string | undefined)[]>([]); const [currentPageToken, setCurrentPageToken] = useState(undefined); @@ -54,6 +56,7 @@ export const useCursorPaginatedQuery = queryFn({ searchFilter, pageToken: currentPageToken, pageSize }), retry: false, keepPreviousData: true, + enabled, }, ); diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts index 69f5ff8e6c8f5..530086af1d26c 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts @@ -1,10 +1,13 @@ import { MCPRegistryApi } from '../api'; -import { buildSearchFilterClause } from '../utils'; +import { MCP_QUERY_KEYS, buildSearchFilterClause } from '../utils'; import { useCursorPaginatedQuery } from './useCursorPaginatedQuery'; -export const useMCPAccessBindingsListQuery = ({ searchFilter }: { searchFilter?: string } = {}) => { +export const useMCPAccessBindingsListQuery = ({ + searchFilter, + enabled, +}: { searchFilter?: string; enabled?: boolean } = {}) => { return useCursorPaginatedQuery({ - queryKeyPrefix: 'mcp_bindings_list', + queryKeyPrefix: MCP_QUERY_KEYS.BINDINGS_LIST, searchFilter, storageKey: 'mcp_registry.bindings_page_size', queryFn: ({ searchFilter: filter, pageToken, pageSize }) => @@ -14,5 +17,6 @@ export const useMCPAccessBindingsListQuery = ({ searchFilter }: { searchFilter?: max_results: pageSize, }), extractData: (response) => response.mcp_access_bindings, + enabled, }); }; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts index d57708c429f63..7e5198d79f287 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts @@ -1,9 +1,10 @@ import { useQuery } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; import { MCPRegistryApi } from '../api'; import type { MCPServer, SearchMCPServerVersionsResponse, SearchMCPAccessBindingsResponse } from '../types'; +import { MCP_QUERY_KEYS } from '../utils'; export const useMCPServerQuery = (name: string) => { - return useQuery(['mcp_server', name], { + return useQuery([MCP_QUERY_KEYS.SERVER, name], { queryFn: () => MCPRegistryApi.getMCPServer(name), retry: false, enabled: Boolean(name), @@ -11,7 +12,7 @@ export const useMCPServerQuery = (name: string) => { }; export const useMCPServerVersionsQuery = (name: string) => { - const queryResult = useQuery(['mcp_server_versions', name], { + const queryResult = useQuery([MCP_QUERY_KEYS.SERVER_VERSIONS, name], { queryFn: () => MCPRegistryApi.searchMCPServerVersions(name), retry: false, enabled: Boolean(name), @@ -24,7 +25,7 @@ export const useMCPServerVersionsQuery = (name: string) => { }; export const useMCPAccessBindingsQuery = (name: string) => { - const queryResult = useQuery(['mcp_server_bindings', name], { + const queryResult = useQuery([MCP_QUERY_KEYS.SERVER_BINDINGS, name], { queryFn: () => MCPRegistryApi.searchMCPAccessBindings(name), retry: false, enabled: Boolean(name), diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts index 7071a68fb8b24..b11fbb2b86a23 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts @@ -1,46 +1,45 @@ import { useMutation, useQueryClient } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; import { MCPRegistryApi } from '../api'; import type { MCPStatus } from '../types'; +import { MCP_QUERY_KEYS } from '../utils'; -export const useUpdateMCPServerVersionStatus = (serverName: string) => { +const useInvalidateServerQueries = () => { const queryClient = useQueryClient(); + return (serverName: string) => { + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_VERSIONS, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_BINDINGS, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVERS_LIST]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDINGS_LIST]); + }; +}; - return useMutation({ - mutationFn: ({ version, status }: { version: string; status: MCPStatus }) => - MCPRegistryApi.updateMCPServerVersion(serverName, version, { status }), - onSuccess: () => { - queryClient.invalidateQueries(['mcp_server', serverName]); - queryClient.invalidateQueries(['mcp_server_versions', serverName]); - queryClient.invalidateQueries(['mcp_server_bindings', serverName]); - queryClient.invalidateQueries(['mcp_servers_list']); - queryClient.invalidateQueries(['mcp_bindings_list']); - }, +export const useUpdateMCPServerVersionStatus = (serverName: string) => { + const invalidate = useInvalidateServerQueries(); + + return useMutation({ + mutationFn: ({ version, status }) => MCPRegistryApi.updateMCPServerVersion(serverName, version, { status }), + onSuccess: () => invalidate(serverName), }); }; export const useDeleteMCPServerVersion = (serverName: string) => { - const queryClient = useQueryClient(); + const invalidate = useInvalidateServerQueries(); - return useMutation({ - mutationFn: (version: string) => MCPRegistryApi.deleteMCPServerVersion(serverName, version), - onSuccess: () => { - queryClient.invalidateQueries(['mcp_server', serverName]); - queryClient.invalidateQueries(['mcp_server_versions', serverName]); - queryClient.invalidateQueries(['mcp_server_bindings', serverName]); - queryClient.invalidateQueries(['mcp_servers_list']); - queryClient.invalidateQueries(['mcp_bindings_list']); - }, + return useMutation({ + mutationFn: (version) => MCPRegistryApi.deleteMCPServerVersion(serverName, version), + onSuccess: () => invalidate(serverName), }); }; export const useDeleteMCPServer = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (name: string) => MCPRegistryApi.deleteMCPServer(name), + return useMutation({ + mutationFn: (name) => MCPRegistryApi.deleteMCPServer(name), onSuccess: () => { - queryClient.invalidateQueries(['mcp_servers_list']); - queryClient.invalidateQueries(['mcp_bindings_list']); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVERS_LIST]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDINGS_LIST]); }, }); }; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts index 219e610b20c2f..497b63fc39dac 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts @@ -1,10 +1,13 @@ import { MCPRegistryApi } from '../api'; -import { buildSearchFilterClause } from '../utils'; +import { MCP_QUERY_KEYS, buildSearchFilterClause } from '../utils'; import { useCursorPaginatedQuery } from './useCursorPaginatedQuery'; -export const useMCPServersListQuery = ({ searchFilter }: { searchFilter?: string } = {}) => { +export const useMCPServersListQuery = ({ + searchFilter, + enabled, +}: { searchFilter?: string; enabled?: boolean } = {}) => { return useCursorPaginatedQuery({ - queryKeyPrefix: 'mcp_servers_list', + queryKeyPrefix: MCP_QUERY_KEYS.SERVERS_LIST, searchFilter, storageKey: 'mcp_registry.page_size', queryFn: ({ searchFilter: filter, pageToken, pageSize }) => @@ -14,5 +17,6 @@ export const useMCPServersListQuery = ({ searchFilter }: { searchFilter?: string max_results: pageSize, }), extractData: (response) => response.mcp_servers, + enabled, }); }; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useSelectedMCPServerVersion.ts b/mlflow/server/js/src/mcp-registry/hooks/useSelectedMCPServerVersion.ts new file mode 100644 index 0000000000000..80ca9c88509b3 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useSelectedMCPServerVersion.ts @@ -0,0 +1,29 @@ +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/pages/MCPRegistryPage.test.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx index 7b19f57b5e2db..9033dff7c55bd 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx @@ -337,6 +337,25 @@ describe('MCPRegistryPage', () => { }); }); + it('disables create binding button when no servers exist', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Register an MCP server before creating access bindings.')).toBeInTheDocument(); + }); + const createButton = screen.getByText('Create access binding').closest('button'); + expect(createButton).toBeDisabled(); + }); + + it('enables create binding button when servers exist', async () => { + const servers = [createMockMCPServer({ name: 'io.test/server' })]; + server.use(getMockedSearchMCPServersResponse(servers), getMockedSearchMCPAccessBindingsAllResponse([])); + renderPage(); + await waitFor(() => { + const createButton = screen.getByText('Create access binding').closest('button'); + expect(createButton).not.toBeDisabled(); + }); + }); + it('renders view toggle on bindings tab', async () => { renderPage(); await waitFor(() => { diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx index cda6d85a48a5c..eb14587e765dc 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx @@ -27,9 +27,11 @@ import { useMCPServersListQuery } from '../hooks/useMCPServersListQuery'; import { useMCPAccessBindingsListQuery } from '../hooks/useMCPAccessBindingsListQuery'; import { MCPServerCardGrid } from '../components/MCPServerCardGrid'; import { MCPServerListTable } from '../components/MCPServerListTable'; +import type { MCPAccessBinding } from '../types'; import { emptyCenterStyles } from '../utils'; import { MCPAccessBindingCardGrid } from '../components/MCPAccessBindingCardGrid'; import { MCPAccessBindingListTable } from '../components/MCPAccessBindingListTable'; +import { AccessBindingModal } from '../components/AccessBindingModal'; import { useDebounce } from 'use-debounce'; type ViewMode = 'list' | 'grid'; @@ -43,6 +45,8 @@ const MCPRegistryPage = () => { const activeTab: ActiveTab = tabFromUrl === 'servers' ? 'servers' : 'bindings'; const [viewMode, setViewMode] = useState('grid'); const [searchFilter, setSearchFilter] = useState(''); + const [bindingModalOpen, setBindingModalOpen] = useState(false); + const [editingBinding, setEditingBinding] = useState(undefined); const [debouncedSearchFilter] = useDebounce(searchFilter, 500); const effectiveFilter = searchFilter ? debouncedSearchFilter : undefined; @@ -55,7 +59,9 @@ const MCPRegistryPage = () => { onNextPage, onPreviousPage, pageSizeSelect, - } = useMCPServersListQuery({ searchFilter: activeTab === 'servers' ? effectiveFilter : undefined }); + } = useMCPServersListQuery({ + searchFilter: activeTab === 'servers' ? effectiveFilter : undefined, + }); const { data: bindings, @@ -66,7 +72,10 @@ const MCPRegistryPage = () => { onNextPage: bindingsOnNextPage, onPreviousPage: bindingsOnPreviousPage, pageSizeSelect: bindingsPageSizeSelect, - } = useMCPAccessBindingsListQuery({ searchFilter: activeTab === 'bindings' ? effectiveFilter : undefined }); + } = useMCPAccessBindingsListQuery({ + searchFilter: activeTab === 'bindings' ? effectiveFilter : undefined, + enabled: activeTab === 'bindings', + }); const handleTabChange = useCallback( (e: RadioChangeEvent) => { @@ -83,12 +92,25 @@ const MCPRegistryPage = () => { [searchParams, setSearchParams], ); - const isEmptyState = !isLoading && !error && !servers?.length && !searchFilter; - const createButton = !isEmptyState ? ( - - ) : null; + const isServersEmpty = !isLoading && !error && !servers?.length && !searchFilter; + const createButton = + activeTab === 'bindings' ? ( + + ) : !isServersEmpty ? ( + + ) : null; const serversEmptyState = (
@@ -198,7 +220,7 @@ const MCPRegistryPage = () => { /> )} {viewMode === 'grid' ? ( - isEmptyState ? ( + isServersEmpty ? ( serversEmptyState ) : ( { css={{ marginTop: theme.spacing.sm, flexShrink: 0 }} /> )} - {isEmptyState && viewMode === 'grid' ? ( + {isServersEmpty && viewMode === 'grid' ? (
{ onNextPage={bindingsOnNextPage} onPreviousPage={bindingsOnPreviousPage} pageSizeSelect={bindingsPageSizeSelect} + onCreateBinding={() => { + setEditingBinding(undefined); + setBindingModalOpen(true); + }} /> ) : ( { onNextPage={bindingsOnNextPage} onPreviousPage={bindingsOnPreviousPage} pageSizeSelect={bindingsPageSizeSelect} + onCreateBinding={() => { + setEditingBinding(undefined); + setBindingModalOpen(true); + }} + onEditBinding={(binding) => { + setEditingBinding(binding); + setBindingModalOpen(true); + }} emptyStateOverride={ - isEmptyState ? ( + isServersEmpty ? ( {
)}
+ { + setEditingBinding(undefined); + setBindingModalOpen(false); + }} + editBinding={editingBinding} + /> ); }; 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 8112c394f8d0d..f3af91a421edd 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.test.tsx @@ -216,6 +216,60 @@ describe('MCPServerDetailPage', () => { }); }); + it('pre-selects version from URL query param', async () => { + const version2 = 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, version2])); + + renderPage(['/mcp-registry/dev.mainline%2Fmcp?version=2']); + await waitFor(() => { + expect(screen.getByText('Viewing version 2')).toBeInTheDocument(); + expect(screen.getByText('2.0.0')).toBeInTheDocument(); + }); + }); + + it('falls back to first version when URL version param is invalid', async () => { + renderPage(['/mcp-registry/dev.mainline%2Fmcp?version=nonexistent']); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + }); + + it('persists selected version across re-renders', async () => { + const version2 = 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, version2])); + + renderPage(); + await waitFor(() => { + expect(screen.getByText('Viewing version 1')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Version 2')); + await waitFor(() => { + expect(screen.getByText('Viewing version 2')).toBeInTheDocument(); + expect(screen.getByText('2.0.0')).toBeInTheDocument(); + }); + }); + it('shows terminal state warning for deleted version status', async () => { const deletedVersion = createMockMCPServerVersion({ name: 'dev.mainline/mcp', diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPServerDetailPage.tsx index 14f31bb1e6101..3abd6a54e8a7c 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, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Alert, Breadcrumb, @@ -32,6 +32,8 @@ import { import { useDeleteMCPServer } from '../hooks/useMCPServerVersionMutations'; import { MCPServerVersionList } from '../components/MCPServerVersionList'; import { MCPServerVersionDetail } from '../components/MCPServerVersionDetail'; +import { AccessBindingModal } from '../components/AccessBindingModal'; +import { useSelectedMCPServerVersion } from '../hooks/useSelectedMCPServerVersion'; import { resolveDisplayName } from '../utils'; const getAliasesModalTitle = (version: string) => ( @@ -48,6 +50,7 @@ const MCPServerDetailPage = () => { const navigate = useNavigate(); const { serverName = '' } = useParams<{ serverName: string }>(); const [deleteServerModalVisible, setDeleteServerModalVisible] = useState(false); + const [addBindingModalOpen, setAddBindingModalOpen] = useState(false); const deleteServerMutation = useDeleteMCPServer(); const { @@ -64,21 +67,14 @@ const MCPServerDetailPage = () => { } = useMCPServerVersionsQuery(serverName); const { data: bindings, isLoading: bindingsLoading, error: bindingsError } = useMCPAccessBindingsQuery(serverName); - const [selectedVersion, setSelectedVersion] = useState(undefined); + const latestVersion = versions?.[0]?.version; + const [selectedVersion, setSelectedVersion] = useSelectedMCPServerVersion(latestVersion); - useEffect(() => { - if (!versions?.length) { - setSelectedVersion(undefined); - return; - } - const currentStillValid = versions.some((v) => v.version === selectedVersion); - if (!currentStillValid) { - setSelectedVersion(versions[0].version); - } + const currentVersion = useMemo(() => { + if (!versions?.length) return undefined; + return versions.find((v) => v.version === selectedVersion) ?? versions[0]; }, [versions, selectedVersion]); - const currentVersion = versions?.find((v) => v.version === selectedVersion); - const aliasesByVersion = useMemo(() => { const result: Record = {}; server?.aliases?.forEach(({ alias, version }) => { @@ -266,10 +262,17 @@ const MCPServerDetailPage = () => { bindingsError={bindingsError} aliasesByVersion={aliasesByVersion} showEditAliasesModal={showEditAliasesModal} + onAddBinding={() => setAddBindingModalOpen(true)} />
{EditAliasesModal} + setAddBindingModalOpen(false)} + lockedServer={serverName} + defaultVersion={currentVersion?.version} + /> { /> } isLoading={deleteServerMutation.isLoading} - error={(deleteServerMutation.error as Error | null)?.message ?? null} + error={deleteServerMutation.error?.message ?? null} onConfirm={() => { deleteServerMutation.mutate(serverName, { onSuccess: () => { diff --git a/mlflow/server/js/src/mcp-registry/routes.ts b/mlflow/server/js/src/mcp-registry/routes.ts index 8a6a734e18981..3ecc583dbba94 100644 --- a/mlflow/server/js/src/mcp-registry/routes.ts +++ b/mlflow/server/js/src/mcp-registry/routes.ts @@ -22,10 +22,14 @@ class MCPRegistryRoutes { return MCPRegistryRoutePaths.mcpRegistryPage; } - static getMCPServerDetailRoute(serverName: string) { - return generatePath(MCPRegistryRoutePaths.mcpServerDetailPage, { + static getMCPServerDetailRoute(serverName: string, version?: string) { + const path = generatePath(MCPRegistryRoutePaths.mcpServerDetailPage, { serverName: encodeURIComponent(serverName), }); + if (version) { + return `${path}?version=${encodeURIComponent(version)}`; + } + return path; } } diff --git a/mlflow/server/js/src/mcp-registry/utils.ts b/mlflow/server/js/src/mcp-registry/utils.ts index d6847b15533db..60c2d77f21d0d 100644 --- a/mlflow/server/js/src/mcp-registry/utils.ts +++ b/mlflow/server/js/src/mcp-registry/utils.ts @@ -31,6 +31,14 @@ export const emptyCenterStyles = { }, }; +export const MCP_QUERY_KEYS = { + SERVERS_LIST: 'mcp_servers_list', + SERVER: 'mcp_server', + SERVER_VERSIONS: 'mcp_server_versions', + SERVER_BINDINGS: 'mcp_server_bindings', + BINDINGS_LIST: 'mcp_bindings_list', +} as const; + export const DEFAULT_PAGE_SIZE = 25; export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; From ab4b2d70433b7c24bae3102e380de634641f287d Mon Sep 17 00:00:00 2001 From: Juntao Wang Date: Wed, 10 Jun 2026 15:42:11 -0400 Subject: [PATCH 3/3] Add Access Binding detail page, URL validation, and bug fixes - Add MCPAccessBindingDetailPage with metadata grid, client config JSON, edit/delete modals, copy endpoint URL, and breadcrumb navigation - Add route, query hook, and delete mutation for binding detail - Add URL validation (isValidEndpointUrl) requiring http:// or https:// with inline error message on AccessBindingModal - Add description text on Access Bindings tab - Fix double-encoding of serverName by adding decodeURIComponent on useParams (matching model-registry pattern) - Fix MCPIcon.sizes type from string to string[] to match backend - Fix UpdateVersionStatusModal selectedStatus type safety - Fix delete binding missing SERVER and BINDING_DETAIL invalidation - Extract useInvalidateBindingQueries to reduce duplication - Add edit/delete binding support on server detail page binding cards with stopPropagation to prevent card navigation - Switch binding card grid to href-based navigation for right-click support - Align metadata grid label width to 120px across detail pages - Add created_by and last_updated_by fields to binding detail page - Add 4 test files: AccessBindingModal, MCPAccessBindingDetailPage, MCPServerAccessBindings, useCursorPaginatedQuery (142 total tests) Signed-off-by: Juntao Wang --- .../componentId-registry.js | 11 + mlflow/server/js/src/lang/default/en.json | 80 +++++ .../components/AccessBindingModal.test.tsx | 111 +++++++ .../components/AccessBindingModal.tsx | 19 +- .../components/MCPAccessBindingCard.test.tsx | 15 +- .../components/MCPAccessBindingCard.tsx | 4 +- .../components/MCPAccessBindingListTable.tsx | 7 +- .../MCPServerAccessBindings.test.tsx | 88 +++++ .../components/MCPServerAccessBindings.tsx | 57 ++-- .../components/MCPServerVersionDetail.tsx | 6 + .../components/UpdateVersionStatusModal.tsx | 4 +- .../hooks/useAccessBindingMutation.ts | 35 +- .../hooks/useCursorPaginatedQuery.test.tsx | 196 +++++++++++ .../hooks/useMCPServerDetailQuery.ts | 15 +- .../pages/MCPAccessBindingDetailPage.test.tsx | 158 +++++++++ .../pages/MCPAccessBindingDetailPage.tsx | 314 ++++++++++++++++++ .../mcp-registry/pages/MCPRegistryPage.tsx | 7 + .../pages/MCPServerDetailPage.tsx | 46 ++- .../server/js/src/mcp-registry/route-defs.ts | 8 + mlflow/server/js/src/mcp-registry/routes.ts | 11 + .../server/js/src/mcp-registry/test-utils.ts | 11 + mlflow/server/js/src/mcp-registry/types.ts | 2 +- .../server/js/src/mcp-registry/utils.test.ts | 41 +++ mlflow/server/js/src/mcp-registry/utils.ts | 11 + 24 files changed, 1204 insertions(+), 53 deletions(-) create mode 100644 mlflow/server/js/src/mcp-registry/components/AccessBindingModal.test.tsx create mode 100644 mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.test.tsx create mode 100644 mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.test.tsx create mode 100644 mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.test.tsx create mode 100644 mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx diff --git a/.github/actions/check-component-ids/componentId-registry.js b/.github/actions/check-component-ids/componentId-registry.js index 36492ce2d1b07..9925be980e080 100644 --- a/.github/actions/check-component-ids/componentId-registry.js +++ b/.github/actions/check-component-ids/componentId-registry.js @@ -1837,6 +1837,16 @@ 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": "", @@ -1872,6 +1882,7 @@ module.exports = { "mlflow.mcp_registry.detail.bindings_error": "", "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": "", diff --git a/mlflow/server/js/src/lang/default/en.json b/mlflow/server/js/src/lang/default/en.json index 181cdde782fc8..d04c5c604b6ac 100644 --- a/mlflow/server/js/src/lang/default/en.json +++ b/mlflow/server/js/src/lang/default/en.json @@ -543,6 +543,10 @@ "defaultMessage": "Copy link to trace", "description": "Tooltip for the share trace button" }, + "0qq5zE": { + "defaultMessage": "Copy endpoint URL", + "description": "Tooltip for copy endpoint URL button on binding detail" + }, "0r2ub6": { "defaultMessage": "Overview", "description": "Label for the overview tab in the MLflow experiment navbar" @@ -699,6 +703,10 @@ "defaultMessage": "Version", "description": "Column title text for model version in model version table" }, + "1fkQkn": { + "defaultMessage": "Created by:", + "description": "Binding detail created by label" + }, "1hI96w": { "defaultMessage": "Showing only visible runs", "description": "Experiment page > compare runs > parallel chart > header > indicator for only visible runs shown" @@ -983,6 +991,10 @@ "defaultMessage": "Add new tag", "description": "Experiment tracking > experiment page > runs > add new tag button" }, + "3/LDru": { + "defaultMessage": "Are you sure you want to delete this access binding? This action cannot be undone.", + "description": "Access binding delete confirmation message" + }, "3253th": { "defaultMessage": "Trace archival retention update failed", "description": "Partial save status message when experiment trace archival retention update fails" @@ -1439,6 +1451,10 @@ "defaultMessage": "This key will be saved for reuse.", "description": "Text indicating API key will be saved for reuse" }, + "5zdBY2": { + "defaultMessage": "Delete", + "description": "Access binding detail delete action" + }, "60b0vW": { "defaultMessage": "Always show spans with exceptions, regardless of filter conditions", "description": "Tooltip for a span filter setting that enables showing spans with exceptions" @@ -1787,6 +1803,10 @@ "defaultMessage": "Trace Archival Retention", "description": "Label for displaying the experiment trace archival retention" }, + "7eEsbo": { + "defaultMessage": "Failed to load access binding", + "description": "Access binding detail page error title" + }, "7hHw+R": { "defaultMessage": "Instructions", "description": "Section header for judge instructions" @@ -2379,6 +2399,10 @@ "defaultMessage": "Error", "description": "Error assessment status in the evaluations table." }, + "AXs8UU": { + "defaultMessage": "Version:", + "description": "Binding detail version label" + }, "AanBxl": { "defaultMessage": "my-endpoint", "description": "Placeholder for endpoint name input" @@ -2671,6 +2695,10 @@ "defaultMessage": "Evaluation", "description": "Label for the evaluation section in the MLflow experiment navbar" }, + "C18YVq": { + "defaultMessage": "Created at:", + "description": "Binding detail created at label" + }, "C4y1fv": { "defaultMessage": "-∞", "description": "Label displaying negative infinity symbol displayed on a plot UI element" @@ -3303,6 +3331,10 @@ "defaultMessage": "Scorer", "description": "Column header for scorer name" }, + "FolS8j": { + "defaultMessage": "More actions", + "description": "Aria label for access binding detail actions overflow menu" + }, "FpjDSq": { "defaultMessage": "Compare", "description": "Text for compare button to compare versions under details tab\n on the model view page" @@ -3403,6 +3435,10 @@ "defaultMessage": "Are you sure you want to delete the webhook \"{name}\"? This action cannot be undone.", "description": "Delete webhook confirmation message" }, + "GcrbK9": { + "defaultMessage": "Description:", + "description": "Binding detail description label" + }, "GdtTc/": { "defaultMessage": "Run evaluation", "description": "Home page quick action title for running evaluations" @@ -3895,6 +3931,10 @@ "defaultMessage": "Moving average over time", "description": "Label for assessment score over time chart" }, + "JOfqZ1": { + "defaultMessage": "Approved endpoints that connect MCP servers in the registry to live deployments in your environment.", + "description": "Description text for the access bindings tab" + }, "JPd7WA": { "defaultMessage": "Key must be unique", "description": "Error message for unique key in trace tag assignment modal" @@ -5379,6 +5419,10 @@ "defaultMessage": "View all", "description": "Home page news section view more link" }, + "Rgq23W": { + "defaultMessage": "Updated by:", + "description": "Binding detail updated by label" + }, "RhQhT4": { "defaultMessage": "Model", "description": "Label for model name in span details" @@ -5419,6 +5463,10 @@ "defaultMessage": "Ready", "description": "Label for ready state of a experiment logged model" }, + "RsCEso": { + "defaultMessage": "Enter a valid HTTP or HTTPS URL", + "description": "MCP registry binding modal endpoint URL validation error" + }, "RsTIRk": { "defaultMessage": "Add personal notes about this trace.", "description": "Tooltip describing the notes section in the assessments pane" @@ -5463,6 +5511,10 @@ "defaultMessage": "Span: {spanName}", "description": "Label for the span name in assessment metadata" }, + "S4UbbT": { + "defaultMessage": "Transport:", + "description": "Binding detail transport label" + }, "S50iFK": { "defaultMessage": "Create endpoint", "description": "Title for create endpoint modal" @@ -6623,6 +6675,10 @@ "defaultMessage": "Save", "description": "Save-changes button on the dataset record drawer footer" }, + "YOW2R+": { + "defaultMessage": "Delete access binding", + "description": "Aria label for delete access binding button" + }, "YOp3/x": { "defaultMessage": "Unavailable when runs are grouped", "description": "Experiment page > view mode switch > evaluation mode disabled tooltip" @@ -7535,6 +7591,10 @@ "defaultMessage": "Run", "description": "Column title for the column displaying the run names for a metric" }, + "d2Z2Ub": { + "defaultMessage": "Last updated:", + "description": "Binding detail last updated label" + }, "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" @@ -8087,6 +8147,10 @@ "defaultMessage": "Does the response follow the provided guidelines?", "description": "Hint for Guidelines template" }, + "g+LzMY": { + "defaultMessage": "Edit binding", + "description": "Access binding detail edit button" + }, "g+YDB/": { "defaultMessage": "Group by", "description": "Label for the grouping selector button in the logged model list page when no grouping is selected" @@ -8711,6 +8775,10 @@ "defaultMessage": "Cancel", "description": "Cancel-button text for the V2 evaluation dataset delete modal on the detail page" }, + "j/2d8u": { + "defaultMessage": "Endpoint URL:", + "description": "Binding detail endpoint URL label" + }, "j/BF11": { "defaultMessage": "Connect to the tracking server", "description": "Step 1 title for Python traces onboarding" @@ -8799,6 +8867,10 @@ "defaultMessage": "Groundedness assessment is missing. This is likely because your agent is not returning retrieved_context.", "description": "Evaluation results > known type of evaluation result assessment > groundedness assessment. Used to indicate if the result is grounded in context of LLMs evaluation. Label displayed if user provided custom value, e.g. \"Groundedness: moderately grounded\"" }, + "jQSwNN": { + "defaultMessage": "Client configuration", + "description": "Binding detail client config section title" + }, "jR08Zd": { "defaultMessage": "This judge template is not yet supported for sample judge output", "description": "Tooltip message when selected template is not supported for running on sample traces" @@ -9111,6 +9183,10 @@ "defaultMessage": "The conversational guidelines LLM judge evaluates whether the assistant's responses throughout a conversation comply with the provided guidelines.", "description": "Evaluation results > known type of evaluation result assessment > conversational guidelines judge." }, + "lEpsnJ": { + "defaultMessage": "Delete access binding", + "description": "Access binding delete confirmation modal title" + }, "lFnpMi": { "defaultMessage": "AutoML", "description": "A short label for generic AutoML experiments" @@ -9851,6 +9927,10 @@ "defaultMessage": "Please input a new name for the new experiment.", "description": "Error message for name requirement in create experiment for MLflow" }, + "ol+AXD": { + "defaultMessage": "MCP server:", + "description": "Binding detail MCP server label" + }, "olY499": { "defaultMessage": "Search datasets", "description": "Aria label for the search input on the V2 evaluation datasets list page (placeholder is not a label per WCAG 1.3.1)" diff --git a/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.test.tsx b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.test.tsx new file mode 100644 index 0000000000000..4415dbe45b48a --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect } from '@jest/globals'; +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'; +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 { AccessBindingModal } from './AccessBindingModal'; +import { + createMockMCPAccessBinding, + createMockMCPServer, + createMockMCPServerVersion, + getMockedSearchMCPServersResponse, + getMockedGetMCPServerResponse, + getMockedSearchMCPServerVersionsResponse, +} from '../test-utils'; + +const noop = () => {}; + +const mockServers = [ + createMockMCPServer({ name: 'io.test/alpha', display_name: 'Alpha' }), + createMockMCPServer({ name: 'io.test/beta', display_name: 'Beta' }), +]; + +const mockVersions = [ + createMockMCPServerVersion({ name: 'io.test/alpha', version: '1', status: 'active' }), + createMockMCPServerVersion({ name: 'io.test/alpha', version: '2', status: 'draft' }), +]; + +const defaultHandlers = [ + getMockedSearchMCPServersResponse(mockServers), + getMockedGetMCPServerResponse(mockServers[0]), + getMockedSearchMCPServerVersionsResponse(mockVersions), +]; + +describe('AccessBindingModal', () => { + const server = setupServer(...defaultHandlers); + + const renderModal = (props: Partial> = {}) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + + , + '/', + ), + ]} + /> + , + ); + }; + + it('renders create mode with empty fields', () => { + renderModal(); + expect(screen.getByText('Create access binding')).toBeInTheDocument(); + expect(screen.getByText('Create')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('https://mcp.example.com/server')).toHaveValue(''); + }); + + it('renders edit mode with pre-filled data from editBinding prop', () => { + const editBinding = createMockMCPAccessBinding({ + server_name: 'io.test/alpha', + endpoint_url: 'https://existing.example.com/mcp', + transport_type: 'sse', + server_version: '1', + }); + renderModal({ editBinding }); + expect(screen.getByText('Edit access binding')).toBeInTheDocument(); + expect(screen.getByText('Save')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('https://mcp.example.com/server')).toHaveValue( + 'https://existing.example.com/mcp', + ); + }); + + it('shows URL validation error for invalid URLs', async () => { + renderModal(); + const input = screen.getByPlaceholderText('https://mcp.example.com/server'); + await userEvent.type(input, 'not-a-url'); + await waitFor(() => { + expect(screen.getByText('Enter a valid HTTP or HTTPS URL')).toBeInTheDocument(); + }); + }); + + it('accepts valid HTTPS URL without showing validation error', async () => { + renderModal(); + const input = screen.getByPlaceholderText('https://mcp.example.com/server'); + await userEvent.type(input, 'https://valid.example.com/mcp'); + await waitFor(() => { + expect(screen.queryByText('Enter a valid HTTP or HTTPS URL')).not.toBeInTheDocument(); + }); + }); + + it('save button disabled when form is invalid (empty URL, no server)', () => { + renderModal(); + const createButton = screen.getByRole('button', { name: 'Create' }); + expect(createButton).toBeDisabled(); + }); + + it('server dropdown hidden when lockedServer is set', () => { + renderModal({ lockedServer: 'io.test/alpha' }); + expect(screen.getByText('io.test/alpha')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Select an MCP server')).not.toBeInTheDocument(); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx index 1d3e1755083a1..64d024c3067e9 100644 --- a/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx +++ b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx @@ -15,7 +15,7 @@ import type { MCPAccessBinding, MCPRemoteTransportType } from '../types'; import { useMCPServerQuery, useMCPServerVersionsQuery } from '../hooks/useMCPServerDetailQuery'; import { useMCPServersListQuery } from '../hooks/useMCPServersListQuery'; import { useCreateAccessBindingMutation, useUpdateAccessBindingMutation } from '../hooks/useAccessBindingMutation'; -import { resolveBindingDisplayName } from '../utils'; +import { isValidEndpointUrl, resolveBindingDisplayName } from '../utils'; import { FieldLabel } from '../../admin/components/FieldLabel'; const ALIAS_PREFIX = 'alias:'; @@ -24,7 +24,7 @@ const VERSION_PREFIX = 'version:'; function bindingToTarget(binding: MCPAccessBinding): string { if (binding.server_alias) return `${ALIAS_PREFIX}${binding.server_alias}`; if (binding.server_version) return `${VERSION_PREFIX}${binding.server_version}`; - return ''; + return `${ALIAS_PREFIX}latest`; } export const AccessBindingModal = ({ @@ -80,7 +80,9 @@ export const AccessBindingModal = ({ const aliases = server?.aliases ?? []; const isSubmitting = activeMutation.isLoading; - const isFormValid = Boolean(selectedServer && endpointUrl.trim() && selectedTarget); + + const isValidUrl = isValidEndpointUrl(endpointUrl); + const isFormValid = Boolean(selectedServer && isValidUrl && selectedTarget); const handleSubmit = () => { if (!isFormValid) return; @@ -167,7 +169,7 @@ export const AccessBindingModal = ({ )} @@ -215,7 +217,16 @@ export const AccessBindingModal = ({ defaultMessage: 'https://mcp.example.com/server', description: 'MCP registry binding modal endpoint placeholder', })} + validationState={endpointUrl.trim() && !isValidUrl ? 'error' : undefined} /> + {endpointUrl.trim() && !isValidUrl && ( + + + + )}
diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx index da2e08fc3773a..300d47c54320a 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx @@ -2,6 +2,7 @@ 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 { testRoute, TestRouter } from '../../common/utils/RoutingTestUtils'; import { MCPAccessBindingCard } from './MCPAccessBindingCard'; import { createMockMCPAccessBinding } from '../test-utils'; import type { MCPAccessBinding } from '../types'; @@ -9,9 +10,17 @@ import type { MCPAccessBinding } from '../types'; const renderCard = (binding: MCPAccessBinding) => render( - - - + + + , + '/', + ), + testRoute(
, '*'), + ]} + /> , ); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx index eff5614f72a0d..49bfa265dafe9 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx @@ -1,6 +1,7 @@ import { Card, ConnectIcon, Typography, useDesignSystemTheme } from '@databricks/design-system'; import type { MCPAccessBinding } from '../types'; +import MCPRegistryRoutes from '../routes'; import { resolveBindingDisplayName } from '../utils'; import { CardIconWrapper } from './CardIconWrapper'; @@ -15,11 +16,12 @@ export const MCPAccessBindingCard = ({ binding }: { binding: MCPAccessBinding }) diff --git a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx index 47a305a4f302b..fa59764e0a01e 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx @@ -48,14 +48,13 @@ const EndpointCell: ColumnDef['cell'] = ({ row: { original } } css={{ flexShrink: 0 }} /> - {original.endpoint_url} - + ); }; diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.test.tsx new file mode 100644 index 0000000000000..96ac4927514c9 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.test.tsx @@ -0,0 +1,88 @@ +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 { testRoute, TestRouter } from '../../common/utils/RoutingTestUtils'; +import { MCPServerAccessBindings } from './MCPServerAccessBindings'; +import { createMockMCPAccessBinding } from '../test-utils'; + +const noop = () => {}; + +const renderComponent = (props: Partial> = {}) => + render( + + + + , + '/', + ), + ]} + /> + , + ); + +describe('MCPServerAccessBindings', () => { + it('renders binding cards when bindings provided', () => { + const bindings = [ + createMockMCPAccessBinding({ + binding_id: 1, + endpoint_url: 'https://mcp.example.com/alpha', + transport_type: 'streamable-http', + }), + createMockMCPAccessBinding({ + binding_id: 2, + endpoint_url: 'https://mcp.example.com/beta', + transport_type: 'sse', + }), + ]; + renderComponent({ bindings }); + expect(screen.getByText('https://mcp.example.com/alpha')).toBeInTheDocument(); + expect(screen.getByText('https://mcp.example.com/beta')).toBeInTheDocument(); + }); + + it('shows empty message when no bindings', () => { + renderComponent({ bindings: [] }); + expect(screen.getByText('No access bindings configured for this server.')).toBeInTheDocument(); + }); + + it('shows loading spinner when loading', () => { + renderComponent({ isLoading: true }); + expect(screen.queryByText('No access bindings configured for this server.')).not.toBeInTheDocument(); + // The Databricks Spinner renders with this class + expect(document.querySelector('.du-bois-light-spin')).toBeInTheDocument(); + }); + + it('shows error alert when error provided', () => { + renderComponent({ error: new Error('Something went wrong') }); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('edit callback fires on Edit link click', async () => { + const onEditBinding = jest.fn(); + const binding = createMockMCPAccessBinding({ + binding_id: 1, + endpoint_url: 'https://mcp.example.com/alpha', + }); + renderComponent({ bindings: [binding], onEditBinding }); + + await userEvent.click(screen.getByText('Edit')); + expect(onEditBinding).toHaveBeenCalledWith(binding); + }); + + it('delete callback fires on delete icon click', async () => { + const onDeleteBinding = jest.fn(); + const binding = createMockMCPAccessBinding({ + binding_id: 1, + endpoint_url: 'https://mcp.example.com/alpha', + }); + renderComponent({ bindings: [binding], onDeleteBinding }); + + const deleteButton = screen.getByRole('button', { name: 'Delete access binding' }); + await userEvent.click(deleteButton); + expect(onDeleteBinding).toHaveBeenCalledWith(binding); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx index c6c4c409e0e04..34cd7350e814e 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx @@ -12,7 +12,9 @@ import { import { FormattedMessage, useIntl } from 'react-intl'; import type { MCPAccessBinding, MCPServer } from '../types'; +import MCPRegistryRoutes from '../routes'; import { formatTransportType } from '../utils'; +import { useNavigate } from '../../common/utils/RoutingUtils'; import Utils from '../../common/utils/Utils'; const BindingCard = ({ @@ -28,12 +30,14 @@ const BindingCard = ({ }) => { const { theme } = useDesignSystemTheme(); const intl = useIntl(); + const navigate = useNavigate(); const target = binding.server_alias || binding.server_version || '—'; return ( navigate(MCPRegistryRoutes.getAccessBindingDetailRoute(binding.server_name, binding.binding_id))} dangerouslyAppendEmotionCSS={{ cursor: 'pointer', '&:hover': { @@ -42,35 +46,42 @@ const BindingCard = ({ }} >
-
+
{binding.endpoint_url} {formatTransportType(binding.transport_type)} -
- {onEditBinding && ( - onEditBinding(binding)} - > - - - )} - {onDeleteBinding && ( -
+ {(onEditBinding || onDeleteBinding) && ( +
e.stopPropagation()} + > + {onEditBinding && ( + onEditBinding(binding)} + > + + + )} + {onDeleteBinding && ( +
+ )}
{serverDescription && ( ; showEditAliasesModal?: (versionNumber: string) => void; onAddBinding?: () => void; + onEditBinding?: (binding: MCPAccessBinding) => void; + onDeleteBinding?: (binding: MCPAccessBinding) => void; }) => { const { theme } = useDesignSystemTheme(); const intl = useIntl(); @@ -257,6 +261,8 @@ export const MCPServerVersionDetail = ({ isLoading={bindingsLoading} error={bindingsError} onAddBinding={onAddBinding} + onEditBinding={onEditBinding} + onDeleteBinding={onDeleteBinding} /> (allowedTransitions[0]); + const [selectedStatus, setSelectedStatus] = useState(allowedTransitions[0]); useEffect(() => { if (visible) { @@ -53,7 +53,7 @@ export const UpdateVersionStatusModal = ({ } visible={visible} onCancel={onCancel} - onOk={() => onUpdate(selectedStatus)} + onOk={() => selectedStatus && onUpdate(selectedStatus)} okText={intl.formatMessage({ defaultMessage: 'Update', description: 'MCP server update version status modal confirm button', diff --git a/mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts b/mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts index 2d6d57c4b7613..76e20bf7f09fc 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts @@ -3,21 +3,27 @@ import { MCPRegistryApi } from '../api'; import type { CreateMCPAccessBindingRequest, MCPAccessBinding, UpdateMCPAccessBindingRequest } from '../types'; import { MCP_QUERY_KEYS } from '../utils'; -export const useCreateAccessBindingMutation = () => { +const useInvalidateBindingQueries = () => { const queryClient = useQueryClient(); + return (serverName: string) => { + queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDINGS_LIST]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_BINDINGS, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); + queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDING_DETAIL]); + }; +}; + +export const useCreateAccessBindingMutation = () => { + const invalidate = useInvalidateBindingQueries(); return useMutation({ mutationFn: ({ serverName, request }) => MCPRegistryApi.createMCPAccessBinding(serverName, request), - onSuccess: (_data, { serverName }) => { - queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDINGS_LIST]); - queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_BINDINGS, serverName]); - queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); - }, + onSuccess: (_data, { serverName }) => invalidate(serverName), }); }; export const useUpdateAccessBindingMutation = () => { - const queryClient = useQueryClient(); + const invalidate = useInvalidateBindingQueries(); return useMutation< MCPAccessBinding, @@ -26,10 +32,15 @@ export const useUpdateAccessBindingMutation = () => { >({ mutationFn: ({ serverName, bindingId, request }) => MCPRegistryApi.updateMCPAccessBinding(serverName, bindingId, request), - onSuccess: (_data, { serverName }) => { - queryClient.invalidateQueries([MCP_QUERY_KEYS.BINDINGS_LIST]); - queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER_BINDINGS, serverName]); - queryClient.invalidateQueries([MCP_QUERY_KEYS.SERVER, serverName]); - }, + onSuccess: (_data, { serverName }) => invalidate(serverName), + }); +}; + +export const useDeleteAccessBindingMutation = () => { + const invalidate = useInvalidateBindingQueries(); + + return useMutation({ + mutationFn: ({ serverName, bindingId }) => MCPRegistryApi.deleteMCPAccessBinding(serverName, bindingId), + onSuccess: (_data, { serverName }) => invalidate(serverName), }); }; diff --git a/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.test.tsx b/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.test.tsx new file mode 100644 index 0000000000000..2eeeb825b2eec --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.test.tsx @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { rest } from 'msw'; +import { IntlProvider } from 'react-intl'; +import { getAjaxUrl } from '@mlflow/mlflow/src/common/utils/FetchUtils'; +import { QueryClient, QueryClientProvider } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { setupServer } from '../../common/utils/setup-msw'; +import { useCursorPaginatedQuery } from './useCursorPaginatedQuery'; + +const BASE_URL = 'ajax-api/3.0/mlflow/test-endpoint'; + +interface TestResponse { + items: string[]; + next_page_token?: string; +} + +describe('useCursorPaginatedQuery', () => { + beforeEach(() => { + localStorage.clear(); + }); + + const mockServer = setupServer( + rest.get(getAjaxUrl(BASE_URL), (_req, res, ctx) => + res(ctx.json({ items: ['item-1', 'item-2'], next_page_token: 'page-2-token' })), + ), + ); + + const createWrapper = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + }; + + const defaultOptions = { + queryKeyPrefix: 'test_query', + storageKey: 'test.page_size', + queryFn: ({ + searchFilter, + pageToken, + pageSize, + }: { + searchFilter?: string; + pageToken?: string; + pageSize: number; + }) => { + const params = new URLSearchParams(); + if (searchFilter) params.set('filter', searchFilter); + if (pageToken) params.set('page_token', pageToken); + params.set('max_results', String(pageSize)); + return fetch(getAjaxUrl(`${BASE_URL}?${params.toString()}`)).then((r) => r.json()) as Promise; + }, + extractData: (response: TestResponse) => response.items, + }; + + it('returns first page of data', async () => { + const { result } = renderHook(() => useCursorPaginatedQuery(defaultOptions), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(['item-1', 'item-2']); + expect(result.current.hasNextPage).toBe(true); + expect(result.current.hasPreviousPage).toBe(false); + }); + + it('onNextPage advances the page token', async () => { + mockServer.use( + rest.get(getAjaxUrl(BASE_URL), (req, res, ctx) => { + const token = req.url.searchParams.get('page_token'); + if (token === 'page-2-token') { + return res(ctx.json({ items: ['page-2-item'], next_page_token: undefined })); + } + return res(ctx.json({ items: ['page-1-item'], next_page_token: 'page-2-token' })); + }), + ); + + const { result } = renderHook(() => useCursorPaginatedQuery(defaultOptions), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.data).toEqual(['page-1-item']); + + act(() => { + result.current.onNextPage(); + }); + + await waitFor(() => { + expect(result.current.data).toEqual(['page-2-item']); + }); + expect(result.current.hasPreviousPage).toBe(true); + expect(result.current.hasNextPage).toBe(false); + }); + + it('onPreviousPage goes back to previous token', async () => { + mockServer.use( + rest.get(getAjaxUrl(BASE_URL), (req, res, ctx) => { + const token = req.url.searchParams.get('page_token'); + if (token === 'page-2-token') { + return res(ctx.json({ items: ['page-2-item'], next_page_token: 'page-3-token' })); + } + return res(ctx.json({ items: ['page-1-item'], next_page_token: 'page-2-token' })); + }), + ); + + const { result } = renderHook(() => useCursorPaginatedQuery(defaultOptions), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data).toEqual(['page-1-item']); + }); + + act(() => { + result.current.onNextPage(); + }); + + await waitFor(() => { + expect(result.current.data).toEqual(['page-2-item']); + }); + + act(() => { + result.current.onPreviousPage(); + }); + + await waitFor(() => { + expect(result.current.data).toEqual(['page-1-item']); + }); + expect(result.current.hasPreviousPage).toBe(false); + }); + + it('filter change resets pagination', async () => { + const capturedTokens: (string | null)[] = []; + mockServer.use( + rest.get(getAjaxUrl(BASE_URL), (req, res, ctx) => { + capturedTokens.push(req.url.searchParams.get('page_token')); + return res(ctx.json({ items: ['item'], next_page_token: 'next' })); + }), + ); + + const { result, rerender } = renderHook( + ({ filter }: { filter?: string }) => useCursorPaginatedQuery({ ...defaultOptions, searchFilter: filter }), + { wrapper: createWrapper(), initialProps: { filter: undefined as string | undefined } }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Navigate to page 2 + act(() => { + result.current.onNextPage(); + }); + + await waitFor(() => { + expect(capturedTokens).toContain('next'); + }); + + // Change filter — should reset token + rerender({ filter: 'new-filter' }); + + await waitFor(() => { + const lastToken = capturedTokens[capturedTokens.length - 1]; + expect(lastToken).toBeNull(); + }); + expect(result.current.hasPreviousPage).toBe(false); + }); + + it('enabled=false prevents query from firing', async () => { + let requestCount = 0; + mockServer.use( + rest.get(getAjaxUrl(BASE_URL), (_req, res, ctx) => { + requestCount++; + return res(ctx.json({ items: ['item'], next_page_token: undefined })); + }), + ); + + const { result } = renderHook(() => useCursorPaginatedQuery({ ...defaultOptions, enabled: false }), { + wrapper: createWrapper(), + }); + + // Wait a tick to ensure no request fires + await new Promise((r) => setTimeout(r, 50)); + expect(result.current.data).toBeUndefined(); + expect(result.current.isLoading).toBe(true); + expect(requestCount).toBe(0); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts index 7e5198d79f287..6c0d5c13c226f 100644 --- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts +++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts @@ -1,6 +1,11 @@ import { useQuery } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; import { MCPRegistryApi } from '../api'; -import type { MCPServer, SearchMCPServerVersionsResponse, SearchMCPAccessBindingsResponse } from '../types'; +import type { + MCPAccessBinding, + MCPServer, + SearchMCPServerVersionsResponse, + SearchMCPAccessBindingsResponse, +} from '../types'; import { MCP_QUERY_KEYS } from '../utils'; export const useMCPServerQuery = (name: string) => { @@ -24,6 +29,14 @@ export const useMCPServerVersionsQuery = (name: string) => { }; }; +export const useMCPAccessBindingQuery = (serverName: string, bindingId: string) => { + return useQuery([MCP_QUERY_KEYS.BINDING_DETAIL, serverName, bindingId], { + queryFn: () => MCPRegistryApi.getMCPAccessBinding(serverName, Number(bindingId)), + retry: false, + enabled: Boolean(serverName && bindingId), + }); +}; + export const useMCPAccessBindingsQuery = (name: string) => { const queryResult = useQuery([MCP_QUERY_KEYS.SERVER_BINDINGS, name], { queryFn: () => MCPRegistryApi.searchMCPAccessBindings(name), diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.test.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.test.tsx new file mode 100644 index 0000000000000..ef1291cc85bc1 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect } from '@jest/globals'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { rest } from 'msw'; +import { IntlProvider } from 'react-intl'; +import { DesignSystemProvider } from '@databricks/design-system'; +import { QueryClient, QueryClientProvider } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; +import { getAjaxUrl } from '@mlflow/mlflow/src/common/utils/FetchUtils'; +import { testRoute, TestRouter } from '../../common/utils/RoutingTestUtils'; +import { setupServer } from '../../common/utils/setup-msw'; +import MCPAccessBindingDetailPage from './MCPAccessBindingDetailPage'; +import { + createMockMCPAccessBinding, + createMockMCPServerVersion, + getMockedGetMCPAccessBindingResponse, + getMockedGetMCPAccessBindingErrorResponse, + getMockedDeleteMCPAccessBindingResponse, + getMockedGetMCPServerResponse, + getMockedSearchMCPServerVersionsResponse, + getMockedSearchMCPServersResponse, + createMockMCPServer, +} from '../test-utils'; + +const mockBinding = createMockMCPAccessBinding({ + binding_id: 42, + server_name: 'io.test/server', + endpoint_url: 'https://mcp.example.com/fs', + transport_type: 'streamable-http', + server_version: '1', + server_alias: undefined, + creation_timestamp: 1717520552000, + last_updated_timestamp: 1717520999000, + resolved_version: createMockMCPServerVersion({ + name: 'io.test/server', + version: '1', + status: 'active', + server_json: { + name: 'io.test/server', + version: '1.0.0', + title: 'Test Server', + description: 'A test MCP server', + }, + }), +}); + +const defaultHandlers = [ + getMockedGetMCPAccessBindingResponse(mockBinding), + getMockedDeleteMCPAccessBindingResponse(), + getMockedGetMCPServerResponse(createMockMCPServer({ name: 'io.test/server' })), + getMockedSearchMCPServerVersionsResponse([]), + getMockedSearchMCPServersResponse([]), +]; + +describe('MCPAccessBindingDetailPage', () => { + const server = setupServer(...defaultHandlers); + + const renderPage = (initialEntries = ['/mcp-registry/io.test%2Fserver/bindings/42']) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(, { + wrapper: ({ children }) => ( + + + {children} + , + '/mcp-registry/:serverName/bindings/:bindingId', + ), + testRoute(
, '/mcp-registry'), + testRoute(
, '*'), + ]} + initialEntries={initialEntries} + /> + + ), + }); + }; + + it('renders metadata grid (endpoint URL, transport, MCP server link, version, timestamps)', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('https://mcp.example.com/fs')).toBeInTheDocument(); + }); + expect(screen.getByText('Endpoint URL:')).toBeInTheDocument(); + expect(screen.getByText('Streamable HTTP')).toBeInTheDocument(); + 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('Last updated:')).toBeInTheDocument(); + expect(screen.getByText('Created at:')).toBeInTheDocument(); + }); + + it('renders client configuration JSON block', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('Client configuration')).toBeInTheDocument(); + }); + }); + + it('shows loading spinner while fetching', () => { + // Use a delayed handler to keep the loading state + server.use( + rest.get(getAjaxUrl('ajax-api/3.0/mlflow/mcp-servers/:name/bindings/:bindingId'), (_req, res, ctx) => + res(ctx.delay('infinite'), ctx.json(mockBinding)), + ), + ); + renderPage(); + // The Databricks Spinner renders with this class + expect(document.querySelector('.du-bois-light-spin')).toBeInTheDocument(); + }); + + it('shows error alert when fetch fails', async () => { + server.use(getMockedGetMCPAccessBindingErrorResponse(500, 'Server error')); + renderPage(); + await waitFor(() => { + expect(screen.getByText('Failed to load access binding')).toBeInTheDocument(); + }); + }); + + it('opens edit modal when "Edit binding" clicked', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('https://mcp.example.com/fs')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Edit binding')); + await waitFor(() => { + expect(screen.getByText('Edit access binding')).toBeInTheDocument(); + }); + }); + + it('opens delete confirmation modal from overflow menu', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('https://mcp.example.com/fs')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: 'More actions' })); + const menuItem = await screen.findByRole('menuitem'); + await userEvent.click(menuItem); + await waitFor(() => { + expect( + screen.getByText('Are you sure you want to delete this access binding? This action cannot be undone.'), + ).toBeInTheDocument(); + }); + }); + + it('breadcrumb links to MCP Registry page', async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText('MCP Registry')).toBeInTheDocument(); + }); + const breadcrumbLink = screen.getByText('MCP Registry').closest('a'); + expect(breadcrumbLink?.getAttribute('href')).toContain('/mcp-registry'); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx new file mode 100644 index 0000000000000..fb28fc585f691 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/pages/MCPAccessBindingDetailPage.tsx @@ -0,0 +1,314 @@ +import { useMemo, useState } from 'react'; +import { + Alert, + Breadcrumb, + Button, + CopyIcon, + DropdownMenu, + Header, + OverflowIcon, + PencilIcon, + Tooltip, + Spacer, + Spinner, + Tag, + Typography, + useDesignSystemTheme, +} 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 { withErrorBoundary } from '../../common/utils/withErrorBoundary'; +import { copyToClipboard } from '../../common/utils/copyToClipboard'; +import ErrorUtils from '../../common/utils/ErrorUtils'; +import { ConfirmationModal } from '../../admin/ConfirmationModal'; +import { ShowArtifactCodeSnippet } from '../../experiment-tracking/components/artifact-view-components/ShowArtifactCodeSnippet'; +import MCPRegistryRoutes from '../routes'; +import { useMCPAccessBindingQuery } from '../hooks/useMCPServerDetailQuery'; +import { useDeleteAccessBindingMutation } from '../hooks/useAccessBindingMutation'; +import { AccessBindingModal } from '../components/AccessBindingModal'; +import { STATUS_TAG_COLOR, formatTransportType, resolveBindingDisplayName } from '../utils'; +import Utils from '../../common/utils/Utils'; + +const buildClientConfig = (serverName: string, endpointUrl: string, transportType: string) => + JSON.stringify( + { + mcpServers: { + [serverName]: { + url: endpointUrl, + type: transportType === 'streamable-http' ? 'http' : transportType, + }, + }, + }, + null, + 2, + ); + +const MCPAccessBindingDetailPage = () => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + const navigate = useNavigate(); + const params = useParams<{ serverName: string; bindingId: string }>(); + const serverName = decodeURIComponent(params.serverName ?? ''); + const bindingId = params.bindingId ?? ''; + const [editModalOpen, setEditModalOpen] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + + const { data: binding, isLoading, error, refetch } = useMCPAccessBindingQuery(serverName, bindingId); + + const deleteMutation = useDeleteAccessBindingMutation(); + + const clientConfig = useMemo( + () => (binding ? buildClientConfig(binding.server_name, binding.endpoint_url, binding.transport_type) : ''), + [binding], + ); + + const breadcrumbs = ( + + + + + + + + ); + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + if (error || !binding) { + return ( + + +
+ + } + description={error?.message} + closable={false} + /> + + ); + } + + const displayName = resolveBindingDisplayName(binding); + const description = binding.resolved_version?.server_json?.description; + const target = binding.server_alias || binding.server_version || '—'; + const versionStatus = binding.resolved_version?.status; + + return ( + + +
+ + + + + } + /> + +
+ {description && ( + <> + + + + {description} + + )} + + + + + + {binding.endpoint_url} + +
+ + + + + + + + setEditModalOpen(false)} + onSuccess={() => refetch()} + editBinding={binding} + lockedServer={binding.server_name} + /> + + + } + isLoading={deleteMutation.isLoading} + error={deleteMutation.error?.message ?? null} + onConfirm={() => { + deleteMutation.mutate( + { serverName: binding.server_name, bindingId: binding.binding_id }, + { + onSuccess: () => { + setDeleteModalVisible(false); + navigate(MCPRegistryRoutes.mcpRegistryPageRoute); + }, + }, + ); + }} + onCancel={() => { + deleteMutation.reset(); + setDeleteModalVisible(false); + }} + /> + + ); +}; + +export default withErrorBoundary(ErrorUtils.mlflowServices.EXPERIMENTS, MCPAccessBindingDetailPage); diff --git a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx index eb14587e765dc..3beef6117ab51 100644 --- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx +++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx @@ -13,6 +13,7 @@ import { Spacer, TableFilterInput, TableFilterLayout, + Typography, useDesignSystemTheme, } from '@databricks/design-system'; import type { RadioChangeEvent } from '@databricks/design-system'; @@ -284,6 +285,12 @@ const MCPRegistryPage = () => { } />
+ + + {bindingsError?.message && ( { const { theme } = useDesignSystemTheme(); const intl = useIntl(); const navigate = useNavigate(); - const { serverName = '' } = useParams<{ serverName: string }>(); + const params = useParams<{ serverName: string }>(); + 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 deleteServerMutation = useDeleteMCPServer(); + const deleteBindingMutation = useDeleteAccessBindingMutation(); const { data: server, @@ -263,13 +269,22 @@ const MCPServerDetailPage = () => { aliasesByVersion={aliasesByVersion} showEditAliasesModal={showEditAliasesModal} onAddBinding={() => setAddBindingModalOpen(true)} + onEditBinding={(binding) => { + setEditingBinding(binding); + setAddBindingModalOpen(true); + }} + onDeleteBinding={setDeletingBinding} />
{EditAliasesModal} setAddBindingModalOpen(false)} + onCancel={() => { + setEditingBinding(undefined); + setAddBindingModalOpen(false); + }} + editBinding={editingBinding} lockedServer={serverName} defaultVersion={currentVersion?.version} /> @@ -301,6 +316,33 @@ const MCPServerDetailPage = () => { setDeleteServerModalVisible(false); }} /> + + } + isLoading={deleteBindingMutation.isLoading} + error={deleteBindingMutation.error?.message ?? null} + onConfirm={() => { + if (!deletingBinding) return; + deleteBindingMutation.mutate( + { serverName: deletingBinding.server_name, bindingId: deletingBinding.binding_id }, + { onSuccess: () => setDeletingBinding(undefined) }, + ); + }} + onCancel={() => { + deleteBindingMutation.reset(); + setDeletingBinding(undefined); + }} + /> ); }; diff --git a/mlflow/server/js/src/mcp-registry/route-defs.ts b/mlflow/server/js/src/mcp-registry/route-defs.ts index 514ac9963c593..9b8948d7dc46f 100644 --- a/mlflow/server/js/src/mcp-registry/route-defs.ts +++ b/mlflow/server/js/src/mcp-registry/route-defs.ts @@ -18,5 +18,13 @@ export const getMCPRegistryRouteDefs = () => { getPageTitle: (params) => `MCP Server: ${params['serverName']}`, } satisfies DocumentTitleHandle, }, + { + path: MCPRegistryRoutePaths.mcpAccessBindingDetailPage, + element: createLazyRouteElement(() => import('./pages/MCPAccessBindingDetailPage')), + pageId: MCPRegistryPageId.mcpAccessBindingDetailPage, + handle: { + getPageTitle: () => 'Access Binding', + } satisfies DocumentTitleHandle, + }, ]; }; diff --git a/mlflow/server/js/src/mcp-registry/routes.ts b/mlflow/server/js/src/mcp-registry/routes.ts index 3ecc583dbba94..7eb614a678a6d 100644 --- a/mlflow/server/js/src/mcp-registry/routes.ts +++ b/mlflow/server/js/src/mcp-registry/routes.ts @@ -3,6 +3,7 @@ import { createMLflowRoutePath, generatePath } from '../common/utils/RoutingUtil export enum MCPRegistryPageId { mcpRegistryPage = 'mlflow.mcp-registry', mcpServerDetailPage = 'mlflow.mcp-registry.server-detail', + mcpAccessBindingDetailPage = 'mlflow.mcp-registry.binding-detail', } // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- TODO(FEINF-4274) @@ -14,6 +15,10 @@ export class MCPRegistryRoutePaths { static get mcpServerDetailPage() { return createMLflowRoutePath('/mcp-registry/:serverName'); } + + static get mcpAccessBindingDetailPage() { + return createMLflowRoutePath('/mcp-registry/:serverName/bindings/:bindingId'); + } } // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- TODO(FEINF-4274) @@ -31,6 +36,12 @@ class MCPRegistryRoutes { } return path; } + static getAccessBindingDetailRoute(serverName: string, bindingId: number) { + return generatePath(MCPRegistryRoutePaths.mcpAccessBindingDetailPage, { + serverName: encodeURIComponent(serverName), + bindingId: String(bindingId), + }); + } } export default MCPRegistryRoutes; diff --git a/mlflow/server/js/src/mcp-registry/test-utils.ts b/mlflow/server/js/src/mcp-registry/test-utils.ts index a12ad11c12984..b6c7bf6a18dc4 100644 --- a/mlflow/server/js/src/mcp-registry/test-utils.ts +++ b/mlflow/server/js/src/mcp-registry/test-utils.ts @@ -73,3 +73,14 @@ export const getMockedSearchMCPAccessBindingsAllResponse = (bindings: MCPAccessB rest.get(getAjaxUrl(`${BASE_URL}/bindings`), (_req, res, ctx) => res(ctx.json({ mcp_access_bindings: bindings, next_page_token: undefined })), ); + +export const getMockedGetMCPAccessBindingResponse = (binding: MCPAccessBinding) => + rest.get(getAjaxUrl(`${BASE_URL}/:name/bindings/:bindingId`), (_req, res, ctx) => res(ctx.json(binding))); + +export const getMockedGetMCPAccessBindingErrorResponse = (status = 404, message = 'Not found') => + rest.get(getAjaxUrl(`${BASE_URL}/:name/bindings/:bindingId`), (_req, res, ctx) => + res(ctx.status(status), ctx.json({ message })), + ); + +export const getMockedDeleteMCPAccessBindingResponse = () => + rest.delete(getAjaxUrl(`${BASE_URL}/:name/bindings/:bindingId`), (_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 76f70cb9eeadd..72f291f2e3fd7 100644 --- a/mlflow/server/js/src/mcp-registry/types.ts +++ b/mlflow/server/js/src/mcp-registry/types.ts @@ -17,7 +17,7 @@ export interface MCPTool { export interface MCPIcon { src: string; - sizes?: string; + sizes?: string[]; mimeType?: string; theme?: string; } diff --git a/mlflow/server/js/src/mcp-registry/utils.test.ts b/mlflow/server/js/src/mcp-registry/utils.test.ts index 6fba4c14b7fd0..57ae8873bc030 100644 --- a/mlflow/server/js/src/mcp-registry/utils.test.ts +++ b/mlflow/server/js/src/mcp-registry/utils.test.ts @@ -5,6 +5,7 @@ import { resolveBindingDisplayName, buildSearchFilterClause, formatTransportType, + isValidEndpointUrl, STATUS_TAG_COLOR, STATUS_TRANSITIONS, } from './utils'; @@ -142,3 +143,43 @@ describe('STATUS_TRANSITIONS', () => { expect(STATUS_TRANSITIONS.deleted).toEqual([]); }); }); + +describe('isValidEndpointUrl', () => { + it('accepts valid HTTPS URLs', () => { + expect(isValidEndpointUrl('https://test.com')).toBe(true); + expect(isValidEndpointUrl('https://mcp.example.com/server')).toBe(true); + expect(isValidEndpointUrl('https://mcp.internal.example.com/filesystem')).toBe(true); + }); + + it('accepts valid HTTP URLs', () => { + expect(isValidEndpointUrl('http://localhost:8080/path')).toBe(true); + expect(isValidEndpointUrl('http://192.168.1.1:3000')).toBe(true); + }); + + it('rejects URLs without double slashes', () => { + expect(isValidEndpointUrl('https:test.com')).toBe(false); + expect(isValidEndpointUrl('http:localhost')).toBe(false); + }); + + it('rejects non-HTTP schemes', () => { + expect(isValidEndpointUrl('ftp://test.com')).toBe(false); + expect(isValidEndpointUrl('ws://test.com')).toBe(false); + expect(isValidEndpointUrl('file:///etc/passwd')).toBe(false); + }); + + it('rejects non-URL strings', () => { + expect(isValidEndpointUrl('not-a-url')).toBe(false); + expect(isValidEndpointUrl('')).toBe(false); + expect(isValidEndpointUrl(' ')).toBe(false); + expect(isValidEndpointUrl('://missing-scheme.com')).toBe(false); + }); + + it('rejects URL with scheme only and no host', () => { + expect(isValidEndpointUrl('https://')).toBe(false); + expect(isValidEndpointUrl('http://')).toBe(false); + }); + + it('trims whitespace before validating', () => { + expect(isValidEndpointUrl(' https://test.com ')).toBe(true); + }); +}); diff --git a/mlflow/server/js/src/mcp-registry/utils.ts b/mlflow/server/js/src/mcp-registry/utils.ts index 60c2d77f21d0d..a0851752bfa04 100644 --- a/mlflow/server/js/src/mcp-registry/utils.ts +++ b/mlflow/server/js/src/mcp-registry/utils.ts @@ -37,6 +37,7 @@ export const MCP_QUERY_KEYS = { SERVER_VERSIONS: 'mcp_server_versions', SERVER_BINDINGS: 'mcp_server_bindings', BINDINGS_LIST: 'mcp_bindings_list', + BINDING_DETAIL: 'mcp_binding_detail', } as const; export const DEFAULT_PAGE_SIZE = 25; @@ -76,6 +77,16 @@ export const buildSearchFilterClause = (searchFilter: string | undefined, field: return `${field} ILIKE '%${searchFilter.replace(/'/g, "''")}%'`; }; +export const isValidEndpointUrl = (url: string): boolean => { + const trimmed = url.trim(); + if (!/^https?:\/\//.test(trimmed)) return false; + try { + return Boolean(new URL(trimmed).hostname); + } catch { + return false; + } +}; + export const formatTransportType = (transport: MCPRemoteTransportType): string => { return TRANSPORT_LABELS[transport] || transport; };