mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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:
1 parent
dcd37a10ef
commit
650b2c7acd
3 files changed
+105
-1
No files matched your search
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in new issue
Block a user