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
11 changes: 10 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,16 @@ sends each `ui/update-model-context` to its own session's `context` endpoint, wh
replaces that tab's text and images (up to 16 MiB) and drops them when the tab's
session closes. The agent reads them with `mobile_read_panel_context`, most recent
tab first, so it sees the user's devices and the logs, screenshots and notes they
add to chat. The opening tool's result points the agent there. Chat messages stay
add to chat. The opening tool's result points the agent there. Once it has acted on
them, the agent calls `mobile_clear_panel_context`: the server forgets what the agent
has read — a tab whose context was stored or changed since the last read keeps it —
and counts the clear. Storing context returns the current count, and while a tab has
stored content its page polls that count from its `context` endpoint every two
seconds (the server keeps no idle stream to push on), ignoring counts its content was
stored under, and hands it to the panel in the host context under
`mobile-dev/contextClears`, where `PanelContext.hostChanged` drops the notes the
panel still shows and shares the devices again, so the user's selection survives the
clear. Chat messages stay
hidden until the host provides a supported bridge. MCP request/response
traffic is bidirectional, but sampling requests an LLM completion rather than
appending a user message to the current conversation. Sampling is also
Expand Down
5 changes: 4 additions & 1 deletion apps/claude/skills/mobile-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ beside the chat without the user clicking Open. Open only that link: the `baseUr
`127.0.0.1` addresses belong to Mobile Dev's backends, not to the panel, so never open
or show them. Without a browser, give the user the link. Then read
`mobile_read_panel_context` for the devices the user selected there, and again when
they refer to something they added from the panel.
they refer to something they added from the panel. Once you have acted on what they added,
call `mobile_clear_panel_context` so it is not read again; it only forgets what you
have read — notes added since your last read stay — and leaves their device
selection alone.

The first open downloads the panel's runtime once, which can take a minute. If it
fails, tell the user what the result says. In the Claude desktop app's Code tab you
Expand Down
61 changes: 54 additions & 7 deletions packages/core/src/browser-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export const BROWSER_CONTEXT_MAX_BYTES = 16 * 1024 * 1024;
const NO_CONTEXT = "No Mobile Dev browser panel has shared context. Open Mobile Dev, or ask the user to select a device in the panel.";

type PanelContent = TextContent | ImageContent;
type BrowserSession = { transport: StreamableHTTPServerTransport; touched: number; requests: number; context?: { content: PanelContent[]; updated: number } };
type PanelPage = { url(tool: string, target: PanelTarget): string; context(): PanelContent[]; close(): Promise<void> };
type BrowserSession = { transport: StreamableHTTPServerTransport; touched: number; requests: number; clears: number; stores: number; read?: number; context?: { content: PanelContent[]; updated: number } };
type PanelPage = { url(tool: string, target: PanelTarget): string; context(): PanelContent[]; clear(): { cleared: number; kept: number }; close(): Promise<void> };

// What a panel would otherwise give an MCP Apps host's model context. Hosts mark
// audience and other annotations; the agent reads only text and images.
Expand Down Expand Up @@ -65,6 +65,23 @@ export function browserPanel({ template = PAGE, always = false }: BrowserPanelOp
const content = opened?.context() ?? [];
return { content: content.length ? content : [{ type: "text", text: NO_CONTEXT }] };
});
// The agent cannot reach into the page, so it asks the server to forget what it read.
// The page learns of it by polling the server's count of clears.
server.registerTool("mobile_clear_panel_context", {
title: "Clear Mobile Dev panel context",
description: "Clear the logs, screenshots and screen notes the user added to chat in a Mobile Dev panel opened in a browser, and remove them from the panel. Call it once you have acted on them, so they are not read again. It only forgets what mobile_read_panel_context has returned: notes added or changed since your last read stay. It leaves the user's selected devices alone.",
inputSchema: {}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
}, async () => {
const opened = await page?.catch(() => undefined);
const { cleared, kept } = opened?.clear() ?? { cleared: 0, kept: 0 };
const tabs = (count: number) => `${count} Mobile Dev browser tab${count === 1 ? "" : "s"}`;
const text = cleared
? `Cleared the shared context of ${tabs(cleared)}.${kept ? ` Kept ${tabs(kept)} whose notes changed since your last read; call mobile_read_panel_context to see them.` : ""}`
: kept
? `Nothing cleared: the shared context of ${tabs(kept)} changed since your last read. Call mobile_read_panel_context to see it, then clear again.`
: "No Mobile Dev browser tab has shared context, so there is nothing to clear.";
return { content: [{ type: "text", text }] };
});
return async () => {
const opened = await page?.catch(() => undefined);
await opened?.close();
Expand Down Expand Up @@ -123,12 +140,19 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet
reply(response, 404);
}

// The page replaces its tab's context, as ui/update-model-context does in a chat host.
// The page replaces its tab's context, as ui/update-model-context does in a chat host,
// and reads how many times the agent has cleared it, so it can drop what it still shows.
async function context(request: IncomingMessage, response: ServerResponse) {
if (request.method !== "PUT") return reply(response, 405, { allow: "PUT" });
if (request.method !== "PUT" && request.method !== "GET") return reply(response, 405, { allow: "GET, PUT" });
const sessionId = request.headers["mcp-session-id"];
const session = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
if (session === undefined) return reply(response, 404);
if (request.method === "GET") {
session.touched = Date.now();
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
response.end(JSON.stringify({ clears: session.clears }));
return;
}
if (request.headers["content-type"]?.startsWith("application/json") !== true) return reply(response, 415);
const body = await readBody(request, BROWSER_CONTEXT_MAX_BYTES);
if (body === undefined) {
Expand All @@ -143,9 +167,12 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet
return reply(response, 400);
}
session.context = { content: parsed.data.content, updated: Date.now() };
session.stores++;
session.touched = Date.now();
recordBrowserPanelContext("stored", body.byteLength);
reply(response, 204);
// The count the content was stored under: a clear up to it cannot have dropped this content.
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
response.end(JSON.stringify({ clears: session.clears }));
}

async function mcp(request: IncomingMessage, response: ServerResponse) {
Expand All @@ -164,7 +191,7 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet
// Per-request SSE supports associated server requests and releases completed streams.
sessionIdGenerator: randomUUID,
onsessioninitialized: id => {
sessions.set(id, { transport, touched: Date.now(), requests: 0 });
sessions.set(id, { transport, touched: Date.now(), requests: 0, clears: 0, stores: 0 });
recordBrowserPanelSessions(sessions.size);
},
});
Expand All @@ -191,14 +218,34 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet
},
context() {
// The most recently updated tab comes first; each open tab keeps its own.
const shared = Array.from(sessions.values()).flatMap(session => session.context ? [session.context] : []);
const shared = Array.from(sessions.values()).flatMap(session => {
if (session.context === undefined) return [];
// clear() forgets only what the agent has read; mark this snapshot as read.
session.read = session.stores;
return [session.context];
});
shared.sort((a, b) => b.updated - a.updated);
return shared.flatMap((context, index) => {
if (context.content.length === 0) return [];
const heading: PanelContent = { type: "text", text: shared.length > 1 ? `Mobile Dev browser tab ${index + 1} of ${shared.length}, most recent first.` : "Mobile Dev browser panel." };
return [heading, ...context.content];
});
},
clear() {
// Forget only what the agent has read: a tab whose notes were stored or changed
// since the last read holds content the agent never saw, so it keeps them.
let cleared = 0;
let kept = 0;
for (const session of sessions.values()) {
if (session.context === undefined || session.context.content.length === 0) continue;
if (session.read !== session.stores) { kept++; continue; }
session.context = undefined;
session.clears++;
cleared++;
recordBrowserPanelContext("cleared");
}
return { cleared, kept };
},
async close() {
closing = true;
clearInterval(expiry);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export function recordBrowserPanelSessions(active: number) {
Sentry.metrics.gauge("browser_panel.sessions", active, { attributes: { component: "server" } });
}

/** Counts browser panel context updates by outcome; sizes only for stored updates, never their content. */
export function recordBrowserPanelContext(outcome: "stored" | "invalid" | "too_large", bytes?: number) {
/** Counts browser panel context updates by outcome, cleared-by-the-agent included; sizes only for stored updates, never their content. */
export function recordBrowserPanelContext(outcome: "stored" | "invalid" | "too_large" | "cleared", bytes?: number) {
if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
const attributes = { component: "server", outcome };
Sentry.metrics.count("browser_panel.context_update", 1, { attributes });
Expand Down
86 changes: 82 additions & 4 deletions packages/core/test/browser-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,16 +331,21 @@ test("a browser tab's model context reaches the agent and leaves with the tab",
assert.equal((await putContext(endpoint, first, "not json")).status, 400);
assert.equal((await putContext(endpoint, first, JSON.stringify({ content: [{ type: "image", data: "x", mimeType: "text/html" }] }))).status, 400);
assert.equal((await fetch(endpoint, { method: "PUT", body: "{}", headers: { "content-type": "application/json", "mcp-session-id": first, origin: "https://attacker.example" } })).status, 403);
assert.equal((await fetch(endpoint, { headers: { "mcp-session-id": first } })).status, 405);
assert.equal((await fetch(endpoint, { method: "POST", headers: { "mcp-session-id": first } })).status, 405);
assert.equal((await fetch(endpoint)).status, 404);
const clears = async (id: string) => (await (await fetch(endpoint, { headers: { "mcp-session-id": id } })).json() as { clears: number }).clears;
assert.equal(await clears(first), 0);

const device = "Active iOS simulator: iPhone 17. Device ID: 11111111-1111-1111-1111-111111111111.";
const stored = await putContext(endpoint, first, JSON.stringify({ content: [{ type: "text", text: device, annotations: { audience: ["assistant"] } }] }));
assert.equal(stored.status, 204);
assert.equal(stored.status, 200);
// The response tells the page which count its content was stored under.
assert.deepEqual(await stored.json(), { clears: 0 });
assert.deepEqual(await read(), [{ type: "text", text: "Mobile Dev browser panel." }, { type: "text", text: device }]);

await new Promise(resolve => setTimeout(resolve, 5));
const screenshot = { type: "image", data: "aGVsbG8=", mimeType: "image/png" };
assert.equal((await putContext(endpoint, second, JSON.stringify({ content: [screenshot] }))).status, 204);
assert.equal((await putContext(endpoint, second, JSON.stringify({ content: [screenshot] }))).status, 200);
const both = await read();
assert.equal(both.length, 4);
assert.match(both[0].text!, /tab 1 of 2/);
Expand All @@ -350,12 +355,85 @@ test("a browser tab's model context reaches the agent and leaves with the tab",
// Closing a tab removes what it shared; an empty update clears without closing.
await tabs[1].terminateSession();
assert.deepEqual((await read()).map(item => item.text), ["Mobile Dev browser panel.", device]);
assert.equal((await putContext(endpoint, first, JSON.stringify({ content: [] }))).status, 204);
assert.equal((await putContext(endpoint, first, JSON.stringify({ content: [] }))).status, 200);
assert.match((await read())[0].text!, /No Mobile Dev browser panel/);
await tabs[0].terminateSession();
for (const client of clients) await client.close();
});

test("the agent clears a browser tab's context and the tab learns of it", async t => {
const directory = await mkdtemp(join(tmpdir(), "browser-panel-"));
const page = join(directory, "browser-panel.html");
await writeFile(page, "<html></html>");
const fake = await fakeBaguette();
const [hostSide, serverSide] = InMemoryTransport.createLinkedPair();
const shared = new SharedTransport(serverSide);
const plugin = await createTestPlugin("<html></html>", { panel: createPanel({ baguette: new Baguette(fake.url), simulatorInput: fakeSimulatorInput() }), features: [browserPanel({ template: pathToFileURL(page) })], connections: shared });
const host = new Client({ name: "claude-code", version: "1" });
await plugin.server.connect(shared);
await host.connect(hostSide);
t.after(async () => { await host.close(); await plugin.close(); await fake.close(); await rm(directory, { recursive: true, force: true }); });
const text = async (name: string) => ((await host.callTool({ name, arguments: {} })).content as Array<{ text: string }>)[0].text;
const read = async () => ((await host.callTool({ name: "mobile_read_panel_context", arguments: {} })).content as Array<{ text?: string }>).map(item => item.text ?? "").join("\n");

assert.match(await text("mobile_clear_panel_context"), /nothing to clear/);
const opened = await host.callTool({ name: "mobile_open_simulator", arguments: {} });
const link = new URL((opened.content as Array<{ text: string }>)[1].text.match(/http:\/\/127\.0\.0\.1:\d+\/[\w-]+\/\?\S+/)![0]);
const endpoint = new URL("context", link);
const tab = new StreamableHTTPClientTransport(new URL("mcp", link));
const client = new Client({ name: "tab", version: "1" });
await client.connect(tab);
const id = tab.sessionId!;
const count = async (session: string) => (await (await fetch(endpoint, { headers: { "mcp-session-id": session } })).json() as { clears: number }).clears;
const clears = () => count(id);
const put = (session: string, text: string) => putContext(endpoint, session, JSON.stringify({ content: [{ type: "text", text }] }));

const note = "Note on the login screen.";
assert.equal((await put(id, note)).status, 200);
assert.equal(await clears(), 0);
// Unread notes stay: the tool clears only what mobile_read_panel_context returned.
assert.match(await text("mobile_clear_panel_context"), /Nothing cleared/);
assert.equal(await clears(), 0);
assert.match(await read(), /login screen/);
assert.match(await text("mobile_clear_panel_context"), /Cleared the shared context of 1 Mobile Dev browser tab\./);
assert.match(await read(), /No Mobile Dev browser panel/);
assert.equal(await clears(), 1);
// With nothing stored there is nothing to clear, and the tab is not told to drop anything.
assert.match(await text("mobile_clear_panel_context"), /nothing to clear/);
assert.equal(await clears(), 1);
// A note changed after the last read is kept; reading again makes it clearable.
assert.equal((await put(id, note)).status, 200);
await read();
const stored = await put(id, "A newer note.");
assert.equal(stored.status, 200);
assert.deepEqual(await stored.json(), { clears: 1 });
assert.match(await text("mobile_clear_panel_context"), /Nothing cleared/);
assert.equal(await clears(), 1);
assert.match(await read(), /newer note/);
assert.match(await text("mobile_clear_panel_context"), /Cleared the shared context of 1/);
assert.equal(await clears(), 2);
// A tab whose notes changed since the read is kept while the read one clears.
const tab2 = new StreamableHTTPClientTransport(new URL("mcp", link));
const client2 = new Client({ name: "tab-2", version: "1" });
await client2.connect(tab2);
const id2 = tab2.sessionId!;
assert.equal((await put(id, note)).status, 200);
assert.equal((await put(id2, "Second tab note.")).status, 200);
await read();
assert.equal((await put(id2, "Second tab, changed.")).status, 200);
assert.match(await text("mobile_clear_panel_context"), /Cleared the shared context of 1 Mobile Dev browser tab\. Kept 1 Mobile Dev browser tab whose notes changed since your last read/);
assert.equal(await clears(), 3);
assert.equal(await count(id2), 0, "the kept tab is not told to drop anything");
assert.match(await read(), /Second tab, changed/);
// A repeat can drop notes added in between, so the tool must not advertise idempotence.
const { tools } = await host.listTools();
assert.equal(tools.find(tool => tool.name === "mobile_clear_panel_context")?.annotations?.idempotentHint, false);
await tab2.terminateSession();
await client2.close();
await tab.terminateSession();
await client.close();
});

test("an oversized browser context update is refused without storing it", async t => {
const { server, shared } = await sharedServer();
const directory = await mkdtemp(join(tmpdir(), "browser-panel-"));
Expand Down
Loading