Skip to content

Commit f612d99

Browse files
manusaclaude
andcommitted
fix(mcpapps): harden resource lifecycle, meta merging, and edge cases
- Clean up stale ui:// resources on toolset reload via RemoveResources - Merge _meta.ui into existing tool Meta instead of skipping - Guard ensureStructuredObject against typed nil slices - Prevent duplicate Namespace key in tableToStructured - Remove redundant loop variable capture (Go 1.22+ semantics) - Use plain object instead of ES6 Map in protocol.js for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Marc Nuri <marc@marcnuri.com>
1 parent 2a489c8 commit f612d99

5 files changed

Lines changed: 55 additions & 22 deletions

File tree

pkg/mcp/mcp.go

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,14 @@ func (c *Configuration) isToolApplicable(tool api.ServerTool) bool {
6464
}
6565

6666
type Server struct {
67-
mu sync.RWMutex
68-
configuration *Configuration
69-
server *mcp.Server
70-
enabledTools []string
71-
enabledPrompts []string
72-
p internalk8s.Provider
73-
metrics *metrics.Metrics // Metrics collection system
67+
mu sync.RWMutex
68+
configuration *Configuration
69+
server *mcp.Server
70+
enabledTools []string
71+
enabledPrompts []string
72+
registeredAppURIs []string // tracked for cleanup on reload
73+
p internalk8s.Provider
74+
metrics *metrics.Metrics // Metrics collection system
7475
}
7576

7677
func NewServer(configuration Configuration, targetProvider internalk8s.Provider) (*Server, error) {
@@ -286,27 +287,46 @@ func (s *Server) registerPrompt(prompt api.ServerPrompt) error {
286287
// registerMCPAppResources registers per-tool viewer HTML as ui:// resources.
287288
// Each tool gets its own resource URI (e.g. ui://kubernetes-mcp-server/tool/pods_list)
288289
// so the viewer knows which tool it belongs to and can call it via serverTools.
290+
// Stale resources from previously enabled tools are removed.
289291
func (s *Server) registerMCPAppResources(toolNames []string) {
292+
// Build the new URI set
293+
newURIs := make(map[string]bool, len(toolNames))
290294
for _, toolName := range toolNames {
291-
tn := toolName // capture for closure
292-
uri := mcpapps.ToolResourceURI(tn)
295+
newURIs[mcpapps.ToolResourceURI(toolName)] = true
296+
}
297+
// Remove stale resources that are no longer needed
298+
var staleURIs []string
299+
for _, uri := range s.registeredAppURIs {
300+
if !newURIs[uri] {
301+
staleURIs = append(staleURIs, uri)
302+
}
303+
}
304+
if len(staleURIs) > 0 {
305+
s.server.RemoveResources(staleURIs...)
306+
}
307+
// Register new/updated resources
308+
registeredURIs := make([]string, 0, len(toolNames))
309+
for _, toolName := range toolNames {
310+
uri := mcpapps.ToolResourceURI(toolName)
311+
registeredURIs = append(registeredURIs, uri)
293312
s.server.AddResource(
294313
&mcp.Resource{
295314
URI: uri,
296-
Name: "Kubernetes MCP Apps Viewer: " + tn,
315+
Name: "Kubernetes MCP Apps Viewer: " + toolName,
297316
MIMEType: mcpapps.ResourceMIMEType,
298317
},
299318
func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
300319
return &mcp.ReadResourceResult{
301320
Contents: []*mcp.ResourceContents{{
302321
URI: uri,
303322
MIMEType: mcpapps.ResourceMIMEType,
304-
Text: mcpapps.ViewerHTMLForTool(tn),
323+
Text: mcpapps.ViewerHTMLForTool(toolName),
305324
}},
306325
}, nil
307326
},
308327
)
309328
}
329+
s.registeredAppURIs = registeredURIs
310330
}
311331

312332
// metricsMiddleware returns a metrics middleware with access to the server's metrics system

pkg/mcp/text_result_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ func (s *TextResultSuite) TestEnsureStructuredObject() {
5858
result := ensureStructuredObject("hello")
5959
s.Equal("hello", result)
6060
})
61+
s.Run("passes nil slice through unchanged", func() {
62+
var nilSlice []map[string]any
63+
result := ensureStructuredObject(nilSlice)
64+
s.Nil(result)
65+
})
6166
}
6267

6368
func (s *TextResultSuite) TestNewStructuredResult() {

pkg/mcp/tool_mutator.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,16 @@ func createTargetListHandler(p targetLister, targetParameterName, defaultTarget
128128

129129
// WithAppsMeta injects _meta.ui into every tool when MCP Apps is enabled.
130130
// Each tool gets a unique resource URI so the viewer knows which tool to call.
131+
// If a tool already has Meta with a "ui" key, it is left unchanged.
131132
func WithAppsMeta() ToolMutator {
132133
return func(tool api.ServerTool) api.ServerTool {
134+
appsMeta := mcpapps.ToolMetaForTool(tool.Tool.Name)
133135
if tool.Tool.Meta == nil {
134-
tool.Tool.Meta = mcpapps.ToolMetaForTool(tool.Tool.Name)
136+
tool.Tool.Meta = appsMeta
137+
} else if _, hasUI := tool.Tool.Meta["ui"]; !hasUI {
138+
for k, v := range appsMeta {
139+
tool.Tool.Meta[k] = v
140+
}
135141
}
136142
return tool
137143
}

pkg/mcpapps/viewer/protocol.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
'use strict';
33

44
var nextId = 1;
5-
var pending = new Map();
5+
var pending = {};
66
var notificationHandlers = {};
77
var requestHandlers = {};
88

@@ -17,7 +17,7 @@
1717
var id = nextId++;
1818
sendMessage({ jsonrpc: '2.0', id: id, method: method, params: params });
1919
return new Promise(function(resolve, reject) {
20-
pending.set(id, { resolve: resolve, reject: reject });
20+
pending[id] = { resolve: resolve, reject: reject };
2121
});
2222
}
2323

@@ -43,9 +43,9 @@
4343
console.log('[mcp-protocol] <<', msg.method || ('response id=' + msg.id), JSON.stringify(msg).substring(0, 500));
4444

4545
// Response to our request
46-
if (msg.id != null && pending.has(msg.id)) {
47-
var p = pending.get(msg.id);
48-
pending.delete(msg.id);
46+
if (msg.id != null && pending[msg.id]) {
47+
var p = pending[msg.id];
48+
delete pending[msg.id];
4949
if (msg.error) p.reject(new Error(msg.error.message));
5050
else p.resolve(msg.result);
5151
return;

pkg/output/output.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,13 @@ func tableToStructured(t *metav1.Table) []map[string]any {
162162
item[col.Name] = row.Cells[ci]
163163
}
164164
}
165-
// Add namespace from the embedded object metadata if available
166-
if row.Object.Object != nil {
167-
if u, ok := row.Object.Object.(*unstructured.Unstructured); ok {
168-
if ns := u.GetNamespace(); ns != "" {
169-
item["Namespace"] = ns
165+
// Add namespace from the embedded object metadata if not already present from column definitions
166+
if _, hasNS := item["Namespace"]; !hasNS {
167+
if row.Object.Object != nil {
168+
if u, ok := row.Object.Object.(*unstructured.Unstructured); ok {
169+
if ns := u.GetNamespace(); ns != "" {
170+
item["Namespace"] = ns
171+
}
170172
}
171173
}
172174
}

0 commit comments

Comments
 (0)