Skip to content

Commit a7b9975

Browse files
authored
Add deploy MCP server button as an extension (opendatahub-io#6811)
* Add new endpoint to detect mcp server CRD Signed-off-by: ppadti <ppadti@redhat.com> * Add deploy mcp server button as an extension Signed-off-by: ppadti <ppadti@redhat.com> * Add cypress tests Signed-off-by: ppadti <ppadti@redhat.com> * Address review comments Signed-off-by: ppadti <ppadti@redhat.com> --------- Signed-off-by: ppadti <ppadti@redhat.com>
1 parent 2322243 commit a7b9975

File tree

17 files changed

+509
-9
lines changed

17 files changed

+509
-9
lines changed

packages/model-registry/upstream/bff/internal/api/app.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,10 @@ const (
9494
KubernetesServicesListPath = SettingsPath + "/services"
9595

9696
// MCPServer deployment endpoints (downstream-only implementations)
97-
McpDeploymentName = "mcp_deployment_name"
98-
McpDeploymentListPath = ApiPathPrefix + "/mcp_deployments"
99-
McpDeploymentPath = McpDeploymentListPath + "/:" + McpDeploymentName
97+
McpDeploymentName = "mcp_deployment_name"
98+
McpDeploymentListPath = ApiPathPrefix + "/mcp_deployments"
99+
McpDeploymentPath = McpDeploymentListPath + "/:" + McpDeploymentName
100+
McpServerAvailabilityPath = McpServerCatalogPathPrefix + "/mcp_server_available"
100101
)
101102

102103
const (
@@ -111,8 +112,9 @@ const (
111112
handlerKubernetesServicesListID HandlerID = "kubernetes:services:list"
112113

113114
// MCPServer deployment handlers - downstream-only
114-
handlerMcpDeploymentListID HandlerID = "mcpDeployment:list"
115-
handlerMcpDeploymentDeleteID HandlerID = "mcpDeployment:delete"
115+
handlerMcpDeploymentListID HandlerID = "mcpDeployment:list"
116+
handlerMcpDeploymentDeleteID HandlerID = "mcpDeployment:delete"
117+
handlerMcpServerAvailabilityID HandlerID = "mcpServer:availability"
116118
)
117119

118120
type App struct {
@@ -366,6 +368,12 @@ func (app *App) Routes() http.Handler {
366368
return app.AttachNamespace(app.EndpointNotImplementedHandler("MCP deployment delete"))
367369
}),
368370
)
371+
apiRouter.GET(
372+
McpServerAvailabilityPath,
373+
app.handlerWithOverride(handlerMcpServerAvailabilityID, func() httprouter.Handle {
374+
return app.EndpointNotImplementedHandler("MCP server availability")
375+
}),
376+
)
369377

370378
//SettingsPath: Certificate endpoints
371379
apiRouter.GET(CertificatesPath, app.AttachNamespace(app.GetCertificatesHandler))

packages/model-registry/upstream/bff/internal/mocks/static_data_mock.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3219,4 +3219,3 @@ func GetMcpDeploymentMocks() []models.McpDeployment {
32193219
},
32203220
}
32213221
}
3222-
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"github.com/julienschmidt/httprouter"
8+
9+
"github.com/kubeflow/model-registry/ui/bff/internal/api"
10+
k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations/kubernetes"
11+
redhatrepos "github.com/kubeflow/model-registry/ui/bff/internal/redhat/repositories"
12+
)
13+
14+
type McpServerAvailabilityEnvelope api.Envelope[McpServerAvailabilityResponse, api.None]
15+
16+
type McpServerAvailabilityResponse struct {
17+
Available bool `json:"available"`
18+
}
19+
20+
type mcpServerAvailabilityChecker interface {
21+
IsMcpServerCRDAvailable(ctx context.Context, client k8s.KubernetesClientInterface) (bool, error)
22+
}
23+
24+
var newMcpServerAvailabilityRepo = func(app *api.App) mcpServerAvailabilityChecker {
25+
return redhatrepos.NewMcpServerAvailabilityRepository(app.Logger())
26+
}
27+
28+
const (
29+
mcpServerAvailabilityHandlerID = api.HandlerID("mcpServer:availability")
30+
)
31+
32+
func init() {
33+
api.RegisterHandlerOverride(mcpServerAvailabilityHandlerID, overrideMcpServerAvailability)
34+
}
35+
36+
func overrideMcpServerAvailability(app *api.App, buildDefault func() httprouter.Handle) httprouter.Handle {
37+
if !shouldUseRedHatOverrides(app) {
38+
return buildDefault()
39+
}
40+
41+
repo := newMcpServerAvailabilityRepo(app)
42+
43+
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
44+
client, ok := getKubernetesClient(app, w, r)
45+
if !ok {
46+
return
47+
}
48+
49+
available, err := repo.IsMcpServerCRDAvailable(r.Context(), client)
50+
if err != nil {
51+
app.ServerError(w, r, err)
52+
return
53+
}
54+
55+
resp := McpServerAvailabilityEnvelope{
56+
Data: McpServerAvailabilityResponse{Available: available},
57+
}
58+
if err := app.WriteJSON(w, http.StatusOK, resp, nil); err != nil {
59+
app.ServerError(w, r, err)
60+
}
61+
}
62+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/julienschmidt/httprouter"
11+
12+
"github.com/kubeflow/model-registry/ui/bff/internal/api"
13+
"github.com/kubeflow/model-registry/ui/bff/internal/config"
14+
k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations/kubernetes"
15+
)
16+
17+
type mockMcpServerAvailabilityRepo struct {
18+
availableFn func(ctx context.Context, client k8s.KubernetesClientInterface) (bool, error)
19+
}
20+
21+
func (m *mockMcpServerAvailabilityRepo) IsMcpServerCRDAvailable(ctx context.Context, client k8s.KubernetesClientInterface) (bool, error) {
22+
if m.availableFn == nil {
23+
return false, errors.New("not implemented")
24+
}
25+
return m.availableFn(ctx, client)
26+
}
27+
28+
func withMcpServerAvailabilityRepo(t *testing.T, repo mcpServerAvailabilityChecker) {
29+
t.Helper()
30+
original := newMcpServerAvailabilityRepo
31+
newMcpServerAvailabilityRepo = func(*api.App) mcpServerAvailabilityChecker {
32+
return repo
33+
}
34+
t.Cleanup(func() {
35+
newMcpServerAvailabilityRepo = original
36+
})
37+
}
38+
39+
func TestOverrideMcpServerAvailability_CRDPresent(t *testing.T) {
40+
factory := &fakeKubeFactory{}
41+
app := newRedHatTestApp(factory)
42+
43+
repo := &mockMcpServerAvailabilityRepo{
44+
availableFn: func(ctx context.Context, client k8s.KubernetesClientInterface) (bool, error) {
45+
return true, nil
46+
},
47+
}
48+
49+
withMcpServerAvailabilityRepo(t, repo)
50+
51+
handler := overrideMcpServerAvailability(app, failDefault(t))
52+
53+
req := httptest.NewRequest(http.MethodGet, api.McpServerAvailabilityPath, nil)
54+
rr := httptest.NewRecorder()
55+
56+
handler(rr, req, nil)
57+
58+
if rr.Code != http.StatusOK {
59+
t.Fatalf("expected status 200, got %d", rr.Code)
60+
}
61+
62+
var resp McpServerAvailabilityEnvelope
63+
decodeResponse(t, rr, &resp)
64+
65+
if !resp.Data.Available {
66+
t.Fatalf("expected available=true, got false")
67+
}
68+
}
69+
70+
func TestOverrideMcpServerAvailability_CRDNotPresent(t *testing.T) {
71+
factory := &fakeKubeFactory{}
72+
app := newRedHatTestApp(factory)
73+
74+
repo := &mockMcpServerAvailabilityRepo{
75+
availableFn: func(ctx context.Context, client k8s.KubernetesClientInterface) (bool, error) {
76+
return false, nil
77+
},
78+
}
79+
80+
withMcpServerAvailabilityRepo(t, repo)
81+
82+
handler := overrideMcpServerAvailability(app, failDefault(t))
83+
84+
req := httptest.NewRequest(http.MethodGet, api.McpServerAvailabilityPath, nil)
85+
rr := httptest.NewRecorder()
86+
87+
handler(rr, req, nil)
88+
89+
if rr.Code != http.StatusOK {
90+
t.Fatalf("expected status 200, got %d", rr.Code)
91+
}
92+
93+
var resp McpServerAvailabilityEnvelope
94+
decodeResponse(t, rr, &resp)
95+
96+
if resp.Data.Available {
97+
t.Fatalf("expected available=false, got true")
98+
}
99+
}
100+
101+
func TestOverrideMcpServerAvailability_RepoError(t *testing.T) {
102+
factory := &fakeKubeFactory{}
103+
app := newRedHatTestApp(factory)
104+
105+
repo := &mockMcpServerAvailabilityRepo{
106+
availableFn: func(ctx context.Context, client k8s.KubernetesClientInterface) (bool, error) {
107+
return false, errors.New("discovery failed")
108+
},
109+
}
110+
111+
withMcpServerAvailabilityRepo(t, repo)
112+
113+
handler := overrideMcpServerAvailability(app, failDefault(t))
114+
115+
req := httptest.NewRequest(http.MethodGet, api.McpServerAvailabilityPath, nil)
116+
rr := httptest.NewRecorder()
117+
118+
handler(rr, req, nil)
119+
120+
if rr.Code != http.StatusInternalServerError {
121+
t.Fatalf("expected status 500, got %d", rr.Code)
122+
}
123+
}
124+
125+
func TestOverrideMcpServerAvailability_MockModeFallsBack(t *testing.T) {
126+
factory := &fakeKubeFactory{}
127+
app := newMockModeTestApp(factory)
128+
129+
defaultCalled := false
130+
buildDefault := func() httprouter.Handle {
131+
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
132+
defaultCalled = true
133+
w.WriteHeader(http.StatusNotImplemented)
134+
}
135+
}
136+
137+
handler := overrideMcpServerAvailability(app, buildDefault)
138+
139+
req := httptest.NewRequest(http.MethodGet, api.McpServerAvailabilityPath, nil)
140+
rr := httptest.NewRecorder()
141+
142+
handler(rr, req, nil)
143+
144+
if !defaultCalled {
145+
t.Fatalf("expected default handler to be invoked in mock mode")
146+
}
147+
if rr.Code != http.StatusNotImplemented {
148+
t.Fatalf("expected status 501, got %d", rr.Code)
149+
}
150+
}
151+
152+
func newMockModeTestApp(factory k8s.KubernetesClientFactory) *api.App {
153+
cfg := config.EnvConfig{AuthMethod: config.AuthMethodUser, MockK8Client: true}
154+
return api.NewTestApp(cfg, noopLogger(), factory, nil)
155+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package repositories
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
8+
k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations/kubernetes"
9+
"k8s.io/client-go/discovery"
10+
)
11+
12+
const (
13+
mcpServerGroup = "mcp.x-k8s.io"
14+
mcpServerVersion = "v1alpha1"
15+
mcpServerResource = "mcpservers"
16+
)
17+
18+
type McpServerAvailabilityRepository struct {
19+
logger *slog.Logger
20+
}
21+
22+
func NewMcpServerAvailabilityRepository(logger *slog.Logger) *McpServerAvailabilityRepository {
23+
return &McpServerAvailabilityRepository{logger: logger}
24+
}
25+
26+
// IsMcpServerCRDAvailable checks whether the MCPServer CRD (mcp.x-k8s.io/v1alpha1/mcpservers) is installed on the cluster.
27+
func (r *McpServerAvailabilityRepository) IsMcpServerCRDAvailable(ctx context.Context, client k8s.KubernetesClientInterface) (bool, error) {
28+
cfg, err := restConfigForClient(client)
29+
if err != nil {
30+
return false, fmt.Errorf("failed to get REST config: %w", err)
31+
}
32+
33+
disco, err := discovery.NewDiscoveryClientForConfig(cfg)
34+
if err != nil {
35+
return false, fmt.Errorf("failed to create discovery client: %w", err)
36+
}
37+
38+
resourceList, err := disco.ServerResourcesForGroupVersion(fmt.Sprintf("%s/%s", mcpServerGroup, mcpServerVersion))
39+
if err != nil {
40+
if r.logger != nil {
41+
r.logger.DebugContext(ctx, "MCPServer CRD not found on cluster", "group", mcpServerGroup, "version", mcpServerVersion, "error", err)
42+
}
43+
return false, nil
44+
}
45+
46+
for _, resource := range resourceList.APIResources {
47+
if resource.Name == mcpServerResource {
48+
return true, nil
49+
}
50+
}
51+
52+
return false, nil
53+
}

packages/model-registry/upstream/frontend/src/__tests__/cypress/cypress/pages/mcpCatalog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ class McpServerDetails {
9999
}
100100

101101
findDeployButton() {
102-
return cy.findByTestId('deploy-mcp-server-button');
102+
return cy.findByTestId('mcp-deploy-button');
103103
}
104104

105105
findDescription() {

packages/model-registry/upstream/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpCatalogTestUtils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,12 @@ export const initServerToolsErrorIntercept = (serverId: string): void => {
133133
{ statusCode: 500, body: { error: 'Internal server error' } },
134134
);
135135
};
136+
137+
const MCP_SERVER_AVAILABLE_PATH = `/model-registry/api/${MODEL_CATALOG_API_VERSION}/mcp_catalog/mcp_server_available`;
138+
139+
export const initMcpServerAvailabilityIntercept = (available: boolean): void => {
140+
cy.intercept(
141+
{ method: 'GET', pathname: MCP_SERVER_AVAILABLE_PATH },
142+
mockModArchResponse({ available }),
143+
);
144+
};

packages/model-registry/upstream/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpServerDetails.cy.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
initServerDetailIntercept,
77
initServerToolsIntercept,
88
initServerToolsErrorIntercept,
9+
initMcpServerAvailabilityIntercept,
910
mockMcpToolWithServer,
1011
mockMcpToolList,
1112
} from './mcpCatalogTestUtils';
@@ -156,6 +157,40 @@ describe('MCP Server Details Page', () => {
156157
});
157158
});
158159

160+
describe('Deploy button', () => {
161+
it('should show enabled deploy button when MCP server CRD is available', () => {
162+
initServerDetailIntercept(kubernetesServer);
163+
initMcpServerAvailabilityIntercept(true);
164+
mcpServerDetails.visit(kubernetesServer.id);
165+
mcpServerDetails.findDeployButton().should('be.visible');
166+
mcpServerDetails.findDeployButton().should('not.be.disabled');
167+
mcpServerDetails.findDeployButton().should('contain.text', 'Deploy MCP server');
168+
});
169+
170+
it('should show disabled deploy button when MCP server CRD is not available', () => {
171+
initServerDetailIntercept(kubernetesServer);
172+
initMcpServerAvailabilityIntercept(false);
173+
mcpServerDetails.visit(kubernetesServer.id);
174+
mcpServerDetails.findDeployButton().should('be.visible');
175+
mcpServerDetails.findDeployButton().should('have.attr', 'aria-disabled', 'true');
176+
});
177+
178+
it('should not show deploy button when server is not found', () => {
179+
cy.intercept(
180+
{ method: 'GET', url: '**/mcp_servers/invalid-server*' },
181+
{
182+
statusCode: 404,
183+
body: {
184+
error: { code: '404', message: 'the requested resource could not be found' },
185+
},
186+
},
187+
);
188+
cy.visit('/mcp-catalog/invalid-server');
189+
mcpServerDetails.findMcpNotFound().should('be.visible');
190+
mcpServerDetails.findDeployButton().should('not.exist');
191+
});
192+
});
193+
159194
describe('Tools section', () => {
160195
const serverId = kubernetesServer.id;
161196

0 commit comments

Comments
 (0)