Skip to content

Commit 080156d

Browse files
philipp-winterleDumbrisclaude
authored
feat: Add tool cache invalidation with differential update logic and manual discovery trigger (#208)
* feat(api): add server tool discovery functionality and auto cache invalidation of tools - Implemented `discoverServerTools` method in APIService to trigger tool discovery for a specified server. - Updated ServerDetail.vue to include a button for discovering tools, enhancing user interaction. - Added backend support for tool discovery in the ServerController and integrated it into the routing. - Introduced `GetToolsByServer` method in the index manager to retrieve tools associated with a server. - Enhanced the OpenAPI documentation to reflect the new endpoint for tool discovery. This feature allows users to manually trigger tool discovery, improving the management of server tools. * fix: add missing DiscoverServerTools and GetToolsByServer to test mocks - Add DiscoverServerTools method to MockServerController in contracts_test.go - Add DiscoverServerTools method to baseController in security_test.go - Add GetToolsByServer to TestIndexManagerContract expected methods These methods were added to the interfaces in this PR but the test mocks were not updated, causing CI failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Algis Dumbris <a.dumbris@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2441d02 commit 080156d

15 files changed

Lines changed: 701 additions & 7 deletions

File tree

frontend/src/services/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@ class APIService {
243243
})
244244
}
245245

246+
async discoverServerTools(serverName: string): Promise<APIResponse> {
247+
return this.request(`/api/v1/servers/${encodeURIComponent(serverName)}/discover-tools`, {
248+
method: 'POST',
249+
})
250+
}
251+
246252
async deleteServer(serverName: string): Promise<APIResponse> {
247253
return this.callTool('upstream_servers', {
248254
operation: 'remove',

frontend/src/views/ServerDetail.vue

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@
8686
OAuth Login
8787
</button>
8888
</li>
89+
<li v-if="server.enabled && server.connected">
90+
<button @click="discoverTools" :disabled="actionLoading">
91+
<span v-if="actionLoading" class="loading loading-spinner loading-xs"></span>
92+
Discover Tools
93+
</button>
94+
</li>
8995
<li>
9096
<button @click="server.quarantined ? unquarantineServer() : quarantineServer()" :disabled="actionLoading">
9197
<span v-if="actionLoading" class="loading loading-spinner loading-xs"></span>
@@ -685,6 +691,43 @@ async function refreshData() {
685691
await loadServerDetails()
686692
}
687693
694+
async function discoverTools() {
695+
if (!server.value) return
696+
697+
actionLoading.value = true
698+
try {
699+
const response = await api.discoverServerTools(server.value.name)
700+
701+
if (!response.success) {
702+
throw new Error(response.error || 'Failed to discover tools')
703+
}
704+
705+
systemStore.addToast({
706+
type: 'success',
707+
title: 'Tool Discovery Started',
708+
message: `Discovering tools for ${server.value.name}...`,
709+
})
710+
711+
// Refresh server details after a short delay to show updated tool count
712+
setTimeout(async () => {
713+
await loadServerDetails()
714+
systemStore.addToast({
715+
type: 'info',
716+
title: 'Tools Updated',
717+
message: `Tool cache refreshed for ${server.value?.name}`,
718+
})
719+
}, 2000)
720+
} catch (error) {
721+
systemStore.addToast({
722+
type: 'error',
723+
title: 'Tool Discovery Failed',
724+
message: error instanceof Error ? error.message : 'Unknown error',
725+
})
726+
} finally {
727+
actionLoading.value = false
728+
}
729+
}
730+
688731
function viewToolSchema(tool: Tool) {
689732
selectedToolSchema.value = tool
690733
}

internal/appctx/contracts_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ func TestIndexManagerContract(t *testing.T) {
179179
in: []reflect.Type{},
180180
out: []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()},
181181
},
182+
"GetToolsByServer": {
183+
name: "GetToolsByServer",
184+
in: []reflect.Type{reflect.TypeOf("")},
185+
out: []reflect.Type{reflect.TypeOf([]*config.ToolMetadata{}), reflect.TypeOf((*error)(nil)).Elem()},
186+
},
182187
}
183188

184189
verifyInterfaceContract(t, interfaceType, expectedMethods)

internal/appctx/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ type IndexManager interface {
6767
// Tool management
6868
DeleteTool(serverName, toolName string) error
6969
DeleteServerTools(serverName string) error
70+
GetToolsByServer(serverName string) ([]*config.ToolMetadata, error)
7071

7172
// Index management
7273
RebuildIndex() error

internal/httpapi/contracts_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,11 @@ func (m *MockServerController) GetVersionInfo() *updatecheck.VersionInfo {
285285
return nil
286286
}
287287

288+
// Tool discovery
289+
func (m *MockServerController) DiscoverServerTools(_ context.Context, _ string) error {
290+
return nil
291+
}
292+
288293
// Test contract compliance for API responses
289294
func TestAPIContractCompliance(t *testing.T) {
290295
logger := zaptest.NewLogger(t).Sugar()

internal/httpapi/security_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,6 @@ func (m *baseController) GetToolCallsBySession(sessionID string, limit, offset i
301301
return nil, 0, nil
302302
}
303303
func (m *baseController) GetVersionInfo() *updatecheck.VersionInfo { return nil }
304+
func (m *baseController) DiscoverServerTools(_ context.Context, _ string) error {
305+
return nil
306+
}

internal/httpapi/server.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type ServerController interface {
5353
GetQuarantinedServers() ([]map[string]interface{}, error)
5454
UnquarantineServer(serverName string) error
5555
GetManagementService() interface{} // Returns the management service for unified operations
56+
DiscoverServerTools(ctx context.Context, serverName string) error
5657

5758
// Tools and search
5859
GetServerTools(serverName string) ([]map[string]interface{}, error)
@@ -335,6 +336,7 @@ func (s *Server) setupRoutes() {
335336
r.Post("/logout", s.handleServerLogout)
336337
r.Post("/quarantine", s.handleQuarantineServer)
337338
r.Post("/unquarantine", s.handleUnquarantineServer)
339+
r.Post("/discover-tools", s.handleDiscoverServerTools)
338340
r.Get("/tools", s.handleGetServerTools)
339341
r.Get("/logs", s.handleGetServerLogs)
340342
r.Get("/tool-calls", s.handleGetServerToolCalls)
@@ -1054,6 +1056,49 @@ func (s *Server) handleRestartServer(w http.ResponseWriter, r *http.Request) {
10541056
s.writeSuccess(w, response)
10551057
}
10561058

1059+
// handleDiscoverServerTools godoc
1060+
// @Summary Discover tools for a specific server
1061+
// @Description Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.
1062+
// @Tags servers
1063+
// @Produce json
1064+
// @Security ApiKeyAuth
1065+
// @Security ApiKeyQuery
1066+
// @Param id path string true "Server ID or name"
1067+
// @Success 200 {object} contracts.ServerActionResponse "Tool discovery triggered successfully"
1068+
// @Failure 400 {object} contracts.ErrorResponse "Bad request (missing server ID)"
1069+
// @Failure 404 {object} contracts.ErrorResponse "Server not found"
1070+
// @Failure 500 {object} contracts.ErrorResponse "Failed to discover tools"
1071+
// @Router /api/v1/servers/{id}/discover-tools [post]
1072+
func (s *Server) handleDiscoverServerTools(w http.ResponseWriter, r *http.Request) {
1073+
serverID := chi.URLParam(r, "id")
1074+
if serverID == "" {
1075+
s.writeError(w, http.StatusBadRequest, "Server ID required")
1076+
return
1077+
}
1078+
1079+
s.logger.Info("Manual tool discovery triggered via API", "server", serverID)
1080+
1081+
if err := s.controller.DiscoverServerTools(r.Context(), serverID); err != nil {
1082+
s.logger.Error("Failed to discover tools for server", "server", serverID, "error", err)
1083+
1084+
if strings.Contains(err.Error(), "not found") {
1085+
s.writeError(w, http.StatusNotFound, fmt.Sprintf("Server not found: %s", serverID))
1086+
return
1087+
}
1088+
1089+
s.writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to discover tools: %v", err))
1090+
return
1091+
}
1092+
1093+
response := contracts.ServerActionResponse{
1094+
Server: serverID,
1095+
Action: "discover_tools",
1096+
Success: true,
1097+
Async: false,
1098+
}
1099+
s.writeSuccess(w, response)
1100+
}
1101+
10571102
func (s *Server) toggleServerAsync(serverID string, enabled bool) (bool, error) {
10581103
errCh := make(chan error, 1)
10591104
go func() {

internal/index/bleve.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,44 @@ func (b *BleveIndex) RebuildIndex() error {
339339
return nil
340340
}
341341

342+
// GetToolsByServer retrieves all tools from a specific server
343+
func (b *BleveIndex) GetToolsByServer(serverName string) ([]*config.ToolMetadata, error) {
344+
// Create a term query for the server name
345+
query := bleve.NewTermQuery(serverName)
346+
query.SetField("server_name")
347+
348+
// Create search request with high limit to get all tools
349+
searchReq := bleve.NewSearchRequest(query)
350+
searchReq.Size = 10000 // Maximum tools per server
351+
searchReq.Fields = []string{"tool_name", "full_tool_name", "server_name", "description", "params_json", "hash"}
352+
353+
b.logger.Debug("Querying tools by server", zap.String("server", serverName))
354+
355+
searchResult, err := b.index.Search(searchReq)
356+
if err != nil {
357+
return nil, fmt.Errorf("failed to query tools by server: %w", err)
358+
}
359+
360+
// Convert results to ToolMetadata
361+
var tools []*config.ToolMetadata
362+
for _, hit := range searchResult.Hits {
363+
toolMeta := &config.ToolMetadata{
364+
Name: getStringField(hit.Fields, "full_tool_name"),
365+
ServerName: getStringField(hit.Fields, "server_name"),
366+
Description: getStringField(hit.Fields, "description"),
367+
ParamsJSON: getStringField(hit.Fields, "params_json"),
368+
Hash: getStringField(hit.Fields, "hash"),
369+
}
370+
tools = append(tools, toolMeta)
371+
}
372+
373+
b.logger.Debug("Found tools for server",
374+
zap.String("server", serverName),
375+
zap.Int("count", len(tools)))
376+
377+
return tools, nil
378+
}
379+
342380
// Helper function to get string field from search results
343381
func getStringField(fields map[string]interface{}, fieldName string) string {
344382
if val, ok := fields[fieldName]; ok {

internal/index/manager.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,11 @@ func (m *Manager) GetStats() (map[string]interface{}, error) {
123123

124124
return stats, nil
125125
}
126+
127+
// GetToolsByServer retrieves all tools from a specific server
128+
func (m *Manager) GetToolsByServer(serverName string) ([]*config.ToolMetadata, error) {
129+
m.mu.RLock()
130+
defer m.mu.RUnlock()
131+
132+
return m.bleveIndex.GetToolsByServer(serverName)
133+
}

0 commit comments

Comments
 (0)