Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,41 @@ export class StitchToolClient implements StitchToolClientSpec {
undefined,
{ timeout: this.config.timeout },
);
return this.parseToolResponse<T>(result, name);
const parsed = this.parseToolResponse<T>(result, name);
if (name === "list_screens" && typeof args.projectId === "string") {
const serverScreens: Record<string, any>[] = 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.
Expand Down Expand Up @@ -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<Record<string, any>[]> {
const byId = new Map<string, Record<string, any>>();

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<any>("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<any>("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
Expand Down
18 changes: 18 additions & 0 deletions packages/sdk/src/entity-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>[] {
const results: Record<string, any>[] = [];
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;
}
}
201 changes: 201 additions & 0 deletions packages/sdk/test/unit/issue-149-list-screens-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
Loading