-
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
Closed
Closed
Changes from 11 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
4617ce2
feat: Support hot reloading of MCP operation files
melsonic 8df7113
added mcp hot reload tests
melsonic 6b9e161
added polling based watcher, removed inotify
melsonic 9ed9577
fixed leaked goroutines
melsonic dbcc024
updated mcp hot reload tests + added shutdown test
melsonic 3a5741c
Merge branch 'main' into cosmo-issue-1886
melsonic 84912f3
added directory watching capability to cosmo watcher with filter
melsonic 5a6a9a2
fix coderabbitai comments
melsonic 01d0551
extracted list dir files func + removed redundant maps
melsonic e84de46
added new watcher tests
melsonic 12d12df
minor change
melsonic 112a153
fail watcher if both paths & dir are empty + consistent change detection
melsonic d6ba838
extracted mcp shutdown tests to lifecycle dir
melsonic a4f512e
Merge branch 'main' into cosmo-issue-1886
melsonic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,277 @@ | ||
| package integration | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "path/filepath" | ||
| "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() | ||
| operationsDir := t.TempDir() | ||
| storageProviderID := "mcp_hot_reload_test_id" | ||
|
|
||
| t.Run("List Updated User Operations On Addition and Removal", func(t *testing.T) { | ||
|
|
||
| testenv.Run(t, &testenv.Config{ | ||
| MCP: config.MCPConfiguration{ | ||
| Enabled: true, | ||
| Storage: config.MCPStorageConfig{ | ||
| ProviderID: storageProviderID, | ||
| }, | ||
| HotReloadConfig: config.MCPOperationsHotReloadConfig{ | ||
| Enabled: true, | ||
| Interval: 1 * 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) | ||
|
|
||
| mcpOperationFile := filepath.Join(operationsDir, "main.graphql") | ||
|
|
||
| // write mcp operation content | ||
| err = os.WriteFile(mcpOperationFile, []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), | ||
| }, | ||
| }) | ||
| }, 10*time.Second, 100*time.Millisecond) | ||
|
|
||
| err = os.Remove(mcpOperationFile) | ||
| 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), | ||
| }, | ||
| }) | ||
|
|
||
| }, 10*time.Second, 100*time.Millisecond) | ||
|
|
||
| }) | ||
| }) | ||
|
|
||
| t.Run("List Updated User Operations On Content Update", func(t *testing.T) { | ||
|
|
||
| testenv.Run(t, &testenv.Config{ | ||
| MCP: config.MCPConfiguration{ | ||
| Enabled: true, | ||
| Storage: config.MCPStorageConfig{ | ||
| ProviderID: storageProviderID, | ||
| }, | ||
| HotReloadConfig: config.MCPOperationsHotReloadConfig{ | ||
| Enabled: true, | ||
| Interval: 1 * time.Second, | ||
| }, | ||
| }, | ||
| RouterOptions: []core.Option{ | ||
| core.WithStorageProviders(config.StorageProviders{ | ||
| FileSystem: []config.FileSystemStorageProvider{ | ||
| { | ||
| ID: storageProviderID, | ||
| Path: operationsDir, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }, func(t *testing.T, xEnv *testenv.Environment) { | ||
|
|
||
| mcpOperationFile := filepath.Join(operationsDir, "main.graphql") | ||
|
|
||
| // write mcp operation content | ||
| require.NoError(t, os.WriteFile(mcpOperationFile, []byte("query getEmployeeNotes($id: Int!) {\nemployee(id: $id) {\nid\nnotes\n}\n}"), 0o600)) | ||
|
|
||
| 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), | ||
| }, | ||
| }) | ||
| }, 10*time.Second, 100*time.Millisecond) | ||
|
|
||
| // update mcp operation content | ||
| require.NoError(t, os.WriteFile(mcpOperationFile, []byte("\nquery getEmployeeNotesUpdatedTitle($id: Int!) {\nemployee(id: $id) {\nid\nnotes\n}\n}"), 0o600)) | ||
|
|
||
| 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), | ||
| }, | ||
| }) | ||
| }, 10*time.Second, 100*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: 1 * time.Second, | ||
| }, | ||
| }, | ||
| RouterOptions: []core.Option{ | ||
| core.WithStorageProviders(config.StorageProviders{ | ||
| FileSystem: []config.FileSystemStorageProvider{ | ||
| { | ||
| ID: storageProviderID, | ||
| Path: operationsDir, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }) | ||
|
|
||
| require.NoError(t, err) | ||
|
|
||
| mcpOperationFile := filepath.Join(operationsDir, "main.graphql") | ||
| // write mcp operation content | ||
| require.NoError(t, os.WriteFile(mcpOperationFile, []byte("query getEmployeeNotes($id: Int!) {\nemployee(id: $id) {\nid\nnotes\n}\n}"), 0o600)) | ||
|
|
||
| // 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), | ||
| }, | ||
| }) | ||
| }, 10*time.Second, 100*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) | ||
|
|
||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
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.
Handle reload errors without terminating the goroutine
If
Reloadreturns an error, the goroutine exits and no further hot reloads will be processed. This could leave the system unable to recover from transient reload failures.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
🤖 Prompt for AI Agents
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.
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 comment
The reason will be displayed to describe this comment to others. Learn more.