Repository navigation
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 3 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,191 @@ | ||
| package integration | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "path" | ||
| "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/pkg/config" | ||
| ) | ||
|
|
||
| func TestMCPOperationHotReload(t *testing.T) { | ||
| // create a temp graphql file into mcp_operations | ||
| mcpOperationsDirectory := "./testdata/mcp_operations" | ||
| fileName := "getEmployeeNotes.graphql" | ||
| filePath := path.Join(mcpOperationsDirectory, fileName) | ||
|
|
||
| t.Parallel() | ||
|
|
||
| t.Run("List Updated User Operations On Addition and Removal", func(t *testing.T) { | ||
| testenv.Run(t, &testenv.Config{ | ||
| MCP: config.MCPConfiguration{ | ||
| Enabled: true, | ||
| HotReloadConfig: config.MCPOperationsHotReloadConfig{ | ||
| Enabled: true, | ||
| Interval: 5 * time.Second, | ||
| }, | ||
| }, | ||
| }, func(t *testing.T, xEnv *testenv.Environment) { | ||
|
|
||
| os.Remove(filePath) | ||
|
|
||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| require.NoError(t, err) | ||
| require.NotNil(t, resp) | ||
|
|
||
| // initial tools count | ||
| initialToolsCount := len(resp.Tools) | ||
|
|
||
| // create new mcp operation file | ||
| file, err := os.Create(filePath) | ||
| assert.NoError(t, err) | ||
| defer func() { | ||
| file.Close() | ||
| os.Remove(filePath) | ||
| }() | ||
|
endigma marked this conversation as resolved.
Outdated
|
||
|
|
||
| // write mcp operation content | ||
| _, err = file.WriteString(` | ||
| query getEmployeeNotes($id: Int!) { | ||
| employee(id: $id) { | ||
| id | ||
| notes | ||
| } | ||
| } | ||
| `) | ||
| assert.NoError(t, err) | ||
|
|
||
| require.EventuallyWithT(t, func(t *assert.CollectT) { | ||
| // List updated Tools | ||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| assert.NoError(t, err) | ||
|
|
||
| // verify updated tools count | ||
| assert.Len(t, resp.Tools, initialToolsCount+1) | ||
|
|
||
| // verity getEmployeeNotes operation | ||
| 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) | ||
|
|
||
| }) | ||
| }) | ||
|
|
||
| t.Run("List Updated User Operations On Content Update", func(t *testing.T) { | ||
| testenv.Run(t, &testenv.Config{ | ||
| MCP: config.MCPConfiguration{ | ||
| Enabled: true, | ||
| HotReloadConfig: config.MCPOperationsHotReloadConfig{ | ||
| Enabled: true, | ||
| Interval: 5 * time.Second, | ||
| }, | ||
| }, | ||
| }, func(t *testing.T, xEnv *testenv.Environment) { | ||
|
|
||
| os.Remove(filePath) | ||
|
|
||
| // create new mcp operation file | ||
| file, err := os.Create(filePath) | ||
| assert.NoError(t, err) | ||
| defer func() { | ||
| file.Close() | ||
| os.Remove(filePath) | ||
| }() | ||
|
|
||
| // write mcp operation content | ||
| _, err = file.WriteString(` | ||
| query getEmployeeNotes($id: Int!) { | ||
| employee(id: $id) { | ||
| id | ||
| notes | ||
| } | ||
| } | ||
| `) | ||
| assert.NoError(t, err) | ||
|
|
||
| require.EventuallyWithT(t, func(t *assert.CollectT) { | ||
| // List updated Tools | ||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| assert.NoError(t, err) | ||
|
|
||
| // verity getEmployeeNotes operation | ||
| 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(` | ||
| query getEmployeeNotesUpdatedTitle($id: Int!) { | ||
| employee(id: $id) { | ||
| id | ||
| notes | ||
| } | ||
| } | ||
| `), 0644) | ||
| assert.NoError(t, err) | ||
|
|
||
| require.EventuallyWithT(t, func(t *assert.CollectT) { | ||
| // fetch updated mcp tools list | ||
| toolsRequest := mcp.ListToolsRequest{} | ||
| resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) | ||
| assert.NoError(t, err) | ||
|
|
||
| // verity getEmployeeNotesUpdatedTitle operation | ||
| 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) | ||
| }) | ||
| }) | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1082,6 +1082,16 @@ func (s *graphServer) buildGraphMux(ctx context.Context, | |||||||||||||||||
| if mErr := s.mcpServer.Reload(executor.ClientSchema); mErr != nil { | ||||||||||||||||||
| return nil, fmt.Errorf("failed to reload MCP server: %w", mErr) | ||||||||||||||||||
| } | ||||||||||||||||||
| go func() { | ||||||||||||||||||
| for { | ||||||||||||||||||
| if reloadOperations := <-s.mcpServer.ReloadOperationsChannel(); reloadOperations { | ||||||||||||||||||
| s.logger.Log(zap.InfoLevel, "Reloading mcp server!") | ||||||||||||||||||
| if mErr := s.mcpServer.Reload(executor.ClientSchema); mErr != nil { | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+1221
to
+1223
Contributor
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.
Contributor
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.
|
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| }() | ||||||||||||||||||
|
endigma marked this conversation as resolved.
|
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if s.Config.cacheWarmup != nil && s.Config.cacheWarmup.Enabled { | ||||||||||||||||||
|
|
||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.