Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apiclient/types/mcpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ type MCPServerCatalogEntry struct {
PowerUserWorkspaceID string `json:"powerUserWorkspaceID,omitempty"`
PowerUserID string `json:"powerUserID,omitempty"`
NeedsUpdate bool `json:"needsUpdate,omitempty"`
NeedsK8sUpdate bool `json:"needsK8sUpdate,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this can also be removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I delete this I do get the following error in mcp.go line 159

unknown field NeedsK8sUpdate in struct literal of type "github.com/obot-platform/obot/apiclient/types".MCPServerCatalogEntry

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to track this for catalog entries. There are no deployments for catalog entries, so this field and all of its references should be removed.

}

type MCPServerCatalogEntryManifest struct {
Expand Down Expand Up @@ -235,6 +236,9 @@ type MCPServer struct {
// NeedsUpdate indicates whether the configuration in this server's catalog entry has drift from this server's configuration.
NeedsUpdate bool `json:"needsUpdate,omitempty"`

// NeedsK8sUpdate indicates whether this server needs redeployment with new K8s settings
NeedsK8sUpdate bool `json:"needsK8sUpdate,omitempty"`

// NeedsURL indicates whether the server's URL needs to be updated to match the catalog entry.
NeedsURL bool `json:"needsURL,omitempty"`

Expand Down Expand Up @@ -313,9 +317,10 @@ type ProjectMCPServer struct {
Runtime Runtime `json:"runtime,omitempty"`

// The following status fields are always copied from the MCPServer that this points to.
Configured bool `json:"configured"`
NeedsURL bool `json:"needsURL"`
NeedsUpdate bool `json:"needsUpdate"`
Configured bool `json:"configured"`
NeedsURL bool `json:"needsURL"`
NeedsUpdate bool `json:"needsUpdate"`
NeedsK8sUpdate bool `json:"needsK8sUpdate"`
}

type ProjectMCPServerList List[ProjectMCPServer]
Expand Down
10 changes: 10 additions & 0 deletions pkg/api/handlers/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ func ConvertMCPServerCatalogEntryWithWorkspace(entry v1.MCPServerCatalogEntry, p
PowerUserWorkspaceID: powerUserWorkspaceID,
PowerUserID: powerUserID,
NeedsUpdate: entry.Status.NeedsUpdate,
NeedsK8sUpdate: entry.Status.NeedsK8sUpdate,
}
}

Expand Down Expand Up @@ -2463,6 +2464,7 @@ func ConvertMCPServer(server v1.MCPServer, credEnv map[string]string, serverURL,
MCPCatalogID: server.Spec.MCPCatalogID,
ConnectURL: connectURL,
NeedsUpdate: server.Status.NeedsUpdate,
NeedsK8sUpdate: server.Status.NeedsK8sUpdate,
NeedsURL: server.Spec.NeedsURL,
PreviousURL: server.Spec.PreviousURL,
MCPServerInstanceUserCount: server.Status.MCPServerInstanceUserCount,
Expand Down Expand Up @@ -3046,6 +3048,14 @@ func (m *MCPHandler) RedeployWithK8sSettings(req api.Context) error {
return fmt.Errorf("failed to redeploy server: %w", err)
}

// Clear the NeedsK8sUpdate flag since user explicitly redeployed
if server.Status.NeedsK8sUpdate {
server.Status.NeedsK8sUpdate = false
if err := req.Storage.Status().Update(req.Context(), &server); err != nil {
return fmt.Errorf("failed to update server status: %w", err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically, we wouldn't want to do this in the API. Instead, let the controller update the field.

I think it's fine if you want to ensure the value is true when returning from the API, but better to let the controller update the database.

}

// Get credential for server
var credCtxs []string
if server.Spec.MCPCatalogID != "" {
Expand Down
8 changes: 5 additions & 3 deletions pkg/api/handlers/projectmcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ func convertProjectMCPServer(projectServer *v1.ProjectMCPServer, mcpServer *v1.M
Runtime: mcpServer.Spec.Manifest.Runtime,

// Default values to show to the user for shared servers:
Configured: true,
NeedsURL: false,
NeedsUpdate: false,
Configured: true,
NeedsURL: false,
NeedsUpdate: false,
NeedsK8sUpdate: false,
}
pmcp.Alias = mcpServer.Spec.Alias

Expand All @@ -66,6 +67,7 @@ func convertProjectMCPServer(projectServer *v1.ProjectMCPServer, mcpServer *v1.M
pmcp.Configured = convertedServer.Configured
pmcp.NeedsURL = convertedServer.NeedsURL
pmcp.NeedsUpdate = convertedServer.NeedsUpdate
pmcp.NeedsK8sUpdate = convertedServer.NeedsK8sUpdate
}

return pmcp
Expand Down
30 changes: 30 additions & 0 deletions pkg/controller/handlers/deployment/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (

"github.com/obot-platform/nah/pkg/apply"
"github.com/obot-platform/nah/pkg/router"
"github.com/obot-platform/obot/apiclient/types"
"github.com/obot-platform/obot/pkg/mcp"
v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1"
"github.com/obot-platform/obot/pkg/system"
appsv1 "k8s.io/api/apps/v1"
Expand Down Expand Up @@ -84,13 +86,41 @@ func (h *Handler) UpdateMCPServerStatus(req router.Request, _ router.Response) e
mcpServer.Status.DeploymentConditions = conditions
needsUpdate = true
}

// Update K8s settings hash if it changed
// Note: k8sSettingsHash will be empty string for non-K8s runtimes or if annotation is missing
if mcpServer.Status.K8sSettingsHash != k8sSettingsHash {
mcpServer.Status.K8sSettingsHash = k8sSettingsHash
needsUpdate = true
}

// Manage NeedsK8sUpdate flag for K8s-compatible runtimes
isK8sRuntime := mcpServer.Spec.Manifest.Runtime == types.RuntimeContainerized ||
mcpServer.Spec.Manifest.Runtime == types.RuntimeUVX ||
mcpServer.Spec.Manifest.Runtime == types.RuntimeNPX

if isK8sRuntime {
// Get current K8s settings to compare
var k8sSettings v1.K8sSettings
if err := h.storageClient.Get(req.Ctx, kclient.ObjectKey{
Namespace: h.mcpNamespace,
Name: system.K8sSettingsName,
}, &k8sSettings); err == nil {
currentHash := mcp.ComputeK8sSettingsHash(k8sSettings.Spec)

// Only SET to true if drift detected, never clear it
// The flag gets cleared by the RedeployWithK8sSettings API endpoint after successful redeploy
hasHash := k8sSettingsHash != ""
hasDrift := k8sSettingsHash != currentHash
flagNotSet := !mcpServer.Status.NeedsK8sUpdate

if hasHash && hasDrift && flagNotSet {
mcpServer.Status.NeedsK8sUpdate = true
needsUpdate = true
}
}
}

// Update the MCPServer status if needed
if needsUpdate {
return h.storageClient.Status().Update(req.Ctx, &mcpServer)
Expand Down
106 changes: 106 additions & 0 deletions pkg/controller/handlers/k8ssettings/k8ssettings.go
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should be deleted.

The way our controller framework works, the two controllers you wrote (one for servers and one for catalog entries) will do the correct thing when the k8s settings change.

Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package k8ssettings

import (
"github.com/obot-platform/nah/pkg/router"
"github.com/obot-platform/obot/pkg/mcp"
v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1"
"github.com/obot-platform/obot/pkg/system"
"k8s.io/apimachinery/pkg/fields"
kclient "sigs.k8s.io/controller-runtime/pkg/client"
)

// UpdateAllServerK8sSettingsDrift updates the NeedsK8sUpdate status on all MCP servers when K8s settings change
func UpdateAllServerK8sSettingsDrift(req router.Request, _ router.Response) error {
k8sSettings := req.Object.(*v1.K8sSettings)

// Compute the new hash
currentHash := mcp.ComputeK8sSettingsHash(k8sSettings.Spec)

// List all MCP servers
var servers v1.MCPServerList
if err := req.List(&servers, &kclient.ListOptions{
Namespace: req.Object.GetNamespace(),
}); err != nil {
return err
}

// Update each server's NeedsK8sUpdate status
for i := range servers.Items {
server := &servers.Items[i]
// Skip servers without K8s settings hash (not deployed or non-K8s runtimes)
if server.Status.K8sSettingsHash == "" {
if server.Status.NeedsK8sUpdate {
server.Status.NeedsK8sUpdate = false
if err := req.Client.Status().Update(req.Ctx, server); err != nil {
return err
}
}
continue
}

// Check if the server needs update
needsK8sUpdate := server.Status.K8sSettingsHash != currentHash

if server.Status.NeedsK8sUpdate != needsK8sUpdate {
server.Status.NeedsK8sUpdate = needsK8sUpdate
if err := req.Client.Status().Update(req.Ctx, server); err != nil {
return err
}
}
}

return nil
}

// UpdateAllCatalogEntryK8sSettingsDrift updates the NeedsK8sUpdate status on all catalog entries when K8s settings change
func UpdateAllCatalogEntryK8sSettingsDrift(req router.Request, _ router.Response) error {
k8sSettings := req.Object.(*v1.K8sSettings)

// Compute the new hash
currentHash := mcp.ComputeK8sSettingsHash(k8sSettings.Spec)

// List all catalog entries
var entries v1.MCPServerCatalogEntryList
if err := req.List(&entries, &kclient.ListOptions{
Namespace: req.Object.GetNamespace(),
}); err != nil {
return err
}

// Update each catalog entry's NeedsK8sUpdate status
for i := range entries.Items {
entry := &entries.Items[i]
// List all servers created from this catalog entry
var mcpServers v1.MCPServerList
if err := req.List(&mcpServers, &kclient.ListOptions{
FieldSelector: fields.OneTermEqualSelector("spec.mcpServerCatalogEntryName", entry.Name),
Namespace: system.DefaultNamespace,
}); err != nil {
return err
}

// Check if any server has outdated K8s settings
var needsK8sUpdate bool
for _, server := range mcpServers.Items {
// Skip servers being deleted or without K8s settings hash
if !server.DeletionTimestamp.IsZero() || server.Status.K8sSettingsHash == "" {
continue
}

// Check if hash differs from current settings
if server.Status.K8sSettingsHash != currentHash {
needsK8sUpdate = true
break
}
}

if entry.Status.NeedsK8sUpdate != needsK8sUpdate {
entry.Status.NeedsK8sUpdate = needsK8sUpdate
if err := req.Client.Status().Update(req.Ctx, entry); err != nil {
return err
}
}
}

return nil
}
38 changes: 38 additions & 0 deletions pkg/controller/handlers/mcpserver/mcpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,44 @@ func (h *Handler) DetectDrift(req router.Request, _ router.Response) error {
return nil
}

// DetectK8sSettingsDrift detects when a server needs redeployment with new K8s settings
func (h *Handler) DetectK8sSettingsDrift(req router.Request, _ router.Response) error {
server := req.Object.(*v1.MCPServer)

// Only check for containerized or uvx/npx runtimes that might run in K8s
if server.Spec.Manifest.Runtime != types.RuntimeContainerized &&
server.Spec.Manifest.Runtime != types.RuntimeUVX &&
server.Spec.Manifest.Runtime != types.RuntimeNPX {
return nil
}

// Skip if server doesn't have K8s settings hash (not yet deployed)
if server.Status.K8sSettingsHash == "" {
return nil
}

// Get current K8s settings
var k8sSettings v1.K8sSettings
if err := req.Get(&k8sSettings, server.Namespace, system.K8sSettingsName); err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return fmt.Errorf("failed to get K8s settings: %w", err)
}

// Compute current K8s settings hash
currentHash := mcp.ComputeK8sSettingsHash(k8sSettings.Spec)

// Only SET to true when drift detected, never clear it
// The flag gets cleared by the RedeployWithK8sSettings API endpoint after successful redeploy
if server.Status.K8sSettingsHash != currentHash && !server.Status.NeedsK8sUpdate {
server.Status.NeedsK8sUpdate = true
return req.Client.Status().Update(req.Ctx, server)
}

return nil
}

func configurationHasDrifted(serverManifest types.MCPServerManifest, entryManifest types.MCPServerCatalogEntryManifest) (bool, error) {
// Check if runtime types differ
if serverManifest.Runtime != entryManifest.Runtime {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/gptscript-ai/gptscript/pkg/hash"
"github.com/obot-platform/nah/pkg/router"
"github.com/obot-platform/obot/apiclient/types"
"github.com/obot-platform/obot/pkg/mcp"
v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1"
"github.com/obot-platform/obot/pkg/system"
apierrors "k8s.io/apimachinery/pkg/api/errors"
Expand Down Expand Up @@ -135,6 +136,76 @@ func DetectCompositeDrift(req router.Request, _ router.Response) error {
return nil
}

// DetectK8sSettingsDrift detects when servers created from this catalog entry need redeployment with new K8s settings
func DetectK8sSettingsDrift(req router.Request, _ router.Response) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be deleted.

The way our controller framework works, the controller you added for MCP servers will do the right thing.

entry := req.Object.(*v1.MCPServerCatalogEntry)

// Only check for containerized or uvx/npx runtimes that might run in K8s
if entry.Spec.Manifest.Runtime != types.RuntimeContainerized &&
entry.Spec.Manifest.Runtime != types.RuntimeUVX &&
entry.Spec.Manifest.Runtime != types.RuntimeNPX {
// Remote and composite servers don't have K8s deployments
if entry.Status.NeedsK8sUpdate {
entry.Status.NeedsK8sUpdate = false
return req.Client.Status().Update(req.Ctx, entry)
}
return nil
}

// Get current K8s settings
var k8sSettings v1.K8sSettings
if err := req.Get(&k8sSettings, req.Object.GetNamespace(), system.K8sSettingsName); err != nil {
if apierrors.IsNotFound(err) {
// K8s settings not found, mark as not needing update
if entry.Status.NeedsK8sUpdate {
entry.Status.NeedsK8sUpdate = false
return req.Client.Status().Update(req.Ctx, entry)
}
return nil
}
return fmt.Errorf("failed to get K8s settings: %w", err)
}

// Compute current K8s settings hash
currentHash := mcp.ComputeK8sSettingsHash(k8sSettings.Spec)

// List all servers created from this catalog entry
var mcpServers v1.MCPServerList
if err := req.List(&mcpServers, &kclient.ListOptions{
FieldSelector: fields.OneTermEqualSelector("spec.mcpServerCatalogEntryName", entry.Name),
Namespace: req.Object.GetNamespace(),
}); err != nil {
return fmt.Errorf("failed to list MCP servers: %w", err)
}

// Check if any server has outdated K8s settings
var needsK8sUpdate bool
for _, server := range mcpServers.Items {
// Skip servers being deleted
if !server.DeletionTimestamp.IsZero() {
continue
}

// Skip servers without K8s settings hash (non-K8s runtimes or not yet deployed)
if server.Status.K8sSettingsHash == "" {
continue
}

// Check if hash differs from current settings
if server.Status.K8sSettingsHash != currentHash {
needsK8sUpdate = true
break
}
}

if entry.Status.NeedsK8sUpdate != needsK8sUpdate {
entry.Status.NeedsK8sUpdate = needsK8sUpdate
return req.Client.Status().Update(req.Ctx, entry)
}

return nil
}

// CleanupNestedCompositeServers removes component servers with composite runtimes from composite catalog entries.
// This handler cleans up entries that were created before API validation to prevent nested composite servers.
func CleanupNestedCompositeEntries(req router.Request, _ router.Response) error {
Expand Down
Loading
Loading