-
+
+
+
+
{displayName}
@@ -73,6 +53,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..102dc361bd632 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,15 +51,23 @@ export const MCPServerVersionDetail = ({
version,
bindings,
bindingsLoading,
+ bindingsError,
aliasesByVersion,
showEditAliasesModal,
+ onAddBinding,
+ onEditBinding,
+ onDeleteBinding,
}: {
server: MCPServer;
version?: MCPServerVersion;
bindings?: MCPAccessBinding[];
bindingsLoading?: boolean;
+ bindingsError?: Error | null;
aliasesByVersion: Record;
showEditAliasesModal?: (versionNumber: string) => void;
+ onAddBinding?: () => void;
+ onEditBinding?: (binding: MCPAccessBinding) => void;
+ onDeleteBinding?: (binding: MCPAccessBinding) => void;
}) => {
const { theme } = useDesignSystemTheme();
const intl = useIntl();
@@ -158,7 +166,7 @@ export const MCPServerVersionDetail = ({
- {displayName}
+ {resolveVersionDisplayName(version, displayName)}
@@ -247,13 +255,21 @@ export const MCPServerVersionDetail = ({
{version.server_json && }
-
+
{
updateStatusMutation.mutate(
{ version: version.version, status: newStatus },
@@ -281,7 +297,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/components/PaginatedCardGrid.tsx b/mlflow/server/js/src/mcp-registry/components/PaginatedCardGrid.tsx
new file mode 100644
index 0000000000000..af9b3880f01e5
--- /dev/null
+++ b/mlflow/server/js/src/mcp-registry/components/PaginatedCardGrid.tsx
@@ -0,0 +1,104 @@
+import type { CursorPaginationProps } from '@databricks/design-system';
+import { CursorPagination, Empty, NoIcon, Spinner, useDesignSystemTheme } from '@databricks/design-system';
+
+import { emptyCenterStyles } from '../utils';
+
+export const PaginatedCardGrid = ({
+ 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/components/UpdateVersionStatusModal.tsx b/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx
index a625f8edd37c1..51cabfb9893e7 100644
--- a/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx
+++ b/mlflow/server/js/src/mcp-registry/components/UpdateVersionStatusModal.tsx
@@ -33,7 +33,7 @@ export const UpdateVersionStatusModal = ({
const intl = useIntl();
const allowedTransitions = STATUS_TRANSITIONS[currentStatus] ?? [];
const isTerminal = allowedTransitions.length === 0;
- const [selectedStatus, setSelectedStatus] = useState(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
new file mode 100644
index 0000000000000..76e20bf7f09fc
--- /dev/null
+++ b/mlflow/server/js/src/mcp-registry/hooks/useAccessBindingMutation.ts
@@ -0,0 +1,46 @@
+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';
+
+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 }) => invalidate(serverName),
+ });
+};
+
+export const useUpdateAccessBindingMutation = () => {
+ const invalidate = useInvalidateBindingQueries();
+
+ return useMutation<
+ MCPAccessBinding,
+ Error,
+ { serverName: string; bindingId: number; request: UpdateMCPAccessBindingRequest }
+ >({
+ mutationFn: ({ serverName, bindingId, request }) =>
+ MCPRegistryApi.updateMCPAccessBinding(serverName, bindingId, request),
+ 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/useCursorPaginatedQuery.ts b/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts
new file mode 100644
index 0000000000000..a3a3d21faf6c7
--- /dev/null
+++ b/mlflow/server/js/src/mcp-registry/hooks/useCursorPaginatedQuery.ts
@@ -0,0 +1,84 @@
+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,
+ enabled,
+}: {
+ queryKeyPrefix: string;
+ searchFilter?: string;
+ storageKey: string;
+ queryFn: (params: { searchFilter?: string; pageToken?: string; pageSize: number }) => Promise;
+ extractData: (response: TResponse) => TData | undefined;
+ enabled?: boolean;
+}) => {
+ 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,
+ enabled,
+ },
+ );
+
+ 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..530086af1d26c
--- /dev/null
+++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPAccessBindingsListQuery.ts
@@ -0,0 +1,22 @@
+import { MCPRegistryApi } from '../api';
+import { MCP_QUERY_KEYS, buildSearchFilterClause } from '../utils';
+import { useCursorPaginatedQuery } from './useCursorPaginatedQuery';
+
+export const useMCPAccessBindingsListQuery = ({
+ searchFilter,
+ enabled,
+}: { searchFilter?: string; enabled?: boolean } = {}) => {
+ return useCursorPaginatedQuery({
+ queryKeyPrefix: MCP_QUERY_KEYS.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,
+ 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..6c0d5c13c226f 100644
--- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts
+++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerDetailQuery.ts
@@ -1,9 +1,15 @@
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) => {
- return useQuery(['mcp_server', name], {
+ return useQuery([MCP_QUERY_KEYS.SERVER, name], {
queryFn: () => MCPRegistryApi.getMCPServer(name),
retry: false,
enabled: Boolean(name),
@@ -11,7 +17,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),
@@ -23,8 +29,16 @@ 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_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 a9b0a67f1947e..b11fbb2b86a23 100644
--- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts
+++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServerVersionMutations.ts
@@ -1,41 +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_servers_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_servers_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_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 590f71a6a45c1..497b63fc39dac 100644
--- a/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts
+++ b/mlflow/server/js/src/mcp-registry/hooks/useMCPServersListQuery.ts
@@ -1,96 +1,22 @@
-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 { MCP_QUERY_KEYS, buildSearchFilterClause } from '../utils';
+import { useCursorPaginatedQuery } from './useCursorPaginatedQuery';
+
+export const useMCPServersListQuery = ({
+ searchFilter,
+ enabled,
+}: { searchFilter?: string; enabled?: boolean } = {}) => {
+ return useCursorPaginatedQuery({
+ queryKeyPrefix: MCP_QUERY_KEYS.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,
+ enabled,
});
};
-
-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,
- });
-
- 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/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/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 (
+
+
+
+
+
+ }
+ aria-label={intl.formatMessage({
+ defaultMessage: 'More actions',
+ description: 'Aria label for access binding detail actions overflow menu',
+ })}
+ />
+
+
+ setDeleteModalVisible(true)}
+ >
+
+
+
+
+ }
+ onClick={() => setEditModalOpen(true)}
+ >
+
+
+ >
+ }
+ />
+
+
+ {description && (
+ <>
+
+
+
+ {description}
+ >
+ )}
+
+
+
+
+
+ {binding.endpoint_url}
+
+ }
+ onClick={() => copyToClipboard(binding.endpoint_url)}
+ css={{ flexShrink: 0 }}
+ />
+
+
+
+
+
+
+ {formatTransportType(binding.transport_type)}
+
+
+
+
+
+ {binding.server_name}
+
+
+
+
+
+
+ {target}
+ {versionStatus && (
+
+ {versionStatus}
+
+ )}
+
+
+
+
+
+
+ {binding.last_updated_timestamp ? Utils.formatTimestamp(binding.last_updated_timestamp, intl) : '—'}
+
+
+
+
+
+ {binding.last_updated_by || '—'}
+
+
+
+
+
+ {binding.creation_timestamp ? Utils.formatTimestamp(binding.creation_timestamp, intl) : '—'}
+
+
+
+
+
+ {binding.created_by || '—'}
+
+
+
+
+
+
+
+
+ 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.test.tsx b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.test.tsx
index 2af95a3755bec..9033dff7c55bd 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('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('renders empty state when no servers exist', async () => {
+ 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,66 @@ 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('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(() => {
+ 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..3beef6117ab51 100644
--- a/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx
+++ b/mlflow/server/js/src/mcp-registry/pages/MCPRegistryPage.tsx
@@ -11,11 +11,9 @@ import {
SegmentedControlGroup,
WrenchIcon,
Spacer,
- Table,
- TableHeader,
- TableRow,
TableFilterInput,
TableFilterLayout,
+ Typography,
useDesignSystemTheme,
} from '@databricks/design-system';
import type { RadioChangeEvent } from '@databricks/design-system';
@@ -27,8 +25,14 @@ 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 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';
@@ -39,9 +43,11 @@ 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 [bindingModalOpen, setBindingModalOpen] = useState(false);
+ const [editingBinding, setEditingBinding] = useState(undefined);
const [debouncedSearchFilter] = useDebounce(searchFilter, 500);
const effectiveFilter = searchFilter ? debouncedSearchFilter : undefined;
@@ -54,14 +60,30 @@ 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,
+ enabled: activeTab === 'bindings',
+ });
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);
@@ -71,12 +93,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 = (
@@ -133,12 +168,12 @@ const MCPRegistryPage = () => {
onChange={handleTabChange}
componentId="mlflow.mcp_registry.tabs"
>
-
-
-
+
+
+
{activeTab === 'servers' && (
@@ -186,7 +221,7 @@ const MCPRegistryPage = () => {
/>
)}
{viewMode === 'grid' ? (
- isEmptyState ? (
+ isServersEmpty ? (
serversEmptyState
) : (
{
)}
{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 && (
+
+ )}
+ {isServersEmpty && viewMode === 'grid' ? (
+
+
+ }
+ description={
+
+ }
+ button={
+ }
+ disabled
+ >
- }
- button={
- }
- disabled
- >
+
+ }
+ />
+
+ ) : viewMode === 'grid' ? (
+ {
+ setEditingBinding(undefined);
+ setBindingModalOpen(true);
+ }}
+ />
+ ) : (
+ {
+ setEditingBinding(undefined);
+ setBindingModalOpen(true);
+ }}
+ onEditBinding={(binding) => {
+ setEditingBinding(binding);
+ setBindingModalOpen(true);
+ }}
+ emptyStateOverride={
+ isServersEmpty ? (
+
-
- }
- />
-
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
+ }
+ description={
+
+ }
+ button={
+
}
+ disabled
+ >
+
+
+ }
+ />
+ ) : undefined
+ }
+ />
+ )}
+
)}
+ {
+ 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 4d15de5036bf6..02f38f3e6f4d2 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,
@@ -30,8 +30,12 @@ import {
useMCPAccessBindingsQuery,
} from '../hooks/useMCPServerDetailQuery';
import { useDeleteMCPServer } from '../hooks/useMCPServerVersionMutations';
+import { useDeleteAccessBindingMutation } from '../hooks/useAccessBindingMutation';
+import type { MCPAccessBinding } from '../types';
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) => (
@@ -46,9 +50,14 @@ const MCPServerDetailPage = () => {
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,
@@ -59,25 +68,19 @@ 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);
+ 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 }) => {
@@ -228,15 +231,24 @@ const MCPServerDetailPage = () => {
-
+ {versionsError ? (
+
+ ) : (
+
+ )}
{
version={currentVersion}
bindings={bindings}
bindingsLoading={bindingsLoading}
+ bindingsError={bindingsError}
aliasesByVersion={aliasesByVersion}
showEditAliasesModal={showEditAliasesModal}
+ onAddBinding={() => setAddBindingModalOpen(true)}
+ onEditBinding={(binding) => {
+ setEditingBinding(binding);
+ setAddBindingModalOpen(true);
+ }}
+ onDeleteBinding={setDeletingBinding}
/>
{EditAliasesModal}
+