feat: add channels support

This commit is contained in:
Jack Wotherspoon
2026-03-20 15:52:29 -04:00
parent 52250c162d
commit 0b94546ee4
14 changed files with 290 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Payload for the 'channel-message' event, emitted when an MCP server
* declaring the `gemini/channel` experimental capability sends a
* `notifications/gemini/channel` notification.
*/
export interface ChannelMessagePayload {
/** Name of the MCP server acting as the channel. */
channelName: string;
/** Sender identifier (e.g. Telegram username, Discord user ID). */
sender: string;
/** The message body. */
content: string;
/** Unix epoch milliseconds when the message was received. */
timestamp: number;
/** Optional correlation ID for two-way channel replies. */
replyTo?: string;
/** Extra key-value pairs surfaced as XML attributes on the <channel> tag. */
metadata?: Record<string, string>;
}
/**
* Describes the channel capability advertised by an MCP server via
* `capabilities.experimental['gemini/channel']`.
*/
export interface ChannelCapability {
/** Whether this channel exposes MCP tools for replying (two-way). */
supportsReply: boolean;
/** Human-readable name for the channel (defaults to MCP server name). */
displayName?: string;
}
/**
* Simple registry tracking which MCP servers have declared channel capability.
* Populated by McpClient.registerNotificationHandlers().
*/
export const activeChannels = new Map<string, ChannelCapability>();
/**
* Returns the names of all MCP servers currently registered as channels.
*/
export function getActiveChannelNames(): string[] {
return Array.from(activeChannels.keys());
}
+7
View File
@@ -665,6 +665,7 @@ export interface ConfigParameters {
billing?: {
overageStrategy?: OverageStrategy;
};
channels?: string[];
}
export class Config implements McpContext, AgentLoopContext {
@@ -868,6 +869,7 @@ export class Config implements McpContext, AgentLoopContext {
private disabledSkills: string[];
private readonly adminSkillsEnabled: boolean;
private readonly channels: string[];
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly topicUpdateNarration: boolean;
@@ -1133,6 +1135,7 @@ export class Config implements McpContext, AgentLoopContext {
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.enableConseca = params.enableConseca ?? false;
this.channels = params.channels ?? [];
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
@@ -2036,6 +2039,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpEnabled;
}
getChannels(): string[] {
return this.channels;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
+1
View File
@@ -115,6 +115,7 @@ export * from './utils/checkpointUtils.js';
export * from './utils/secure-browser-launcher.js';
export * from './utils/apiConversionUtils.js';
export * from './utils/channel.js';
export * from './channels/types.js';
export * from './utils/constants.js';
export * from './utils/sessionUtils.js';
export * from './utils/cache.js';
+54
View File
@@ -29,12 +29,14 @@ import {
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
ProgressNotificationSchema,
NotificationSchema,
type GetPromptResult,
type Prompt,
type ReadResourceResult,
type Resource,
type Tool as McpTool,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod/v4';
import { parse } from 'shell-quote';
import {
AuthProviderType,
@@ -70,6 +72,7 @@ import type { ToolRegistry } from './tool-registry.js';
import { debugLogger } from '../utils/debugLogger.js';
import { type MessageBus } from '../confirmation-bus/message-bus.js';
import { coreEvents } from '../utils/events.js';
import { activeChannels } from '../channels/types.js';
import {
type ResourceRegistry,
type MCPResource,
@@ -478,6 +481,56 @@ export class McpClient implements McpProgressReporter {
}
},
);
// Channel capability: if the server declares experimental['gemini/channel'],
// listen for channel notifications and route them through coreEvents.
// Only register if this server is in the --channels list.
const channelCap = capabilities?.experimental?.['gemini/channel'];
const enabledChannels = this.cliConfig.getChannels?.() ?? [];
if (channelCap && enabledChannels.includes(this.serverName)) {
debugLogger.log(
`Server '${this.serverName}' declares gemini/channel capability. Listening for channel messages...`,
);
const channelCapRecord: Record<string, unknown> =
channelCap != null && typeof channelCap === 'object'
? Object.fromEntries(Object.entries(channelCap))
: {};
activeChannels.set(this.serverName, {
supportsReply: capabilities?.tools != null,
displayName:
typeof channelCapRecord['displayName'] === 'string'
? channelCapRecord['displayName']
: undefined,
});
const ChannelNotificationSchema = NotificationSchema.extend({
method: z.literal('notifications/gemini/channel'),
});
this.client.setNotificationHandler(
ChannelNotificationSchema,
(notification) => {
const params: Record<string, unknown> = Object.fromEntries(
Object.entries(notification.params ?? {}),
);
const rawMeta = params['meta'];
const meta =
rawMeta != null && typeof rawMeta === 'object'
? Object.fromEntries(
Object.entries(rawMeta).map(([k, v]) => [k, String(v)]),
)
: undefined;
coreEvents.emitChannelMessage({
channelName: this.serverName,
sender: String(params['sender'] ?? 'unknown'),
content: String(params['content'] ?? ''),
timestamp: Date.now(),
replyTo: params['replyTo'] ? String(params['replyTo']) : undefined,
metadata: meta,
});
},
);
}
}
/**
@@ -1757,6 +1810,7 @@ export interface McpContext {
getPolicyEngine?(): {
getRules(): ReadonlyArray<{ toolName?: string; source?: string }>;
};
getChannels?(): string[];
}
/**
+12
View File
@@ -13,6 +13,7 @@ import type {
TokenStorageInitializationEvent,
KeychainAvailabilityEvent,
} from '../telemetry/types.js';
import type { ChannelMessagePayload } from '../channels/types.js';
import { debugLogger } from './debugLogger.js';
/**
@@ -192,6 +193,7 @@ export enum CoreEvent {
QuotaChanged = 'quota-changed',
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
TelemetryTokenStorageType = 'telemetry-token-storage-type',
ChannelMessage = 'channel-message',
}
/**
@@ -225,6 +227,7 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
[CoreEvent.ChannelMessage]: [ChannelMessagePayload];
}
type EventBacklogItem = {
@@ -400,6 +403,15 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.QuotaChanged, payload);
}
/**
* Forwards a channel message from an MCP server that declared the
* `gemini/channel` experimental capability.
* Buffers automatically if the UI hasn't subscribed yet.
*/
emitChannelMessage(payload: ChannelMessagePayload): void {
this._emitOrQueue(CoreEvent.ChannelMessage, payload);
}
/**
* Flushes buffered messages. Call this immediately after primary UI listener
* subscribes.