Skip to content

Commit b3c73ea

Browse files
Slachaider-chat-bot
andcommitted
feat: switch to lazy loading of dynamic tools
Co-authored-by: aider (gemini/gemini-3-pro-preview) <[email protected]>
1 parent 79969be commit b3c73ea

File tree

3 files changed

+68
-20
lines changed

3 files changed

+68
-20
lines changed

cmd/altinity-mcp/main.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,17 @@ func (a *application) createTokenInjector() func(http.Handler) http.Handler {
327327
}
328328
}
329329

330+
// dynamicToolsInjector creates a middleware that ensures dynamic tools are loaded
331+
func (a *application) dynamicToolsInjector(next http.Handler) http.Handler {
332+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
333+
if err := a.mcpServer.EnsureDynamicTools(r.Context()); err != nil {
334+
// Log error but continue, static tools should still work
335+
log.Warn().Err(err).Msg("Failed to ensure dynamic tools")
336+
}
337+
next.ServeHTTP(w, r)
338+
})
339+
}
340+
330341
// stripTrailingSlash normalizes paths to remove a single trailing slash (except root)
331342
func stripTrailingSlash(next http.Handler) http.Handler {
332343
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -535,11 +546,12 @@ func (a *application) startHTTPServer(cfg config.Config, mcpServer *server.MCPSe
535546
log.Info().Msg("Using dynamic base path for JWE authentication")
536547

537548
tokenInjector := a.createTokenInjector()
549+
dtInjector := a.dynamicToolsInjector
538550
httpServer := server.NewStreamableHTTPServer(mcpServer)
539551

540552
// Register custom handlers to ensure token is in the path and inject it into context
541553
mux := http.NewServeMux()
542-
mux.Handle("/{token}/http", serverInjector(tokenInjector(httpServer)))
554+
mux.Handle("/{token}/http", serverInjector(tokenInjector(dtInjector(httpServer))))
543555
if cfg.Server.OpenAPI.Enabled {
544556
mux.HandleFunc("/openapi", a.mcpServer.ServeOpenAPISchema)
545557
mux.HandleFunc("/{token}/openapi", serverInjectorOpenAPI)
@@ -555,8 +567,9 @@ func (a *application) startHTTPServer(cfg config.Config, mcpServer *server.MCPSe
555567
} else {
556568
// Use standard HTTP server without dynamic paths
557569
httpServer := server.NewStreamableHTTPServer(mcpServer)
570+
dtInjector := a.dynamicToolsInjector
558571
mux := http.NewServeMux()
559-
mux.Handle("/http", serverInjector(httpServer))
572+
mux.Handle("/http", serverInjector(dtInjector(httpServer)))
560573
if cfg.Server.OpenAPI.Enabled {
561574
mux.HandleFunc("/openapi", serverInjectorOpenAPI)
562575
mux.HandleFunc("/openapi/", serverInjectorOpenAPI)
@@ -629,6 +642,7 @@ func (a *application) startSSEServer(cfg config.Config, mcpServer *server.MCPSer
629642
log.Info().Msg("Using dynamic base path for JWE authentication")
630643

631644
tokenInjector := a.createTokenInjector()
645+
dtInjector := a.dynamicToolsInjector
632646

633647
sseServer := server.NewSSEServer(
634648
mcpServer,
@@ -645,8 +659,8 @@ func (a *application) startSSEServer(cfg config.Config, mcpServer *server.MCPSer
645659
)
646660

647661
mux := http.NewServeMux()
648-
mux.Handle("/{token}/sse", serverInjector(tokenInjector(sseServer.SSEHandler())))
649-
mux.Handle("/{token}/message", serverInjector(tokenInjector(sseServer.MessageHandler())))
662+
mux.Handle("/{token}/sse", serverInjector(tokenInjector(dtInjector(sseServer.SSEHandler()))))
663+
mux.Handle("/{token}/message", serverInjector(tokenInjector(dtInjector(sseServer.MessageHandler()))))
650664
if cfg.Server.OpenAPI.Enabled {
651665
mux.HandleFunc("/openapi", a.mcpServer.ServeOpenAPISchema)
652666
mux.HandleFunc("/{token}/openapi", serverInjectorOpenAPI)
@@ -662,9 +676,10 @@ func (a *application) startSSEServer(cfg config.Config, mcpServer *server.MCPSer
662676
} else {
663677
// Use standard SSE server without dynamic paths
664678
sseServer := server.NewSSEServer(mcpServer)
679+
dtInjector := a.dynamicToolsInjector
665680
mux := http.NewServeMux()
666-
mux.Handle("/sse", serverInjector(sseServer))
667-
mux.Handle("/message", serverInjector(sseServer.MessageHandler()))
681+
mux.Handle("/sse", serverInjector(dtInjector(sseServer)))
682+
mux.Handle("/message", serverInjector(dtInjector(sseServer.MessageHandler())))
668683
if cfg.Server.OpenAPI.Enabled {
669684
mux.HandleFunc("/openapi", serverInjectorOpenAPI)
670685
mux.HandleFunc("/openapi/", serverInjectorOpenAPI)

pkg/server/server.go

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"regexp"
1111
"strconv"
1212
"strings"
13+
"sync"
1314

1415
"github.com/altinity/altinity-mcp/pkg/clickhouse"
1516
"github.com/altinity/altinity-mcp/pkg/config"
@@ -25,7 +26,9 @@ type ClickHouseJWEServer struct {
2526
Config config.Config
2627
Version string
2728
// dynamic tools metadata for OpenAPI routing and schema
28-
dynamicTools map[string]dynamicToolMeta
29+
dynamicTools map[string]dynamicToolMeta
30+
dynamicToolsMu sync.RWMutex
31+
dynamicToolsInit bool
2932
}
3033

3134
type dynamicToolParam struct {
@@ -75,8 +78,7 @@ func NewClickHouseMCPServer(cfg config.Config, version string) *ClickHouseJWESer
7578

7679
// Register tools, resources, and prompts
7780
RegisterTools(chJweServer)
78-
// dynamic tools registered after static ones
79-
registerDynamicTools(chJweServer)
81+
// dynamic tools registered lazily via EnsureDynamicTools
8082
RegisterResources(chJweServer)
8183
RegisterPrompts(chJweServer)
8284

@@ -414,19 +416,28 @@ func RegisterPrompts(srv AltinityMCPServer) {
414416
log.Info().Int("prompt_count", 0).Msg("ClickHouse prompts registered")
415417
}
416418

417-
// registerDynamicTools discovers ClickHouse views and registers MCP/OpenAPI tools
418-
func registerDynamicTools(s *ClickHouseJWEServer) {
419+
// EnsureDynamicTools discovers ClickHouse views and registers MCP/OpenAPI tools
420+
func (s *ClickHouseJWEServer) EnsureDynamicTools(ctx context.Context) error {
421+
s.dynamicToolsMu.Lock()
422+
defer s.dynamicToolsMu.Unlock()
423+
424+
if s.dynamicToolsInit {
425+
return nil
426+
}
427+
419428
if len(s.Config.Server.DynamicTools) == 0 {
420-
return
429+
s.dynamicToolsInit = true
430+
return nil
421431
}
422432

423-
ctx := context.WithValue(context.Background(), "clickhouse_jwe_server", s)
424-
token := ""
433+
// Check if we have a valid client/token to proceed
434+
token := s.ExtractTokenFromCtx(ctx)
425435
// Get ClickHouse client
426436
chClient, err := s.GetClickHouseClient(ctx, token)
427437
if err != nil {
428-
log.Error().Err(err).Msg("dynamic_tools: failed to get ClickHouse client")
429-
return
438+
// If we can't get a client (e.g. missing token when JWE enabled), we can't register dynamic tools yet
439+
// Return error so we retry later
440+
return fmt.Errorf("dynamic_tools: failed to get ClickHouse client: %w", err)
430441
}
431442
defer func() {
432443
if closeErr := chClient.Close(); closeErr != nil {
@@ -438,8 +449,7 @@ func registerDynamicTools(s *ClickHouseJWEServer) {
438449
q := "SELECT database, name, create_table_query, comment FROM system.tables WHERE engine='View'"
439450
result, err := chClient.ExecuteQuery(ctx, q)
440451
if err != nil {
441-
log.Error().Err(err).Str("query", q).Msg("dynamic_tools: failed to list views")
442-
return
452+
return fmt.Errorf("dynamic_tools: failed to list views: %w", err)
443453
}
444454

445455
// compile regex rules
@@ -556,6 +566,9 @@ func registerDynamicTools(s *ClickHouseJWEServer) {
556566
log.Error().Msg("dynamic_tools: overlaps detected; conflicting views were skipped as per policy 'error on overlap'")
557567
}
558568
log.Info().Int("tool_count", dynamicCount).Msg("Dynamic ClickHouse view tools registered")
569+
570+
s.dynamicToolsInit = true
571+
return nil
559572
}
560573

561574
func makeDynamicToolHandler(meta dynamicToolMeta) server.ToolHandlerFunc {
@@ -830,11 +843,21 @@ func (s *ClickHouseJWEServer) OpenAPIHandler(w http.ResponseWriter, r *http.Requ
830843
case strings.HasSuffix(r.URL.Path, "/openapi/execute_query"):
831844
s.handleExecuteQueryOpenAPI(w, r, token)
832845
case strings.Contains(r.URL.Path, "/openapi/") && r.Method == http.MethodPost:
846+
// Ensure dynamic tools are loaded
847+
if err := s.EnsureDynamicTools(r.Context()); err != nil {
848+
log.Warn().Err(err).Msg("Failed to ensure dynamic tools in OpenAPI handler")
849+
}
850+
833851
// dynamic tool endpoint: /openapi/{tool}
834852
parts := strings.Split(r.URL.Path, "/openapi/")
835853
if len(parts) == 2 {
836854
tool := strings.Trim(parts[1], "/")
837-
if meta, ok := s.dynamicTools[tool]; ok {
855+
856+
s.dynamicToolsMu.RLock()
857+
meta, ok := s.dynamicTools[tool]
858+
s.dynamicToolsMu.RUnlock()
859+
860+
if ok {
838861
s.handleDynamicToolOpenAPI(w, r, token, meta)
839862
return
840863
}
@@ -847,6 +870,11 @@ func (s *ClickHouseJWEServer) OpenAPIHandler(w http.ResponseWriter, r *http.Requ
847870
}
848871

849872
func (s *ClickHouseJWEServer) ServeOpenAPISchema(w http.ResponseWriter, r *http.Request) {
873+
// Ensure dynamic tools are loaded
874+
if err := s.EnsureDynamicTools(r.Context()); err != nil {
875+
log.Warn().Err(err).Msg("Failed to ensure dynamic tools in ServeOpenAPISchema")
876+
}
877+
850878
// Get host URL based on OpenAPI TLS configuration
851879
protocol := "http"
852880
if s.Config.Server.OpenAPI.TLS {
@@ -936,6 +964,10 @@ func (s *ClickHouseJWEServer) ServeOpenAPISchema(w http.ResponseWriter, r *http.
936964

937965
// add dynamic tool paths (POST)
938966
paths := schema["paths"].(map[string]interface{})
967+
968+
s.dynamicToolsMu.RLock()
969+
defer s.dynamicToolsMu.RUnlock()
970+
939971
for toolName, meta := range s.dynamicTools {
940972
path := "/{jwe_token}/openapi/" + toolName
941973
// request body schema

pkg/server/server_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1263,7 +1263,8 @@ func TestRegisterDynamicTools_SuccessAndOverlap(t *testing.T) {
12631263
{Regexp: "default\\.v_a", Prefix: "other_"},
12641264
}}}, dynamicTools: make(map[string]dynamicToolMeta)}
12651265

1266-
registerDynamicTools(s)
1266+
err = s.EnsureDynamicTools(ctx)
1267+
require.NoError(t, err)
12671268

12681269
// v_a matches two rules -> should be skipped
12691270
_, existsA1 := s.dynamicTools["custom_default_v_a"]

0 commit comments

Comments
 (0)