fix(chats): give MCP connector tool calls the MCP tool timeout

A tool call through an MCP connector was cut after the connector
package default of 60 seconds, while the model's own MCP servers allow
360 seconds. Research and document tools behind a connector take
minutes, so a saved agent lost them mid-turn with "connection closed:
calling tools/call: client is closing" and the model fell back to a
weaker tool.

The connector service now gets the MCP tool timeout the rest of
LocalAI uses (DistributedConfig.MCPToolTimeoutOrDefault, 360 s by
default). The package default and the per-call output cap are
unchanged.

Tests in core/application start the connector service on JSON stores
against a slow MCP server: the timeout defaults to the MCP tool
timeout, a tool slower than the old cap but within the timeout
returns, and a tool past the timeout is still stopped.

Docs: chats-connectors.md, "How chats use them".

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
(cherry picked from commit 99f663a777)
This commit is contained in:
Stefan Walcz authored and Ettore Di Giacinto committed 2026-11-03 09:17:42 +00:00
1 parent dcd37a10ef
commit 650b2c7acd
3 files changed
+105 -1

No files matched your search

+11
View File
@@ -2,6 +2,7 @@ package application
import (
"context"
"time"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/studio"
@@ -28,6 +29,7 @@ func (a *Application) initStudioMCPConnectors() {
AllowPrivate: func() bool { return a.applicationConfig.MCPConnectorsAllowPrivate },
// A local command would run on whichever frontend serves the request.
Distributed: a.Distributed() != nil,
CallTimeout: a.studioMCPCallTimeout(),
})
if err != nil {
return
@@ -35,6 +37,15 @@ func (a *Application) initStudioMCPConnectors() {
a.studioMCPConns = svc
}
// studioMCPCallTimeout is how long a connector tool call may run: the MCP tool
// timeout the model's MCP servers have (360 s unless the distributed config
// sets another), not the connector package default of 60 s. A research or
// document tool behind a connector takes minutes; at 60 s the chat dropped it
// ("client is closing").
func (a *Application) studioMCPCallTimeout() time.Duration {
return a.applicationConfig.Distributed.MCPToolTimeoutOrDefault()
}
// studioMCPPermitted says whether a user may use MCP now: the server has not
// turned it off and the user has the MCP feature. It is asked on every use.
func (a *Application) studioMCPPermitted(ctx context.Context, user string) bool {
@@ -0,0 +1,93 @@
package application
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/core/services/studio/mcpconns"
)
type slowIn struct {
Text string `json:"text"`
}
// slowMCPServer serves one tool, "slow", that answers after d.
func slowMCPServer(d time.Duration) *httptest.Server {
srv := mcp.NewServer(&mcp.Implementation{Name: "slow", Version: "1"}, nil)
mcp.AddTool(srv, &mcp.Tool{Name: "slow", Description: "sleeps"}, func(ctx context.Context, _ *mcp.CallToolRequest, _ slowIn) (*mcp.CallToolResult, any, error) {
select {
case <-time.After(d):
case <-ctx.Done():
}
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, nil, nil
})
h := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil)
ts := httptest.NewServer(h)
DeferCleanup(func() { ts.CloseClientConnections(); ts.Close() })
return ts
}
// connectorApp builds an Application with the connector service on JSON
// stores, with the given MCP tool timeout (zero keeps the default).
func connectorApp(toolTimeout time.Duration) *Application {
dir := GinkgoT().TempDir()
ms, err := mcpconns.NewJSON(filepath.Join(dir, "mcp"))
Expect(err).NotTo(HaveOccurred())
cs, err := connectors.NewJSON(filepath.Join(dir, "connectors"))
Expect(err).NotTo(HaveOccurred())
cfg := &config.ApplicationConfig{MCPConnectorsAllowPrivate: true}
cfg.Distributed.MCPToolTimeout = toolTimeout
a := &Application{applicationConfig: cfg, studioMCPStore: ms, studioConnStore: cs}
a.initStudioMCPConnectors()
Expect(a.StudioMCPConnectors()).NotTo(BeNil())
DeferCleanup(a.StudioMCPConnectors().Close)
return a
}
func runSlow(a *Application, url string) (string, error) {
ctx := context.Background()
_, err := a.StudioMCPConnectors().Create(ctx, "u1", mcpconns.Input{Name: "Slow", Transport: mcpconns.TransportHTTP, URL: url, Enabled: true})
Expect(err).NotTo(HaveOccurred())
tools, _ := a.StudioMCPConnectors().Resolve(ctx, "u1", nil, true)
for _, t := range tools {
if t.Name == "slow" {
return t.Run(ctx, json.RawMessage(`{"text":"x"}`))
}
}
Fail("the connector offers no tool slow")
return "", nil
}
var _ = Describe("MCP connector call timeout", func() {
It("defaults to the MCP tool timeout, not the connector default", func() {
Expect(connectorApp(0).studioMCPCallTimeout()).To(Equal(config.DefaultMCPToolTimeout))
Expect(config.DefaultMCPToolTimeout).To(BeNumerically(">", mcpconns.DefaultCallTimeout))
})
It("lets a tool run longer than the connector default when the MCP tool timeout allows it", func() {
// The tool takes 1.2 s; the MCP tool timeout is 3 s. With a 1 s cap the
// call would fail, with the MCP tool timeout it returns.
a := connectorApp(3 * time.Second)
out, err := runSlow(a, slowMCPServer(1200*time.Millisecond).URL)
Expect(err).NotTo(HaveOccurred())
Expect(out).To(ContainSubstring("done"))
})
It("still stops a tool that runs past the MCP tool timeout", func() {
a := connectorApp(300 * time.Millisecond)
start := time.Now()
_, err := runSlow(a, slowMCPServer(5*time.Second).URL)
Expect(err).To(HaveOccurred())
Expect(time.Since(start)).To(BeNumerically("<", 3*time.Second))
})
})
+1 -1
View File
@@ -547,7 +547,7 @@ time limit to start and to answer. It runs on the frontend, as the LocalAI proce
namespace; it is dropped. When two connectors (or a connector and the model)
offer a tool with the same name, each gets the connector's name in front:
`docs__search`, `mail__search` (the connector's name in lower case, two underscores, the tool). The names do not change while the connectors stay as they are.
- **Limits.** A call has a time limit of 60 seconds, and its output is cut at 64 KiB like the output of the chat's own tools.
- **Limits.** A call has the time limit of the model's MCP tools (360 seconds), so a research or document tool that takes minutes finishes, and its output is cut at 64 KiB like the output of the chat's own tools.
- **Per chat switches.** The tools panel of a chat lists the connectors and the
model's MCP servers with a switch each. See [Chats](/features/chats/#mcp-tools).