feat: reload MCP config on change via lazy stat-on-request (#24700)

The MCP manager previously read .mcp.json exactly once at agent startup.
Editing the file had no effect until workspace rebuild or agent restart.

handleListTools now stats config file mtimes on every tool-list request
and triggers a differential reload when any file changed. Unchanged
servers keep their client pointer so in-flight tool calls survive.
Concurrent reload requests coalesce via singleflight.

MCP stdio subprocesses use the agent's execer for resource limits and
receive the same enriched environment as SSH sessions via updateEnv.

On the chatd side, WorkspaceMCPTool.Run detects 404 responses from
CallMCPTool (indicating the server was removed) and drops the chat's
cached tool list so the next turn refetches from the agent.
This commit is contained in:
Mathias Fredriksson
2026-04-28 19:47:14 +03:00
committed by GitHub
parent 3f0e015fe5
commit 881df9a5b0
9 changed files with 1658 additions and 219 deletions
+4 -2
View File
@@ -249,8 +249,9 @@ func (p *Server) loadCachedWorkspaceContext(
}
var tools []fantasy.AgentTool
invalidate := func() { p.workspaceMCPToolsCache.Delete(chatID) }
for _, t := range entry.tools {
tools = append(tools, chattool.NewWorkspaceMCPTool(t, getConn))
tools = append(tools, chattool.NewWorkspaceMCPTool(t, getConn, invalidate))
}
return tools
@@ -6290,9 +6291,10 @@ func (p *Server) runChat(
}
}
invalidate := func() { p.workspaceMCPToolsCache.Delete(chat.ID) }
for _, t := range toolsResp.Tools {
workspaceMCPTools = append(workspaceMCPTools,
chattool.NewWorkspaceMCPTool(t, workspaceCtx.getWorkspaceConn),
chattool.NewWorkspaceMCPTool(t, workspaceCtx.getWorkspaceConn, invalidate),
)
}
return nil
+23 -5
View File
@@ -4,10 +4,13 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"charm.land/fantasy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
@@ -16,17 +19,22 @@ import (
// connection. It implements fantasy.AgentTool so it can be
// registered alongside built-in chat tools.
type WorkspaceMCPTool struct {
info fantasy.ToolInfo
getConn func(context.Context) (workspacesdk.AgentConn, error)
providerOpts fantasy.ProviderOptions
info fantasy.ToolInfo
getConn func(context.Context) (workspacesdk.AgentConn, error)
providerOpts fantasy.ProviderOptions
invalidateCache func()
}
// NewWorkspaceMCPTool creates a tool wrapper from an MCPToolInfo
// discovered on a workspace agent. Each tool proxies calls back
// through the agent connection.
// through the agent connection. The optional invalidateCache
// callback is invoked when CallMCPTool returns a 404 error,
// indicating that the server was removed and the chat's cached
// tool list should be dropped.
func NewWorkspaceMCPTool(
tool workspacesdk.MCPToolInfo,
getConn func(context.Context) (workspacesdk.AgentConn, error),
invalidateCache func(),
) *WorkspaceMCPTool {
required := tool.Required
if required == nil {
@@ -40,7 +48,8 @@ func NewWorkspaceMCPTool(
Required: required,
Parallel: true,
},
getConn: getConn,
getConn: getConn,
invalidateCache: invalidateCache,
}
}
@@ -75,6 +84,15 @@ func (t *WorkspaceMCPTool) Run(
Arguments: args,
})
if err != nil {
// If the agent returns a 404 (ErrUnknownServer), the
// server was removed or renamed. Invalidate the chat's
// cached tool list so the next turn refetches.
var coderErr *codersdk.Error
if errors.As(err, &coderErr) && coderErr.StatusCode() == http.StatusNotFound {
if t.invalidateCache != nil {
t.invalidateCache()
}
}
return fantasy.NewTextErrorResponse(err.Error()), nil
}
@@ -0,0 +1,155 @@
package chattool_test
import (
"context"
"net/http"
"sync/atomic"
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// fakeAgentConn implements just enough of workspacesdk.AgentConn
// for testing CallMCPTool.
type fakeAgentConn struct {
workspacesdk.AgentConn
callMCPToolFunc func(ctx context.Context, req workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error)
}
func (f *fakeAgentConn) CallMCPTool(ctx context.Context, req workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) {
return f.callMCPToolFunc(ctx, req)
}
func TestWorkspaceMCPTool_InvalidateOn404(t *testing.T) {
t.Parallel()
t.Run("404ErrorInvalidatesCache", func(t *testing.T) {
t.Parallel()
var invalidated atomic.Bool
tool := chattool.NewWorkspaceMCPTool(
workspacesdk.MCPToolInfo{
Name: "test__echo",
Description: "test tool",
},
func(ctx context.Context) (workspacesdk.AgentConn, error) {
return &fakeAgentConn{
callMCPToolFunc: func(_ context.Context, _ workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) {
return workspacesdk.CallMCPToolResponse{}, codersdk.NewError(
http.StatusNotFound,
codersdk.Response{
Message: "MCP tool call failed.",
Detail: `unknown MCP server: "test"`,
},
)
},
}, nil
},
func() { invalidated.Store(true) },
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{})
require.NoError(t, err)
assert.True(t, resp.IsError, "response should be an error")
assert.True(t, invalidated.Load(),
"invalidateCache should fire on 404")
})
t.Run("Non404DoesNotInvalidate", func(t *testing.T) {
t.Parallel()
var invalidated atomic.Bool
tool := chattool.NewWorkspaceMCPTool(
workspacesdk.MCPToolInfo{
Name: "test__echo",
Description: "test tool",
},
func(ctx context.Context) (workspacesdk.AgentConn, error) {
return &fakeAgentConn{
callMCPToolFunc: func(_ context.Context, _ workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) {
return workspacesdk.CallMCPToolResponse{}, codersdk.NewError(
http.StatusBadGateway,
codersdk.Response{
Message: "Bad Gateway",
},
)
},
}, nil
},
func() { invalidated.Store(true) },
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.False(t, invalidated.Load(),
"invalidateCache should NOT fire on non-404 error")
})
t.Run("ToolLevelErrorNoInvalidation", func(t *testing.T) {
t.Parallel()
var invalidated atomic.Bool
tool := chattool.NewWorkspaceMCPTool(
workspacesdk.MCPToolInfo{
Name: "test__echo",
Description: "test tool",
},
func(ctx context.Context) (workspacesdk.AgentConn, error) {
return &fakeAgentConn{
callMCPToolFunc: func(_ context.Context, _ workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) {
return workspacesdk.CallMCPToolResponse{
IsError: true,
Content: []workspacesdk.MCPToolContent{
{Type: "text", Text: "tool error"},
},
}, nil
},
}, nil
},
func() { invalidated.Store(true) },
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.False(t, invalidated.Load(),
"invalidateCache should NOT fire on tool-level error (HTTP 200)")
})
t.Run("NilInvalidateCallbackSafe", func(t *testing.T) {
t.Parallel()
tool := chattool.NewWorkspaceMCPTool(
workspacesdk.MCPToolInfo{
Name: "test__echo",
Description: "test tool",
},
func(ctx context.Context) (workspacesdk.AgentConn, error) {
return &fakeAgentConn{
callMCPToolFunc: func(_ context.Context, _ workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) {
return workspacesdk.CallMCPToolResponse{}, codersdk.NewError(
http.StatusNotFound,
codersdk.Response{
Message: "MCP tool call failed.",
Detail: `unknown MCP server: "test"`,
},
)
},
}, nil
},
nil,
)
// Should not panic.
resp, err := tool.Run(context.Background(), fantasy.ToolCall{})
require.NoError(t, err)
assert.True(t, resp.IsError)
})
}