diff --git a/.github/actions/check-component-ids/componentId-registry.js b/.github/actions/check-component-ids/componentId-registry.js index f5d60c72e0c30..9925be980e080 100644 --- a/.github/actions/check-component-ids/componentId-registry.js +++ b/.github/actions/check-component-ids/componentId-registry.js @@ -1837,24 +1837,52 @@ 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.binding_detail.actions": "", + "mlflow.mcp_registry.binding_detail.actions.delete": "", + "mlflow.mcp_registry.binding_detail.breadcrumb_back": "", + "mlflow.mcp_registry.binding_detail.copy_endpoint": "", + "mlflow.mcp_registry.binding_detail.copy_tooltip": "", + "mlflow.mcp_registry.binding_detail.delete_modal": "", + "mlflow.mcp_registry.binding_detail.edit": "", + "mlflow.mcp_registry.binding_detail.error": "", + "mlflow.mcp_registry.binding_detail.server_link": "", + "mlflow.mcp_registry.binding_detail.version_status": "", + "mlflow.mcp_registry.binding_modal": "", + "mlflow.mcp_registry.binding_modal.endpoint": "", + "mlflow.mcp_registry.binding_modal.error": "", + "mlflow.mcp_registry.binding_modal.server": "", + "mlflow.mcp_registry.binding_modal.target": "", + "mlflow.mcp_registry.binding_modal.transport": "", + "mlflow.mcp_registry.bindings.card": "", + "mlflow.mcp_registry.bindings.empty_state.create_server": "", + "mlflow.mcp_registry.bindings.error": "", + "mlflow.mcp_registry.bindings.grid.empty_state.create": "", + "mlflow.mcp_registry.bindings.list.empty_state.create_server": "", "mlflow.mcp_registry.bindings.search": "", - "mlflow.mcp_registry.card.link": "", + "mlflow.mcp_registry.bindings.table.copy_endpoint": "", + "mlflow.mcp_registry.bindings.table.copy_tooltip": "", + "mlflow.mcp_registry.bindings.table.edit_link": "", + "mlflow.mcp_registry.bindings.table.empty_state.create": "", + "mlflow.mcp_registry.bindings.table.endpoint_link": "", + "mlflow.mcp_registry.bindings.table.header": "", + "mlflow.mcp_registry.bindings.table.pagination": "", + "mlflow.mcp_registry.bindings.table.server_link": "", + "mlflow.mcp_registry.bindings.view_toggle": "", + "mlflow.mcp_registry.card": "", + "mlflow.mcp_registry.card_grid.pagination": "", + "mlflow.mcp_registry.create_binding_button": "", "mlflow.mcp_registry.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": "", + "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": "", @@ -1871,11 +1899,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..d04c5c604b6ac 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" @@ -135,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" @@ -155,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" @@ -251,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" @@ -511,6 +527,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" @@ -523,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" @@ -679,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" @@ -755,6 +783,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" @@ -863,6 +895,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" @@ -871,6 +907,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" @@ -951,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" @@ -1015,6 +1059,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" @@ -1363,6 +1411,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" @@ -1371,10 +1423,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" @@ -1403,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" @@ -1547,6 +1599,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" @@ -1623,6 +1679,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" @@ -1631,10 +1691,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" @@ -1747,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" @@ -2219,6 +2279,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" @@ -2335,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" @@ -2627,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" @@ -2743,6 +2815,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" @@ -2935,6 +3011,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" @@ -3095,6 +3175,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" @@ -3247,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" @@ -3347,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" @@ -3467,6 +3559,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" @@ -3583,10 +3679,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" @@ -3823,6 +3915,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" @@ -3835,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" @@ -4971,6 +5071,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" @@ -4983,10 +5087,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" @@ -5131,6 +5231,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" @@ -5283,6 +5387,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" @@ -5311,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" @@ -5351,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" @@ -5395,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" @@ -5447,6 +5567,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 +5695,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" @@ -5959,6 +6087,10 @@ "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" @@ -6203,10 +6335,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" @@ -6331,10 +6459,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" @@ -6551,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" @@ -7223,10 +7351,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" @@ -7467,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" @@ -7491,6 +7619,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 +7687,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 +7847,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" @@ -8015,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" @@ -8079,6 +8215,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" @@ -8107,10 +8247,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" @@ -8159,6 +8295,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)" @@ -8635,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" @@ -8723,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" @@ -9035,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" @@ -9199,10 +9351,18 @@ "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" }, + "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 +9703,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", @@ -9715,6 +9875,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" @@ -9763,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)" @@ -9803,6 +9971,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." @@ -10083,6 +10255,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" @@ -10111,14 +10287,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." @@ -10295,6 +10471,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" @@ -10303,6 +10483,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 +10791,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" @@ -11063,6 +11251,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" @@ -11159,6 +11351,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\"" @@ -11199,10 +11395,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 +11567,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/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 new file mode 100644 index 0000000000000..64d024c3067e9 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/AccessBindingModal.tsx @@ -0,0 +1,309 @@ +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 { isValidEndpointUrl, 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 `${ALIAS_PREFIX}latest`; +} + +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 isValidUrl = isValidEndpointUrl(endpointUrl); + const isFormValid = Boolean(selectedServer && isValidUrl && 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', + })} + validationState={endpointUrl.trim() && !isValidUrl ? 'error' : undefined} + /> + {endpointUrl.trim() && !isValidUrl && ( + + + + )} +
+ +
+ + + + 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.test.tsx b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx new file mode 100644 index 0000000000000..300d47c54320a --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.test.tsx @@ -0,0 +1,91 @@ +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'; + +const renderCard = (binding: MCPAccessBinding) => + render( + + + + , + '/', + ), + testRoute(
, '*'), + ]} + /> + , + ); + +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..49bfa265dafe9 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCard.tsx @@ -0,0 +1,60 @@ +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'; + +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..a95bc9ecc800c --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingCardGrid.tsx @@ -0,0 +1,83 @@ +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, + onCreateBinding, +}: { + bindings?: MCPAccessBinding[]; + isLoading?: boolean; + isFiltered?: boolean; + hasNextPage: boolean; + hasPreviousPage: boolean; + onNextPage: () => void; + onPreviousPage: () => void; + pageSizeSelect?: CursorPaginationProps['pageSizeSelect']; + onCreateBinding?: () => void; +}) => ( + + } + 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..1338de35dfe9c --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.test.tsx @@ -0,0 +1,163 @@ +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('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 }); + 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..fa59764e0a01e --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/components/MCPAccessBindingListTable.tsx @@ -0,0 +1,289 @@ +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, + Tooltip, + 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 { copyToClipboard } from '../../common/utils/copyToClipboard'; +import Utils from '../../common/utils/Utils'; + +const EndpointCell: ColumnDef['cell'] = ({ row: { original } }) => { + const { theme } = useDesignSystemTheme(); + const intl = useIntl(); + return ( + + + + } + /> +
+ ); + } + return null; + }; + + return ( + + } + empty={getEmptyState()} + > + + {table.getLeafHeaders().map((header) => { + const flex = (header.column.columnDef.meta as { flex?: number } | undefined)?.flex; + return ( + + {flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + {isLoading ? ( + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => { + const flex = (cell.column.columnDef.meta as { flex?: number } | undefined)?.flex; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )) + )} +
+ ); +}; 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 3fc320497330d..34cd7350e814e 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerAccessBindings.tsx @@ -1,28 +1,134 @@ import { + Alert, Button, + Card, PlusIcon, Spinner, - Table, - TableCell, - TableHeader, - TableRow, + Tag, + TrashIcon, Typography, useDesignSystemTheme, } from '@databricks/design-system'; import { FormattedMessage, useIntl } from 'react-intl'; -import type { MCPAccessBinding } from '../types'; +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 = ({ + binding, + serverDescription, + onEditBinding, + onDeleteBinding, +}: { + binding: MCPAccessBinding; + serverDescription?: string; + onEditBinding?: (binding: MCPAccessBinding) => void; + onDeleteBinding?: (binding: MCPAccessBinding) => void; +}) => { + 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': { + background: theme.colors.actionDefaultBackgroundHover, + }, + }} + > +
+
+ + {binding.endpoint_url} + + + {formatTransportType(binding.transport_type)} + + {(onEditBinding || onDeleteBinding) && ( +
e.stopPropagation()} + > + {onEditBinding && ( + onEditBinding(binding)} + > + + + )} + {onDeleteBinding && ( +
+ )} +
+ {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 (
@@ -30,12 +136,24 @@ export const MCPServerAccessBindings = ({ -
- {isLoading ? ( + {error ? ( + + ) : isLoading ? (
@@ -47,41 +165,17 @@ export const MCPServerAccessBindings = ({ /> ) : ( - - - - - - - - - - - - - - - +
{bindings.map((binding) => ( - - {binding.endpoint_url} - {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..d6589c6dac428 100644 --- a/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx +++ b/mlflow/server/js/src/mcp-registry/components/MCPServerCard.tsx @@ -1,10 +1,10 @@ -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 { CardIconWrapper } from './CardIconWrapper'; import Utils from '../../common/utils/Utils'; export const MCPServerCard = ({ server }: { server: MCPServer }) => { @@ -17,36 +17,16 @@ export const MCPServerCard = ({ server }: { server: MCPServer }) => { : undefined; return ( - -
- +
+ + +
{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 ( + + +
+ + + + + } + /> + +
+ {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.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={ + + } + /> +
+ ) : viewMode === 'grid' ? ( + { + setEditingBinding(undefined); + setBindingModalOpen(true); + }} + /> + ) : ( + { + setEditingBinding(undefined); + setBindingModalOpen(true); + }} + onEditBinding={(binding) => { + setEditingBinding(binding); + setBindingModalOpen(true); + }} + emptyStateOverride={ + isServersEmpty ? ( + - - } - /> - - } - > - - - - - - - - - - - - - - - - - -
- + } + description={ + + } + button={ + + } + /> + ) : 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} + { + setEditingBinding(undefined); + setAddBindingModalOpen(false); + }} + editBinding={editingBinding} + 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: () => { @@ -287,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 8a6a734e18981..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) @@ -22,9 +27,19 @@ 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; + } + static getAccessBindingDetailRoute(serverName: string, bindingId: number) { + return generatePath(MCPRegistryRoutePaths.mcpAccessBindingDetailPage, { serverName: encodeURIComponent(serverName), + bindingId: String(bindingId), }); } } diff --git a/mlflow/server/js/src/mcp-registry/test-utils.ts b/mlflow/server/js/src/mcp-registry/test-utils.ts index 7cec63091af75..b6c7bf6a18dc4 100644 --- a/mlflow/server/js/src/mcp-registry/test-utils.ts +++ b/mlflow/server/js/src/mcp-registry/test-utils.ts @@ -68,3 +68,19 @@ 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 })), + ); + +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 new file mode 100644 index 0000000000000..57ae8873bc030 --- /dev/null +++ b/mlflow/server/js/src/mcp-registry/utils.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from '@jest/globals'; +import { + resolveDisplayName, + resolveVersionDisplayName, + resolveBindingDisplayName, + buildSearchFilterClause, + formatTransportType, + isValidEndpointUrl, + 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([]); + }); +}); + +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 f65f537adee84..a0851752bfa04 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,78 @@ 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 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', + BINDING_DETAIL: 'mcp_binding_detail', +} as const; + +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 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; +};