diff --git a/core/application/studio_mcpconns.go b/core/application/studio_mcpconns.go index cdf8f291be..3d1e0cbdf9 100644 --- a/core/application/studio_mcpconns.go +++ b/core/application/studio_mcpconns.go @@ -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 { diff --git a/core/application/studio_mcpconns_timeout_internal_test.go b/core/application/studio_mcpconns_timeout_internal_test.go new file mode 100644 index 0000000000..15b9191d39 --- /dev/null +++ b/core/application/studio_mcpconns_timeout_internal_test.go @@ -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)) + }) +}) diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index bad2812ec0..1b06f49cee 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -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).