返回 DeepSeek-Reasonix
apps_meta_test.go
根目录 / internal / plugin / apps_meta_test.go
1 package plugin
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8 "time"
9
10 mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
11 "reasonix/internal/tool"
12 )
13
14 // appsFixtureServer advertises the Apps extension and serves tools with
15 // visibility/_meta.ui metadata, capturing tools/list as the client sees it.
16 type appsFixtureServer struct {
17 }
18
19 func (f *appsFixtureServer) server(t *testing.T, advertiseApps bool) *mcpsdk.Server {
20 t.Helper()
21 capabilities := &mcpsdk.ServerCapabilities{}
22 if advertiseApps {
23 capabilities.AddExtension(AppsUIExtensionID, map[string]any{"mimeTypes": []any{AppsMimeType}})
24 }
25 server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "apps-fixture", Version: "1"}, &mcpsdk.ServerOptions{
26 Capabilities: capabilities,
27 })
28 addAppTool := func(name string, meta map[string]any) {
29 t := &mcpsdk.Tool{
30 Name: name,
31 Description: name + " tool",
32 InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
33 Meta: mcpsdk.Meta(meta),
34 }
35 server.AddTool(t, func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
36 return &mcpsdk.CallToolResult{
37 Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: name + " done"}},
38 StructuredContent: map[string]any{"ok": true, "tool": name},
39 }, nil
40 })
41 }
42 addAppTool("both_tool", map[string]any{})
43 // Top-level visibility is retained for pre-stable servers.
44 addAppTool("model_only", map[string]any{"visibility": []string{"model"}})
45 addAppTool("app_only", map[string]any{
46 "ui": map[string]any{
47 "visibility": []string{"app"},
48 "resourceUri": "ui://stable/index.html",
49 },
50 })
51 addAppTool("app_rich", map[string]any{
52 "ui": map[string]any{
53 "resourceUri": "ui://app/rich.html",
54 "csp": map[string]any{"connect-src": []string{"https://api.example.com"}},
55 "visibility": []string{"model", "app"},
56 },
57 })
58 addAppTool("nested_wins", map[string]any{
59 "visibility": []string{"model"},
60 "ui": map[string]any{
61 "visibility": []string{"app"},
62 },
63 })
64 server.AddTool(&mcpsdk.Tool{
65 Name: "complete_result",
66 Description: "complete CallToolResult fixture",
67 InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
68 Meta: mcpsdk.Meta{"ui": map[string]any{"visibility": []string{"app"}}},
69 }, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
70 size := int64(12)
71 return &mcpsdk.CallToolResult{
72 Meta: mcpsdk.Meta{"trace": "app-call"},
73 Content: []mcpsdk.Content{
74 &mcpsdk.TextContent{Text: "complete result"},
75 &mcpsdk.ResourceLink{URI: "https://example.test/result", Name: "result", MIMEType: "application/json", Size: &size, Meta: mcpsdk.Meta{"resource": "metadata"}},
76 },
77 StructuredContent: map[string]any{"ok": false, "reason": "fixture"},
78 IsError: true,
79 }, nil
80 })
81 server.AddResource(
82 &mcpsdk.Resource{URI: "ui://app/rich.html", Name: "rich-app", MIMEType: "text/html;profile=mcp-app"},
83 func(context.Context, *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
84 return &mcpsdk.ReadResourceResult{Contents: []*mcpsdk.ResourceContents{{
85 URI: "ui://app/rich.html", MIMEType: "text/html;profile=mcp-app", Text: "<html>rich</html>",
86 Meta: mcpsdk.Meta{"ui": map[string]any{"csp": map[string]any{
87 "connectDomains": []string{"https://api.resource.example"},
88 "resourceDomains": []string{"https://cdn.resource.example"},
89 }}},
90 }}}, nil
91 },
92 )
93 return server
94 }
95
96 // startAppsClient connects a desktop-profile client through the real build
97 // path and returns the started Client plus the fixture for assertions.
98 func startAppsClientWithAgreement(t *testing.T, advertiseApps bool) (*Host, *Client, toolCatalogSnapshot, *appsFixtureServer) {
99 t.Helper()
100 fixture := &appsFixtureServer{}
101 host := NewHostWithProfile(HostProfileDesktopApps)
102 lifeCtx, cancel := context.WithCancel(context.Background())
103 transport := &sdkSessionTransport{
104 name: "apps-fixture",
105 spec: Spec{Name: "apps-fixture", Type: "http", StartupTimeout: 2 * time.Second},
106 profile: HostProfileDesktopApps,
107 lifeCtx: lifeCtx,
108 cancel: cancel,
109 state: SessionStateConnecting,
110 reconnectDelays: []time.Duration{time.Millisecond},
111 }
112 transport.endpointFactory = func(ctx context.Context) (sdkEndpoint, error) {
113 clientSide, serverSide := mcpsdk.NewInMemoryTransports()
114 go func() { _ = fixture.server(t, advertiseApps).Run(ctx, serverSide) }()
115 return sdkEndpoint{transport: clientSide}, nil
116 }
117 t.Cleanup(transport.close)
118 client := &Client{name: "apps-fixture", t: transport, spec: Spec{Name: "apps-fixture"}, profile: HostProfileDesktopApps, transport: "http"}
119 if err := client.initialize(t.Context()); err != nil {
120 t.Fatalf("initialize: %v", err)
121 }
122 if _, err := client.listTools(t.Context()); err != nil {
123 t.Fatalf("tools/list: %v", err)
124 }
125 client.toolsMu.RLock()
126 snapshot := client.toolCatalog
127 snapshot.infos = append([]ToolInfo(nil), snapshot.infos...)
128 snapshot.adapters = append([]tool.Tool(nil), snapshot.adapters...)
129 snapshot.appAdapters = append([]tool.Tool(nil), snapshot.appAdapters...)
130 client.toolsMu.RUnlock()
131 return host, client, snapshot, fixture
132 }
133
134 func startAppsClient(t *testing.T) (*Host, *Client, toolCatalogSnapshot, *appsFixtureServer) {
135 t.Helper()
136 return startAppsClientWithAgreement(t, true)
137 }
138
139 func TestMetaVisibilitySplitsCatalogs(t *testing.T) {
140 _, _, catalog, _ := startAppsClient(t)
141
142 var modelNames, appNames []string
143 for _, tl := range catalog.adapters {
144 modelNames = append(modelNames, tl.Name())
145 }
146 for _, tl := range catalog.appAdapters {
147 appNames = append(appNames, tl.Name())
148 }
149 joined := strings.Join(modelNames, ",")
150 for _, banned := range []string{"app_only", "nested_wins", "complete_result"} {
151 if strings.Contains(joined, banned) {
152 t.Fatalf("model catalog contains %s: %v", banned, modelNames)
153 }
154 }
155 for _, want := range []string{"both_tool", "model_only", "app_rich"} {
156 if !strings.Contains(joined, want) {
157 t.Fatalf("model catalog missing %s: %v", want, modelNames)
158 }
159 }
160 appJoined := strings.Join(appNames, ",")
161 for _, want := range []string{"both_tool", "app_only", "app_rich", "nested_wins", "complete_result"} {
162 if !strings.Contains(appJoined, want) {
163 t.Fatalf("app catalog missing %s: %v", want, appNames)
164 }
165 }
166 if strings.Contains(appJoined, "model_only") {
167 t.Fatalf("app catalog contains model-only tool: %v", appNames)
168 }
169 // ToolInfo (use_capability list source) must also exclude app-only.
170 for _, info := range catalog.infos {
171 if info.Name == "app_only" {
172 t.Fatal("app-only tool visible in ToolInfo list")
173 }
174 }
175 }
176
177 func TestMetaUIResourceNestedAndFlat(t *testing.T) {
178 _, _, catalog, _ := startAppsClient(t)
179 byName := map[string]tool.Tool{}
180 for _, tl := range catalog.appAdapters {
181 byName[tl.Name()] = tl
182 }
183 rich, ok := byName[toolName("apps-fixture", "app_rich")].(*remoteTool)
184 if !ok || rich.UIResourceURI() != "ui://app/rich.html" {
185 t.Fatalf("nested ui.resourceUri not parsed: %+v", rich)
186 }
187 if len(rich.UICSP()["connect-src"]) != 1 || rich.UICSP()["connect-src"][0] != "https://api.example.com" {
188 t.Fatalf("csp not parsed: %v", rich.UICSP())
189 }
190 legacy, ok := byName[toolName("apps-fixture", "app_only")].(*remoteTool)
191 if !ok || legacy.UIResourceURI() != "ui://stable/index.html" {
192 t.Fatalf("stable nested resourceUri not parsed: %+v", legacy)
193 }
194 }
195
196 func TestAppsRequireTwoWayExtensionAgreement(t *testing.T) {
197 host, client, catalog, _ := startAppsClientWithAgreement(t, false)
198 if client.appsNegotiated() {
199 t.Fatal("Apps negotiated without the server extension")
200 }
201 if len(catalog.appAdapters) != 0 {
202 t.Fatalf("app catalog populated without agreement: %v", catalog.appAdapters)
203 }
204 for _, info := range catalog.infos {
205 if info.Name == "app_only" || info.Name == "nested_wins" || info.Name == "complete_result" {
206 t.Fatalf("stable app-only tool leaked to the model catalog: %q", info.Name)
207 }
208 }
209 host.mu.Lock()
210 host.clients = append(host.clients, client)
211 host.mu.Unlock()
212 inst := host.RegisterAppInstance("apps-fixture", "app_rich", catalog.generation, "call", "ui://app/rich.html")
213 if _, ok := host.AppInstanceResourceDescriptor(inst.Token); ok {
214 t.Fatal("App resource opened without two-way extension agreement")
215 }
216 var rich *remoteTool
217 for _, candidate := range catalog.adapters {
218 if rt, ok := candidate.(*remoteTool); ok && rt.rawName == "app_rich" {
219 rich = rt
220 }
221 }
222 if rich == nil {
223 t.Fatal("model-visible app_rich tool not found")
224 }
225 ctx, collector := tool.WithMCPAppCollector(t.Context())
226 if _, _, err := rich.ExecuteWithImages(ctx, json.RawMessage(`{}`)); err != nil {
227 t.Fatal(err)
228 }
229 if collector.Server != "" {
230 t.Fatal("rich presentation stamped without two-way extension agreement")
231 }
232 }
233
234 func TestAppCallResultPreservesStandardFields(t *testing.T) {
235 _, _, catalog, _ := startAppsClient(t)
236 var complete *remoteTool
237 for _, candidate := range catalog.appAdapters {
238 if rt, ok := candidate.(*remoteTool); ok && rt.rawName == "complete_result" {
239 complete = rt
240 }
241 }
242 if complete == nil {
243 t.Fatal("complete_result App tool not found")
244 }
245 raw, text, reportedError, err := complete.ExecuteForApp(t.Context(), json.RawMessage(`{}`))
246 if err != nil {
247 t.Fatal(err)
248 }
249 if text != "complete result" || !reportedError {
250 t.Fatalf("host projection = %q, isError=%v", text, reportedError)
251 }
252 var result map[string]any
253 if err := json.Unmarshal(raw, &result); err != nil {
254 t.Fatal(err)
255 }
256 content, _ := result["content"].([]any)
257 resource, _ := content[1].(map[string]any)
258 meta, _ := resource["_meta"].(map[string]any)
259 if result["isError"] != true || result["structuredContent"] == nil || result["_meta"] == nil || meta["resource"] != "metadata" {
260 t.Fatalf("complete CallToolResult was not preserved: %s", raw)
261 }
262 }
263
264 func TestRichResultStampedOnCallContext(t *testing.T) {
265 _, _, catalog, _ := startAppsClient(t)
266 var rich *remoteTool
267 for _, tl := range catalog.adapters {
268 if rt, ok := tl.(*remoteTool); ok && rt.rawName == "app_rich" {
269 rich = rt
270 break
271 }
272 }
273 if rich == nil {
274 t.Fatal("app_rich not found")
275 }
276 ctx, collector := tool.WithMCPAppCollector(t.Context())
277 out, _, err := rich.ExecuteWithImages(ctx, json.RawMessage(`{}`))
278 if err != nil {
279 t.Fatalf("execute: %v", err)
280 }
281 if !strings.Contains(out, "app_rich done") {
282 t.Fatalf("text form lost: %q", out)
283 }
284 stamped := collector.Sanitized()
285 if stamped == nil || stamped.Server == "" {
286 t.Fatal("Apps presentation not collected")
287 }
288 if stamped.Server != "apps-fixture" || stamped.Tool != "app_rich" || stamped.ResourceURI != "ui://app/rich.html" {
289 t.Fatalf("stamped identity = %+v", stamped)
290 }
291 if len(stamped.Structured) == 0 || !strings.Contains(string(stamped.Structured), `"tool":"app_rich"`) {
292 t.Fatalf("structured content not captured: %s", stamped.Structured)
293 }
294 }
295
296 func TestAppInstanceRegistryBoundAndReclaimed(t *testing.T) {
297 host := NewHostWithProfile(HostProfileDesktopApps)
298 reg := host.appInstances
299 inst := host.RegisterAppInstance("srv", "tool", 3, "call-1", "ui://x/a.html")
300 if len(inst.Token) != 48 {
301 t.Fatalf("token length = %d, want 48 hex chars", len(inst.Token))
302 }
303 if got, ok := host.LookupAppInstance(inst.Token); !ok || got.Server != "srv" {
304 t.Fatalf("lookup failed: %+v %v", got, ok)
305 }
306 for range maxAppInstances + 4 {
307 host.RegisterAppInstance("srv", "tool", 3, "call", "ui://x/b.html")
308 }
309 if reg.Len() > maxAppInstances {
310 t.Fatalf("registry exceeded bound: %d", reg.Len())
311 }
312 if _, ok := host.LookupAppInstance(inst.Token); ok {
313 t.Fatal("oldest instance not evicted at capacity")
314 }
315 other := host.RegisterAppInstance("other", "t", 1, "c", "ui://y/a.html")
316 host.appInstances.ReleaseServer("other")
317 if _, ok := reg.Lookup(other.Token); ok {
318 t.Fatal("release-server did not reclaim the server's instances")
319 }
320 }
321
322 func TestAppInstanceReleaseCancelsNestedCalls(t *testing.T) {
323 host := NewHostWithProfile(HostProfileDesktopApps)
324 inst := host.RegisterAppInstance("srv", "tool", 3, "call", "ui://x/a.html")
325 ctx, ok := host.AppInstanceContext(inst.Token)
326 if !ok || ctx.Err() != nil {
327 t.Fatal("live App instance has no call context")
328 }
329 host.ReleaseAppInstance(inst.Token)
330 select {
331 case <-ctx.Done():
332 case <-time.After(time.Second):
333 t.Fatal("releasing App instance did not cancel nested calls")
334 }
335 }
336
337 func TestReadResourceForAppReturnsResourceLevelCSP(t *testing.T) {
338 host, client, snapshot, _ := startAppsClient(t)
339 host.mu.Lock()
340 host.clients = append(host.clients, client)
341 host.mu.Unlock()
342 inst := host.RegisterAppInstance("apps-fixture", "app_rich", snapshot.generation, "call", "ui://app/rich.html")
343 if csp, ok := host.AppInstanceResourceDescriptor(inst.Token); !ok || len(csp["connect-src"]) != 1 {
344 t.Fatalf("tool resource descriptor = %#v, %v", csp, ok)
345 }
346 wrong := host.RegisterAppInstance("apps-fixture", "app_rich", snapshot.generation, "call", "ui://app/other.html")
347 if _, ok := host.AppInstanceResourceDescriptor(wrong.Token); ok {
348 t.Fatal("mismatched resource URI was accepted")
349 }
350 content, mime, csp, err := client.readResourceWithMime(t.Context(), "ui://app/rich.html")
351 if err != nil {
352 t.Fatal(err)
353 }
354 if content != "<html>rich</html>" || mime != "text/html;profile=mcp-app" {
355 t.Fatalf("content/mime = %q %q", content, mime)
356 }
357 if got := csp["connectDomains"]; len(got) != 1 || got[0] != "https://api.resource.example" {
358 t.Fatalf("resource CSP = %#v", csp)
359 }
360 }
361
362 func TestAppInstanceRegistryFreezesResourceAndBoundsSnapshotMemory(t *testing.T) {
363 host := NewHostWithProfile(HostProfileDesktopApps)
364 csp := map[string][]string{"connect-src": {"https://api.example.test"}}
365 first := host.RegisterAppInstance("srv", "tool", 3, "call-1", "ui://x/a.html")
366 if !host.BindAppResource(first.Token, "<html>first</html>", "text/html", "digest-1", csp) {
367 t.Fatal("bind first resource failed")
368 }
369 csp["connect-src"][0] = "https://mutated.example.test"
370 snapshot, ok := host.AppResource(first.Token)
371 if !ok || snapshot.Digest != "digest-1" || snapshot.Content != "<html>first</html>" {
372 t.Fatalf("snapshot = %+v, %v", snapshot, ok)
373 }
374 if got := snapshot.CSP["connect-src"][0]; got != "https://api.example.test" {
375 t.Fatalf("snapshot CSP was not copied: %q", got)
376 }
377
378 chunk := strings.Repeat("x", 1<<20)
379 for i := range 17 {
380 inst := host.RegisterAppInstance("srv", "tool", 3, "call", "ui://x/b.html")
381 if !host.BindAppResource(inst.Token, chunk, "text/html", "digest", nil) {
382 t.Fatalf("bind resource %d failed", i)
383 }
384 }
385 if host.appInstances.bytes > maxAppResourceRegistryBytes {
386 t.Fatalf("snapshot bytes = %d, limit = %d", host.appInstances.bytes, maxAppResourceRegistryBytes)
387 }
388 if _, ok := host.AppResource(first.Token); ok {
389 t.Fatal("oldest snapshot was not evicted by the aggregate memory budget")
390 }
391 tooLarge := host.RegisterAppInstance("srv", "tool", 3, "call", "ui://x/large.html")
392 if host.BindAppResource(tooLarge.Token, strings.Repeat("z", maxAppResourceSnapshotBytes+1), "text/html", "digest", nil) {
393 t.Fatal("oversized resource snapshot was accepted")
394 }
395 metadataBomb := host.RegisterAppInstance("srv", "tool", 3, "call", "ui://x/csp.html")
396 if host.BindAppResource(metadataBomb.Token, "<html></html>", "text/html", "digest", map[string][]string{
397 "resourceDomains": {strings.Repeat("z", maxAppResourceSnapshotBytes)},
398 }) {
399 t.Fatal("oversized resource CSP was accepted")
400 }
401 }
402
402 lines GO