From d76e3974fa3b46cda5861e0be57a6aeaf6b87a51 Mon Sep 17 00:00:00 2001 From: David East Date: Fri, 25 Sep 2026 05:29:21 +0000 Subject: [PATCH] fix(sdk): fallback to get_project screenInstances when list_screens returns empty (#149) --- packages/sdk/src/client.ts | 103 ++++++++- packages/sdk/src/entity-manager.ts | 18 ++ .../issue-149-list-screens-fallback.test.ts | 201 ++++++++++++++++++ 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/test/unit/issue-149-list-screens-fallback.test.ts diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 9df4c5d..67b15a9 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -416,7 +416,41 @@ export class StitchToolClient implements StitchToolClientSpec { undefined, { timeout: this.config.timeout }, ); - return this.parseToolResponse(result, name); + const parsed = this.parseToolResponse(result, name); + if (name === "list_screens" && typeof args.projectId === "string") { + const serverScreens: Record[] = Array.isArray( + (parsed as any)?.screens, + ) + ? (parsed as any).screens + : []; + if (serverScreens.length === 0) { + const recovered = await this.recoverScreensFromProject( + args.projectId, + ); + if (recovered.length > 0) { + return { ...(parsed as any), screens: recovered } as T; + } + } else { + const cached = this.entities.getCachedScreensData(args.projectId); + if (cached.length > 0) { + const seenIds = new Set( + serverScreens.map((s) => s.id || s.name?.split("/").pop()), + ); + const merged = [...serverScreens]; + for (const c of cached) { + const cid = c.id || c.name?.split("/").pop(); + if (cid && !seenIds.has(cid)) { + seenIds.add(cid); + merged.push(c); + } + } + if (merged.length > serverScreens.length) { + return { ...(parsed as any), screens: merged } as T; + } + } + } + } + return parsed; } catch (rawErr) { // Normalize transport HTTP / network errors first, so a real 429 or // transient socket reset is classified and retry-eligible. @@ -551,6 +585,73 @@ export class StitchToolClient implements StitchToolClientSpec { }; } + /** + * Recover screens for `projectId` from `EntityManager` and `get_project`'s + * `screenInstances` when the MCP `list_screens` endpoint returns empty + * prior to a Stitch web UI visit (Issue #149). + */ + private async recoverScreensFromProject( + projectId: string, + ): Promise[]> { + const byId = new Map>(); + + for (const cached of this.entities.getCachedScreensData(projectId)) { + const id = cached.id || cached.name?.split("/").pop(); + if (id) byId.set(id, cached); + } + + try { + const project = await this.callTool("get_project", { + name: `projects/${projectId}`, + }); + const instances = Array.isArray(project?.screenInstances) + ? project.screenInstances + : []; + for (const inst of instances) { + const sourceScreen = + typeof inst?.sourceScreen === "string" ? inst.sourceScreen : ""; + const screenId = sourceScreen.includes("/screens/") + ? sourceScreen.split("/screens/").pop() + : undefined; + if (!screenId) continue; + + const existing = byId.get(screenId); + if (existing?.htmlCode?.downloadUrl) continue; + + try { + const full = await this.callTool("get_screen", { + projectId, + screenId, + name: `projects/${projectId}/screens/${screenId}`, + }); + if (full && typeof full === "object") { + byId.set(screenId, { + id: screenId, + name: `projects/${projectId}/screens/${screenId}`, + title: inst.label, + ...full, + }); + continue; + } + } catch { + // Fall back to metadata on ScreenInstance + } + byId.set(screenId, { + id: screenId, + name: sourceScreen || `projects/${projectId}/screens/${screenId}`, + title: inst.label, + width: inst.width, + height: inst.height, + ...(existing ?? {}), + }); + } + } catch { + // Ignore get_project errors and return any cached in-session screens + } + + return Array.from(byId.values()); + } + /** * Close the connection. TERMINAL: after close(), every subsequent * connect/callTool/httpPost/listTools throws CLIENT_CLOSED — create a diff --git a/packages/sdk/src/entity-manager.ts b/packages/sdk/src/entity-manager.ts index da6b38e..f5f32bd 100644 --- a/packages/sdk/src/entity-manager.ts +++ b/packages/sdk/src/entity-manager.ts @@ -236,4 +236,22 @@ export class EntityManager { } this.cache.clear(); } + + /** + * Returns raw data payloads for all cached Screen entities belonging to `projectId`. + * Used when `list_screens` returns empty before the Stitch web UI hydrates the project. + */ + getCachedScreensData(projectId: string): Record[] { + const results: Record[] = []; + for (const [key, val] of this.cache.entries()) { + if (key.startsWith("Screen:") && val?.projectId === projectId) { + results.push({ + name: `projects/${projectId}/screens/${val.screenId}`, + id: val.screenId, + ...(val.data ?? {}), + }); + } + } + return results; + } } diff --git a/packages/sdk/test/unit/issue-149-list-screens-fallback.test.ts b/packages/sdk/test/unit/issue-149-list-screens-fallback.test.ts new file mode 100644 index 0000000..44b2111 --- /dev/null +++ b/packages/sdk/test/unit/issue-149-list-screens-fallback.test.ts @@ -0,0 +1,201 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi } from "vitest"; +import { StitchToolClient, Stitch } from "../../src/index.js"; + +describe("Issue #149: project.screens() fallback when list_screens returns empty", () => { + it("recovers screens from get_project screenInstances when list_screens returns empty", async () => { + const client = new StitchToolClient({ apiKey: "test-key" }); + client["isConnected"] = true; + + client["client"].callTool = vi + .fn() + .mockImplementation(async ({ name, arguments: args }: any) => { + if (name === "list_screens") { + // Simulate backend bug #149: list_screens returns empty before web UI visit + return { + isError: false, + content: [], + structuredContent: { screens: [] }, + }; + } + if (name === "get_project") { + return { + isError: false, + content: [], + structuredContent: { + name: `projects/${args.name?.replace("projects/", "") || "p1"}`, + title: "My App", + screenInstances: [ + { + id: "inst-1", + sourceScreen: "projects/p1/screens/s1", + label: "Dashboard", + width: 1440, + height: 900, + }, + { + id: "inst-2", + sourceScreen: "projects/p1/screens/s2", + label: "Settings", + width: 1440, + height: 900, + }, + ], + }, + }; + } + if (name === "get_screen") { + return { + isError: false, + content: [], + structuredContent: { + name: args.name, + title: args.screenId === "s1" ? "Dashboard" : "Settings", + htmlCode: { + downloadUrl: `https://example.com/${args.screenId}.html`, + }, + }, + }; + } + throw new Error(`Unexpected tool call: ${name}`); + }); + + const sdk = new Stitch(client); + const project = sdk.project("p1"); + + const screens = await project.screens(); + expect(screens.map((s) => s.screenId)).toEqual(["s1", "s2"]); + expect(screens[0].title).toBe("Dashboard"); + expect(await screens[0].getHtmlUrl()).toBe("https://example.com/s1.html"); + }); + + it("recovers in-session generated screens from EntityManager even if both list_screens and get_project omit them", async () => { + const client = new StitchToolClient({ apiKey: "test-key" }); + client["isConnected"] = true; + + client["client"].callTool = vi + .fn() + .mockImplementation(async ({ name }: any) => { + if (name === "generate_screen_from_text") { + return { + isError: false, + content: [], + structuredContent: { + outputComponents: [ + { + design: { + screens: [ + { + name: "projects/p1/screens/gen-1", + title: "Generated Home", + htmlCode: { + downloadUrl: "https://example.com/gen-1.html", + }, + }, + ], + }, + }, + ], + }, + }; + } + if (name === "list_screens") { + return { + isError: false, + content: [], + structuredContent: { screens: [] }, + }; + } + if (name === "get_project") { + return { + isError: false, + content: [], + structuredContent: { + name: "projects/p1", + screenInstances: [], + }, + }; + } + throw new Error(`Unexpected tool call: ${name}`); + }); + + const sdk = new Stitch(client); + const project = sdk.project("p1"); + + await project.generate("Home screen", "MOBILE"); + const screens = await project.screens(); + + expect(screens).toHaveLength(1); + expect(screens[0].screenId).toBe("gen-1"); + expect(screens[0].title).toBe("Generated Home"); + }); + + it("merges newly generated in-session screens when list_screens only returns older screens", async () => { + const client = new StitchToolClient({ apiKey: "test-key" }); + client["isConnected"] = true; + + client["client"].callTool = vi + .fn() + .mockImplementation(async ({ name }: any) => { + if (name === "generate_screen_from_text") { + return { + isError: false, + content: [], + structuredContent: { + outputComponents: [ + { + design: { + screens: [ + { + name: "projects/p1/screens/new-screen", + title: "New Screen", + }, + ], + }, + }, + ], + }, + }; + } + if (name === "list_screens") { + return { + isError: false, + content: [], + structuredContent: { + screens: [ + { + name: "projects/p1/screens/old-screen", + title: "Old Screen", + }, + ], + }, + }; + } + throw new Error(`Unexpected tool call: ${name}`); + }); + + const sdk = new Stitch(client); + const project = sdk.project("p1"); + + await project.generate("Add new screen", "DESKTOP"); + const screens = await project.screens(); + + expect(screens.map((s) => s.screenId)).toEqual([ + "old-screen", + "new-screen", + ]); + }); +});