feat: Only send custom notifications when ACP client specifies this capability in the initialization request (#9596)

This commit is contained in:
Lifei Zhou
2026-06-05 08:39:59 +10:00
committed by GitHub
parent 6251e56347
commit ec519eeaaf
15 changed files with 306 additions and 103 deletions
+1 -2
View File
@@ -2,7 +2,6 @@ import {
DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
GooseClient,
type Client,
type GooseInitializeRequest,
} from '@aaif/goose-sdk';
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
import packageJson from '../../package.json';
@@ -58,7 +57,7 @@ async function initializeConnection(): Promise<GooseClient> {
name: packageJson.name,
version: packageJson.version,
},
} satisfies GooseInitializeRequest);
});
monitorConnection(client);
return client;
+18 -14
View File
@@ -239,18 +239,16 @@ async function generateClient(meta: {
const handlerFields: string[] = [];
const dispatchCases: string[] = [];
const handlerKeys: string[] = [];
for (const n of meta.notifications ?? []) {
const handlerName = methodToHandlerName(n.method);
handlerKeys.push(handlerName);
if (!n.paramsType) {
handlerFields.push(
` ${handlerName}?: (params: Record<string, unknown>) => Promise<void>;`,
);
dispatchCases.push(
` case "${n.method}": {
await ${handlerName}?.(params);
await callbacks.${handlerName}?.(params);
return;
}`,
);
@@ -265,16 +263,12 @@ async function generateClient(meta: {
dispatchCases.push(
` case "${n.method}": {
const parsed = ${zodName}.parse(params) as ${n.paramsType};
await ${handlerName}?.(parsed);
await callbacks.${handlerName}?.(parsed);
return;
}`,
);
}
const handlerDestructure =
handlerKeys.length > 0
? `const { ${handlerKeys.join(", ")}, ...rest } = callbacks;`
: `const rest = callbacks;`;
const handlersInterface = `export interface GooseExtNotifications {
${handlerFields.join("\n")}
}`;
@@ -282,19 +276,26 @@ ${handlerFields.join("\n")}
const dispatcherFn = `export function installGooseExtNotificationDispatcher(
callbacks: GooseClientCallbacks,
): Client {
${handlerDestructure}
const userExtNotification = rest.extNotification;
return {
...rest,
const dispatcher: Pick<Client, "extNotification"> = {
extNotification: async (method, params) => {
switch (method) {
${dispatchCases.join("\n")}
default:
await userExtNotification?.(method, params);
await callbacks.extNotification?.(method, params);
return;
}
},
};
return new Proxy(callbacks, {
get(target, property) {
if (property === "extNotification") {
return dispatcher.extNotification;
}
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as Client;
}`;
const upstreamImportLine = `import type { ${[...upstreamTypeImports].sort().join(", ")} } from "@agentclientprotocol/sdk";`;
@@ -322,7 +323,10 @@ ${methodDefs.join("\n")}
${handlersInterface}
export type GooseClientCallbacks = Client & GooseExtNotifications;
export type GooseClientCallbacks =
Omit<Client, "extNotification"> &
Partial<Pick<Client, "extNotification">> &
GooseExtNotifications;
${dispatcherFn}
`;
+2
View File
@@ -37,6 +37,8 @@
"build:native:all": "tsx scripts/build-native.ts --all",
"generate": "tsx generate-schema.ts",
"lint": "tsc --noEmit",
"test": "node --import tsx --test tests/*.test.ts",
"typecheck:test": "tsc -p tsconfig.test.json --noEmit",
"format": "prettier --write src/",
"check:compat": "node scripts/check-binary-compat.mjs"
},
+8
View File
@@ -0,0 +1,8 @@
import type { GooseMcpHostCapabilities } from "./mcp-apps.js";
export interface GooseClientCapabilitiesMeta {
goose?: {
mcpHostCapabilities?: GooseMcpHostCapabilities;
customNotifications?: boolean;
};
}
+16 -7
View File
@@ -733,28 +733,37 @@ export interface GooseExtNotifications {
) => Promise<void>;
}
export type GooseClientCallbacks = Client & GooseExtNotifications;
export type GooseClientCallbacks = Omit<Client, "extNotification"> &
Partial<Pick<Client, "extNotification">> &
GooseExtNotifications;
export function installGooseExtNotificationDispatcher(
callbacks: GooseClientCallbacks,
): Client {
const { unstable_sessionUpdate, ...rest } = callbacks;
const userExtNotification = rest.extNotification;
return {
...rest,
const dispatcher: Pick<Client, "extNotification"> = {
extNotification: async (method, params) => {
switch (method) {
case "_goose/unstable/session/update": {
const parsed = zGooseSessionNotification_unstable.parse(
params,
) as GooseSessionNotification_unstable;
await unstable_sessionUpdate?.(parsed);
await callbacks.unstable_sessionUpdate?.(parsed);
return;
}
default:
await userExtNotification?.(method, params);
await callbacks.extNotification?.(method, params);
return;
}
},
};
return new Proxy(callbacks, {
get(target, property) {
if (property === "extNotification") {
return dispatcher.extNotification;
}
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as Client;
}
+1
View File
@@ -6,6 +6,7 @@ export {
} from "./generated/client.gen.js";
export { GooseClient } from "./goose-client.js";
export { createHttpStream } from "./http-stream.js";
export * from "./client-capabilities.js";
export * from "./mcp-apps.js";
export {
-17
View File
@@ -1,7 +1,3 @@
import type {
Implementation,
InitializeRequest,
} from "@agentclientprotocol/sdk";
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
import type {
McpUiAppResourceConfig,
@@ -68,19 +64,6 @@ export interface GooseToolCallUpdateMeta {
[key: string]: unknown;
}
export interface GooseClientMeta {
goose: {
mcpHostCapabilities: GooseMcpHostCapabilities;
};
}
export type GooseInitializeRequest = InitializeRequest & {
clientCapabilities: NonNullable<InitializeRequest["clientCapabilities"]> & {
_meta: GooseClientMeta;
};
clientInfo: Implementation;
};
export const DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: GooseMcpHostCapabilities = {
extensions: {
[GOOSE_MCP_UI_EXTENSION_ID]: {
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { installGooseExtNotificationDispatcher } from "../src/generated/client.gen.ts";
import type { GooseSessionNotification_unstable } from "../src/generated/types.gen.ts";
import type {
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
} from "@agentclientprotocol/sdk";
class ClassBackedCallbacks {
#events: string[] = [];
get events(): string[] {
return this.#events;
}
async requestPermission(
_params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
this.#events.push("requestPermission");
return { outcome: { outcome: "cancelled" } };
}
async sessionUpdate(_params: SessionNotification): Promise<void> {
this.#events.push("sessionUpdate");
}
async extNotification(
method: string,
_params: Record<string, unknown>,
): Promise<void> {
this.#events.push(`extNotification:${method}`);
}
async unstable_sessionUpdate(
notification: GooseSessionNotification_unstable,
): Promise<void> {
this.#events.push(
`unstable_sessionUpdate:${notification.update.sessionUpdate}`,
);
}
}
class MinimalCallbacks {
async requestPermission(
_params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
return { outcome: { outcome: "cancelled" } };
}
async sessionUpdate(_params: SessionNotification): Promise<void> {}
}
test("dispatcher preserves class-backed callback receivers", async () => {
const callbacks = new ClassBackedCallbacks();
const client = installGooseExtNotificationDispatcher(callbacks);
await client.requestPermission({} as RequestPermissionRequest);
await client.sessionUpdate({} as SessionNotification);
await client.extNotification!("_goose/unstable/session/update", {
sessionId: "session-1",
update: {
sessionUpdate: "status_message",
status: {
type: "notice",
message: "ready",
},
},
});
await client.extNotification!("example/unknown", {});
assert.deepEqual(callbacks.events, [
"requestPermission",
"sessionUpdate",
"unstable_sessionUpdate:status_message",
"extNotification:example/unknown",
]);
});
test("raw extNotification is optional", async () => {
const client = installGooseExtNotificationDispatcher(new MinimalCallbacks());
await client.extNotification!("example/unknown", {});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"noEmit": true,
"rootDir": "."
},
"include": ["src", "tests"]
}