diff --git a/core/services/studio/mcpapps_test.go b/core/services/studio/mcpapps_test.go index 9e42dddf21..25a38daeba 100644 --- a/core/services/studio/mcpapps_test.go +++ b/core/services/studio/mcpapps_test.go @@ -44,6 +44,14 @@ func appsMCPServer() *httptest.Server { add("refresh", mcp.Meta{"ui": map[string]any{"resourceUri": "ui://clock/app.html", "visibility": []any{"app"}}}, "refreshed ") add("model_only", mcp.Meta{"ui": map[string]any{"resourceUri": "ui://clock/app.html", "visibility": []any{"model"}}}, "model ") add("plain", nil, "plain ") + mcp.AddTool(srv, &mcp.Tool{Name: "slow", Meta: mcp.Meta{"ui": map[string]any{"resourceUri": "ui://clock/app.html"}}}, + func(ctx context.Context, _ *mcp.CallToolRequest, _ appIn) (*mcp.CallToolResult, any, error) { + select { + case <-time.After(30 * time.Second): + case <-ctx.Done(): + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "slept"}}}, nil, nil + }) add("big", mcp.Meta{"ui": map[string]any{"resourceUri": "ui://big/app.html"}}, "big ") add("json", mcp.Meta{"ui": map[string]any{"resourceUri": "ui://json/app.html"}}, "json ") page := func(uri, mime, body string, meta mcp.Meta) { @@ -73,8 +81,18 @@ type appsEnv struct { } func newAppsEnv(turns ...[]sessiontest.Step) *appsEnv { + return newAppsEnvWith(nil, turns...) +} + +// newAppsEnvWith is newAppsEnv with the options of the connector service +// changed by tune. +func newAppsEnvWith(tune func(*mcpconns.Options), turns ...[]sessiontest.Step) *appsEnv { ts := appsMCPServer() - conns, _ := realConnectors(true) + var tuners []func(*mcpconns.Options) + if tune != nil { + tuners = append(tuners, tune) + } + conns, _ := realConnectors(true, tuners...) in := mcpconns.Input{Name: "Clock", Transport: mcpconns.TransportHTTP, URL: ts.URL, Enabled: true} token := "tok-clock-secret" in.Token = &token @@ -132,6 +150,16 @@ func toolEntries(svc *studio.Service, user, id string) []session.Entry { } var _ = Describe("mcpapps", func() { + It("ends an app tool call that outlasts the call time limit as a failure with no credential", func() { + e := newAppsEnvWith(func(o *mcpconns.Options) { o.CallTimeout = time.Second }) + id := create(e.svc, alice) + start := time.Now() + _, err := e.svc.MCPAppCall(context.Background(), alice, id, e.ref(), "slow", json.RawMessage(`{"zone":"UTC"}`)) + Expect(err).To(MatchError(studio.ErrMCPAppFailed)) + Expect(time.Since(start)).To(BeNumerically("<", 10*time.Second)) + Expect(err.Error()).NotTo(ContainSubstring("tok-clock-secret")) + }) + It("a connector tool with a view gives the transcript an app reference", func() { // The model's own server has the same tool names, so the connector's tools // take the connector's name in front; the app still names the tool of the server. diff --git a/core/services/studio/mcpconnectors_e2e_test.go b/core/services/studio/mcpconnectors_e2e_test.go index a3a5a33e08..a269cccb39 100644 --- a/core/services/studio/mcpconnectors_e2e_test.go +++ b/core/services/studio/mcpconnectors_e2e_test.go @@ -39,14 +39,18 @@ func e2eServer() (*httptest.Server, *atomic.Int32) { return ts, &reached } -func realConnectors(allowPrivate bool) (*mcpconns.Service, mcpconns.Store) { +func realConnectors(allowPrivate bool, tune ...func(*mcpconns.Options)) (*mcpconns.Service, mcpconns.Store) { ms, err := mcpconns.NewJSON("") Expect(err).NotTo(HaveOccurred()) cs, _ := connectors.NewJSON("") - svc, err := mcpconns.New(mcpconns.Options{ + o := mcpconns.Options{ Store: ms, Key: func(ctx context.Context) ([]byte, error) { return cs.EnsureKey(ctx, connectors.NewKey) }, AllowPrivate: func() bool { return allowPrivate }, - }) + } + for _, t := range tune { + t(&o) + } + svc, err := mcpconns.New(o) Expect(err).NotTo(HaveOccurred()) DeferCleanup(svc.Close) return svc, ms diff --git a/tests/e2e-platform/mcp_apps_test.go b/tests/e2e-platform/mcp_apps_test.go index aa252185f1..3431cec9e8 100644 --- a/tests/e2e-platform/mcp_apps_test.go +++ b/tests/e2e-platform/mcp_apps_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/url" "strings" - "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -180,15 +179,6 @@ var _ = Describe("MCP Apps", Label("platform", "mcp", "mcp-apps"), Ordered, func Expect(code).To(Equal(http.StatusForbidden)) }) - It("ends a tool call that outlasts the 60 second limit with a 502 and no credential", Label("slow"), func() { - start := time.Now() - code, out := call(alice, chat, server, "slow", obj{"millis": 90000}) - Expect(code).To(Equal(http.StatusBadGateway), string(out)) - Expect(time.Since(start)).To(BeNumerically("<", 80*time.Second)) - Expect(string(out)).To(ContainSubstring("mcp_app_failed")) - Expect(string(out)).NotTo(ContainSubstring("APP-SECRET")) - }) - It("refuses both routes without a credential", func() { anon := newAPI(in.api.base) code, _ := anon.do(http.MethodGet, resPath(chat, server, "ui://clock/app.html"), nil)