-
Notifications
You must be signed in to change notification settings - Fork 191
feat: Support hot reloading of MCP operation files #1959
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
4617ce2
8df7113
6b9e161
9ed9577
dbcc024
3a5741c
84912f3
5a6a9a2
01d0551
e84de46
12d12df
112a153
d6ba838
a4f512e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,282 @@ | ||
| package integration | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/mark3labs/mcp-go/mcp" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| "github.com/wundergraph/cosmo/router-tests/testenv" | ||
| "github.com/wundergraph/cosmo/router/core" | ||
| "github.com/wundergraph/cosmo/router/pkg/config" | ||
| "go.uber.org/goleak" | ||
| ) | ||
|
|
||
| func TestMCPOperationHotReload(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| t.Run("List Updated User Operations On Addition and Removal", func(t *testing.T) { | ||
|
|
||
| operationsDir := t.TempDir() | ||
| storageProviderId := "mcp_hot_reload_test_id" | ||
|
|
||
| testenv.Run(t, &testenv.Config{ | ||
| MCP: config.MCPConfiguration{ | ||
| Enabled: true, | ||
| Storage: config.MCPStorageConfig{ | ||
| ProviderID: storageProviderId, | ||
| }, | ||
| HotReloadConfig: config.MCPOperationsHotReloadConfig{ | ||
| Enabled: true, | ||
| Interval: 5 * time.Second, | ||
| }, | ||
| }, | ||
| RouterOptions: []core.Option{ | ||
| core.WithStorageProviders(config.StorageProviders{ | ||
| FileSystem: []config.FileSystemStorageProvider{ | ||
| { | ||
| ID: storageProviderId, | ||
| Path: operationsDir, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }, func(t *testing.T, xEnv *testenv.Environment) { | ||
|
|
||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| require.NoError(t, err) | ||
|
|
||
| initialToolsCount := len(resp.Tools) | ||
|
|
||
| filePath := operationsDir + "/main.graphql" | ||
|
||
|
|
||
| // write mcp operation content | ||
| err = os.WriteFile(filePath, []byte("query getEmployeeNotes($id: Int!) {\nemployee(id: $id) {\nid\nnotes\n}\n}"), 0644) | ||
| assert.NoError(t, err) | ||
|
|
||
| require.EventuallyWithT(t, func(t *assert.CollectT) { | ||
|
|
||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| assert.NoError(t, err) | ||
| assert.Len(t, resp.Tools, initialToolsCount+1) | ||
|
|
||
| // verity getEmployeeNotes operation is present | ||
| require.Contains(t, resp.Tools, mcp.Tool{ | ||
| Name: "execute_operation_get_employee_notes", | ||
| Description: "Executes the GraphQL operation 'getEmployeeNotes' of type query.", | ||
| InputSchema: mcp.ToolInputSchema{ | ||
| Type: "object", | ||
| Properties: map[string]interface{}{"id": map[string]interface{}{"type": "integer"}}, | ||
| Required: []string{"id"}, | ||
| }, | ||
| RawInputSchema: json.RawMessage(nil), | ||
| Annotations: mcp.ToolAnnotation{ | ||
| Title: "Execute operation getEmployeeNotes", | ||
| ReadOnlyHint: mcp.ToBoolPtr(true), | ||
| IdempotentHint: mcp.ToBoolPtr(true), | ||
| OpenWorldHint: mcp.ToBoolPtr(true), | ||
| }, | ||
| }) | ||
| }, 15*time.Second, 250*time.Millisecond) | ||
|
|
||
| err = os.Remove(filePath) | ||
| assert.NoError(t, err) | ||
|
|
||
| assert.EventuallyWithT(t, func(t *assert.CollectT) { | ||
|
|
||
| resp, err = xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| assert.NoError(t, err) | ||
| assert.Len(t, resp.Tools, initialToolsCount) | ||
|
|
||
| // verity getEmployeeNotes operation tool is properly removed | ||
| require.NotContains(t, resp.Tools, mcp.Tool{ | ||
| Name: "execute_operation_get_employee_notes", | ||
| Description: "Executes the GraphQL operation 'getEmployeeNotes' of type query.", | ||
| InputSchema: mcp.ToolInputSchema{ | ||
| Type: "object", | ||
| Properties: map[string]interface{}{"id": map[string]interface{}{"type": "integer"}}, | ||
| Required: []string{"id"}, | ||
| }, | ||
| RawInputSchema: json.RawMessage(nil), | ||
| Annotations: mcp.ToolAnnotation{ | ||
| Title: "Execute operation getEmployeeNotes", | ||
| ReadOnlyHint: mcp.ToBoolPtr(true), | ||
| IdempotentHint: mcp.ToBoolPtr(true), | ||
| OpenWorldHint: mcp.ToBoolPtr(true), | ||
| }, | ||
| }) | ||
|
|
||
| }, 15*time.Second, 250*time.Millisecond) | ||
|
||
|
|
||
| }) | ||
| }) | ||
|
|
||
| t.Run("List Updated User Operations On Content Update", func(t *testing.T) { | ||
| operationsDir := t.TempDir() | ||
| storageProviderId := "mcp_hot_reload_test_id" | ||
|
|
||
| testenv.Run(t, &testenv.Config{ | ||
| MCP: config.MCPConfiguration{ | ||
| Enabled: true, | ||
| Storage: config.MCPStorageConfig{ | ||
| ProviderID: storageProviderId, | ||
| }, | ||
| HotReloadConfig: config.MCPOperationsHotReloadConfig{ | ||
| Enabled: true, | ||
| Interval: 5 * time.Second, | ||
| }, | ||
| }, | ||
| RouterOptions: []core.Option{ | ||
| core.WithStorageProviders(config.StorageProviders{ | ||
| FileSystem: []config.FileSystemStorageProvider{ | ||
| { | ||
| ID: storageProviderId, | ||
| Path: operationsDir, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }, func(t *testing.T, xEnv *testenv.Environment) { | ||
|
|
||
| filePath := operationsDir + "/main.graphql" | ||
|
|
||
| // write mcp operation content | ||
| err := os.WriteFile(filePath, []byte("query getEmployeeNotes($id: Int!) {\nemployee(id: $id) {\nid\nnotes\n}\n}"), 0644) | ||
| assert.NoError(t, err) | ||
|
|
||
| require.EventuallyWithT(t, func(t *assert.CollectT) { | ||
|
|
||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| assert.NoError(t, err) | ||
|
|
||
| // verity getEmployeeNotes operation is present | ||
| require.Contains(t, resp.Tools, mcp.Tool{ | ||
| Name: "execute_operation_get_employee_notes", | ||
| Description: "Executes the GraphQL operation 'getEmployeeNotes' of type query.", | ||
| InputSchema: mcp.ToolInputSchema{ | ||
| Type: "object", | ||
| Properties: map[string]interface{}{"id": map[string]interface{}{"type": "integer"}}, | ||
| Required: []string{"id"}, | ||
| }, | ||
| RawInputSchema: json.RawMessage(nil), | ||
| Annotations: mcp.ToolAnnotation{ | ||
| Title: "Execute operation getEmployeeNotes", | ||
| ReadOnlyHint: mcp.ToBoolPtr(true), | ||
| IdempotentHint: mcp.ToBoolPtr(true), | ||
| OpenWorldHint: mcp.ToBoolPtr(true), | ||
| }, | ||
| }) | ||
| }, 15*time.Second, 250*time.Millisecond) | ||
|
|
||
| // update mcp operation content | ||
| err = os.WriteFile(filePath, []byte("\nquery getEmployeeNotesUpdatedTitle($id: Int!) {\nemployee(id: $id) {\nid\nnotes\n}\n}"), 0644) | ||
| assert.NoError(t, err) | ||
|
|
||
| require.EventuallyWithT(t, func(t *assert.CollectT) { | ||
|
|
||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| assert.NoError(t, err) | ||
|
|
||
| // verity getEmployeeNotesUpdatedTitle operation is present | ||
| require.Contains(t, resp.Tools, mcp.Tool{ | ||
| Name: "execute_operation_get_employee_notes_updated_title", | ||
| Description: "Executes the GraphQL operation 'getEmployeeNotesUpdatedTitle' of type query.", | ||
| InputSchema: mcp.ToolInputSchema{ | ||
| Type: "object", | ||
| Properties: map[string]interface{}{"id": map[string]interface{}{"type": "integer"}}, | ||
| Required: []string{"id"}, | ||
| }, | ||
| RawInputSchema: json.RawMessage(nil), | ||
| Annotations: mcp.ToolAnnotation{ | ||
| Title: "Execute operation getEmployeeNotesUpdatedTitle", | ||
| ReadOnlyHint: mcp.ToBoolPtr(true), | ||
| IdempotentHint: mcp.ToBoolPtr(true), | ||
| OpenWorldHint: mcp.ToBoolPtr(true), | ||
| }, | ||
| }) | ||
| }, 15*time.Second, 250*time.Millisecond) | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| func TestShutDownMCPGoRoutineLeaks(t *testing.T) { | ||
|
|
||
| defer goleak.VerifyNone(t, | ||
| goleak.IgnoreTopFunction("github.com/hashicorp/consul/sdk/freeport.checkFreedPorts"), // Freeport, spawned by init | ||
| goleak.IgnoreAnyFunction("net/http.(*conn).serve"), // HTTPTest server I can't close if I want to keep the problematic goroutine open for the test | ||
| ) | ||
|
|
||
| operationsDir := t.TempDir() | ||
| storageProviderId := "mcp_hot_reload_test_id" | ||
|
|
||
| xEnv, err := testenv.CreateTestEnv(t, &testenv.Config{ | ||
| MCP: config.MCPConfiguration{ | ||
| Enabled: true, | ||
| Storage: config.MCPStorageConfig{ | ||
| ProviderID: storageProviderId, | ||
| }, | ||
| HotReloadConfig: config.MCPOperationsHotReloadConfig{ | ||
| Enabled: true, | ||
| Interval: 5 * time.Second, | ||
| }, | ||
| }, | ||
| RouterOptions: []core.Option{ | ||
| core.WithStorageProviders(config.StorageProviders{ | ||
| FileSystem: []config.FileSystemStorageProvider{ | ||
| { | ||
| ID: storageProviderId, | ||
| Path: operationsDir, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }) | ||
|
|
||
| require.NoError(t, err) | ||
|
|
||
| filePath := operationsDir + "/main.graphql" | ||
| // write mcp operation content | ||
| err = os.WriteFile(filePath, []byte("query getEmployeeNotes($id: Int!) {\nemployee(id: $id) {\nid\nnotes\n}\n}"), 0644) | ||
| assert.NoError(t, err) | ||
|
|
||
| // Verify GoRoutines are properly setup for Hot Reloading | ||
| require.EventuallyWithT(t, func(t *assert.CollectT) { | ||
|
|
||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| assert.NoError(t, err) | ||
|
|
||
| require.Contains(t, resp.Tools, mcp.Tool{ | ||
| Name: "execute_operation_get_employee_notes", | ||
| Description: "Executes the GraphQL operation 'getEmployeeNotes' of type query.", | ||
| InputSchema: mcp.ToolInputSchema{ | ||
| Type: "object", | ||
| Properties: map[string]interface{}{"id": map[string]interface{}{"type": "integer"}}, | ||
| Required: []string{"id"}, | ||
| }, | ||
| RawInputSchema: json.RawMessage(nil), | ||
| Annotations: mcp.ToolAnnotation{ | ||
| Title: "Execute operation getEmployeeNotes", | ||
| ReadOnlyHint: mcp.ToBoolPtr(true), | ||
| IdempotentHint: mcp.ToBoolPtr(true), | ||
| OpenWorldHint: mcp.ToBoolPtr(true), | ||
| }, | ||
| }) | ||
| }, 15*time.Second, 250*time.Millisecond) | ||
|
|
||
| xEnv.Shutdown() | ||
|
|
||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| if assert.Error(t, err) { | ||
| require.ErrorIs(t, err, testenv.ErrEnvironmentClosed) | ||
| } | ||
| require.Nil(t, resp) | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1079,9 +1079,22 @@ func (s *graphServer) buildGraphMux(ctx context.Context, | |||||||||||||||||
|
|
||||||||||||||||||
| // We support the MCP only on the base graph. Feature flags are not supported yet. | ||||||||||||||||||
| if featureFlagName == "" && s.mcpServer != nil { | ||||||||||||||||||
| if mErr := s.mcpServer.Reload(executor.ClientSchema); mErr != nil { | ||||||||||||||||||
| if mErr := s.mcpServer.Reload(ctx, executor.ClientSchema); mErr != nil { | ||||||||||||||||||
| return nil, fmt.Errorf("failed to reload MCP server: %w", mErr) | ||||||||||||||||||
| } | ||||||||||||||||||
| go func() { | ||||||||||||||||||
| for { | ||||||||||||||||||
| select { | ||||||||||||||||||
| case <-s.mcpServer.ReloadOperationsChannel(): | ||||||||||||||||||
| s.logger.Log(zap.InfoLevel, "Reloading mcp server!") | ||||||||||||||||||
| if mErr := s.mcpServer.Reload(ctx, executor.ClientSchema); mErr != nil { | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+1221
to
+1223
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle reload errors without terminating the goroutine If Consider logging the error and continuing to listen for reload signals: case <-s.mcpServer.ReloadOperationsChannel():
s.logger.Log(zap.InfoLevel, "Reloading mcp server!")
if mErr := s.mcpServer.Reload(ctx, executor.ClientSchema); mErr != nil {
- return
+ s.logger.Error("Failed to reload MCP server", zap.Error(mErr))
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @endigma, Please let me know if it is the expected behaviour to further retry mcpServer reload operations even after reload failure. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||
| case <-ctx.Done(): | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| }() | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if s.Config.cacheWarmup != nil && s.Config.cacheWarmup.Enabled { | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
storageProviderIdshould beIDand it seems the same across all subtests so can we move it out to test scope?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we can.