diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62086121a..84f239aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: run: pnpm --filter @aaif/goose-sdk build - name: Run frontend checks + env: + # The full TypeScript check needs headroom above Node's default 2 GiB heap. + NODE_OPTIONS: --max-old-space-size=4096 run: just check - name: Run unit tests @@ -201,6 +204,9 @@ jobs: run: pnpm --filter @aaif/goose-sdk build - name: Build frontend + env: + # The build repeats the full TypeScript check with the same heap budget. + NODE_OPTIONS: --max-old-space-size=4096 run: just build - name: Upload frontend build diff --git a/docs/mcp-app-tool-call-repro.md b/docs/mcp-app-tool-call-repro.md new file mode 100644 index 000000000..64bc8db7e --- /dev/null +++ b/docs/mcp-app-tool-call-repro.md @@ -0,0 +1,66 @@ +# MCP app tool-call contract reproduction + +This fix supplies `extensionName` when an MCP app calls a server tool. It matches +Goose's pinned request schema and preserves the existing `extension__tool` name. +It does **not** enable app frames or change frame policy or security transport. +Full native rendering validation remains blocked pending separate security and +frame-policy work; see [#53](https://github.com/block/berd/pull/53). + +## Public clock app + +Use the official [Basic Server (Vanilla JS)](https://github.com/modelcontextprotocol/ext-apps/tree/92f46a574568a3ddac7600343b7d3c4c4ed7b588/examples/basic-server-vanillajs) +from ext-apps v1.7.5, matching the ext-apps version used by Berd's +`@mcp-ui/client` 7.1.1. It needs no API key and returns the real server clock. +Its **Get Server Time** button calls `get-time` with `{}` arguments and displays +`structuredContent.time`. Its resource is `ui://get-time/mcp-app.html` with MIME +`text/html;profile=mcp-app`. + +In a separate scratch directory, with Git, npm and Node 22 or 24: + +```sh +git clone https://github.com/modelcontextprotocol/ext-apps.git ext-apps-public-demo +cd ext-apps-public-demo +git checkout --detach 92f46a574568a3ddac7600343b7d3c4c4ed7b588 +npm ci +npm run build --workspace @modelcontextprotocol/server-basic-vanillajs +node examples/basic-server-vanillajs/dist/index.js --stdio +``` + +`npm ci` runs upstream's prepare/build script. Keep the source lockfile; the build +uses its Bun dependency. The last command starts a stdio server, not a web page. +Stop that process before letting Berd start its own instance. Preserve upstream's +license and attribution: package manifests say MIT, while the repository LICENSE +explains mixed historical MIT/Apache-2.0 code and CC-BY-4.0 documentation. + +## Future native acceptance steps (not yet observed) + +After the separate security and frame-policy prerequisites are approved: + +1. Use a disposable Berd profile. In **Settings → Connections → Add connection**, + ask the setup agent to add a local Goose stdio MCP named `public-clock`. +2. Supply the absolute Node executable path as the command. Supply the absolute + path to `examples/basic-server-vanillajs/dist/index.js`, then `--stdio`, as + two arguments. No environment secrets are needed. Review and approve the + proposed configuration; do not edit a live profile for this test. +3. Enable the connection for a fresh Goose session. The pinned backend requires + auto mode for app-initiated tool calls; use only this harmless clock extension. +4. Ask the agent to invoke `get-time`. Confirm the native inline app displays + a concrete initial timestamp, not Loading or Error. +5. After the clock advances, click **Get Server Time**. Confirm a new timestamp + arrives from the awaited server call. Leave unrelated app controls unused. +6. Capture actual native before/after screenshots without unrelated chats or + private data. Upload through GitHub's native attachment UI; do not substitute + a browser mock, upstream image, or committed screenshot for native evidence. + +## Evidence for this partial fix + +The host regression checks the app callback's outgoing request. The SDK contract +regressions check the serialized extension method payload, required validator +field, and the request definition from the pinned public Goose schema. These +checks do not exercise a WebView or prove native rendering. + +The source-pinned demo checkout was obtained, but `npm ci` failed with archive +integrity errors in the test environment. Its build and MCP stdio round trip +were not observed. No native app was launched, no user profile was changed, and +no native screenshots are available. The commands above are source-verified +reproduction instructions, not a claim that installation or the full app passed. diff --git a/sdk/schema/acp-schema.json b/sdk/schema/acp-schema.json index d5eed38c2..2fbbf439a 100644 --- a/sdk/schema/acp-schema.json +++ b/sdk/schema/acp-schema.json @@ -437,6 +437,9 @@ "sessionId": { "type": "string" }, + "extensionName": { + "type": "string" + }, "name": { "type": "string" }, @@ -444,7 +447,7 @@ "default": null } }, - "required": ["sessionId", "name"], + "required": ["sessionId", "extensionName", "name"], "description": "Call a tool from an extension.", "x-side": "agent", "x-method": "_goose/unstable/tools/call" diff --git a/sdk/src/generated/types.gen.ts b/sdk/src/generated/types.gen.ts index ba354f4f1..88fdb9d46 100644 --- a/sdk/src/generated/types.gen.ts +++ b/sdk/src/generated/types.gen.ts @@ -278,6 +278,7 @@ export type SetToolPermissionsResponseUnstable = { */ export type GooseToolCallRequestUnstable = { sessionId: string; + extensionName: string; name: string; arguments?: unknown; }; diff --git a/sdk/src/generated/zod.gen.ts b/sdk/src/generated/zod.gen.ts index 71cf728ca..849affa78 100644 --- a/sdk/src/generated/zod.gen.ts +++ b/sdk/src/generated/zod.gen.ts @@ -179,6 +179,7 @@ export const zSetToolPermissionsResponseUnstable = z.record(z.string(), z.unknow */ export const zGooseToolCallRequestUnstable = z.object({ sessionId: z.string(), + extensionName: z.string(), name: z.string(), arguments: z.unknown().optional().default(null) }); diff --git a/src/features/chat/ui/McpAppView.tsx b/src/features/chat/ui/McpAppView.tsx index a6aaa2805..e62e6632a 100644 --- a/src/features/chat/ui/McpAppView.tsx +++ b/src/features/chat/ui/McpAppView.tsx @@ -539,6 +539,7 @@ export function McpAppView({ const client = await getClientForSession(payload.sessionId); const response = await client.goose.GooseUnstableToolsCall({ sessionId: getWireSessionId(payload.sessionId), + extensionName: payload.tool.extensionName, name: `${payload.tool.extensionName}__${name}`, arguments: args ?? {}, }); diff --git a/src/features/chat/ui/__tests__/McpAppView.test.tsx b/src/features/chat/ui/__tests__/McpAppView.test.tsx index ea648aa2a..5f07bd260 100644 --- a/src/features/chat/ui/__tests__/McpAppView.test.tsx +++ b/src/features/chat/ui/__tests__/McpAppView.test.tsx @@ -523,6 +523,7 @@ describe("McpAppView nested tool calls", () => { await waitFor(() => { expect(mocks.toolCall).toHaveBeenCalledWith({ sessionId: "local-session", + extensionName: "mcpappbench_local_", name: "mcpappbench_local___get-server-time", arguments: { timezone: "America/New_York" }, }); diff --git a/src/shared/api/__tests__/mcpAppToolCallContract.test.ts b/src/shared/api/__tests__/mcpAppToolCallContract.test.ts new file mode 100644 index 000000000..2e069bbd4 --- /dev/null +++ b/src/shared/api/__tests__/mcpAppToolCallContract.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import schema from "../../../../sdk/schema/acp-schema.json"; +import { GooseExtClient } from "../../../../sdk/src/generated/client.gen"; +import { zGooseToolCallRequestUnstable } from "../../../../sdk/src/generated/zod.gen"; + +// Pinned backend source: aaif-goose/goose@7debb275d36559ee4e81799362e7f638275dfed3, +// crates/goose/acp-schema.json, $defs.GooseToolCallRequest_unstable. +const backendRequestSchema = { + type: "object", + properties: { + sessionId: { type: "string" }, + extensionName: { type: "string" }, + name: { type: "string" }, + arguments: { default: null }, + }, + required: ["sessionId", "extensionName", "name"], + description: "Call a tool from an extension.", + "x-side": "agent", + "x-method": "_goose/unstable/tools/call", +}; + +const request = { + sessionId: "wire-session", + extensionName: "clock", + name: "clock__get-time", + arguments: {}, +}; + +describe("MCP app tool call contract", () => { + it("matches the pinned backend request schema", () => { + expect(schema.$defs.GooseToolCallRequest_unstable).toEqual( + backendRequestSchema, + ); + }); + + it("requires extensionName in the generated validator", () => { + expect(zGooseToolCallRequestUnstable.parse(request)).toEqual(request); + const { extensionName: _, ...missingExtension } = request; + expect( + zGooseToolCallRequestUnstable.safeParse(missingExtension).success, + ).toBe(false); + expect( + zGooseToolCallRequestUnstable.safeParse({ + ...request, + extensionName: 42, + }).success, + ).toBe(false); + }); + + it("preserves extensionName and the prefixed name on the wire", async () => { + const extMethod = vi + .fn() + .mockResolvedValue({ content: [], isError: false }); + const client = new GooseExtClient({ extMethod }); + await client.GooseUnstableToolsCall(request); + expect(extMethod).toHaveBeenCalledExactlyOnceWith( + "_goose/unstable/tools/call", + request, + ); + }); +});