Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions docs/mcp-app-tool-call-repro.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion sdk/schema/acp-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,14 +437,17 @@
"sessionId": {
"type": "string"
},
"extensionName": {
"type": "string"
},
"name": {
"type": "string"
},
"arguments": {
"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"
Expand Down
1 change: 1 addition & 0 deletions sdk/src/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export type SetToolPermissionsResponseUnstable = {
*/
export type GooseToolCallRequestUnstable = {
sessionId: string;
extensionName: string;
name: string;
arguments?: unknown;
};
Expand Down
1 change: 1 addition & 0 deletions sdk/src/generated/zod.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});
Expand Down
1 change: 1 addition & 0 deletions src/features/chat/ui/McpAppView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {},
});
Expand Down
1 change: 1 addition & 0 deletions src/features/chat/ui/__tests__/McpAppView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
Expand Down
61 changes: 61 additions & 0 deletions src/shared/api/__tests__/mcpAppToolCallContract.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading