diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fdcd18c086..a1595fa5cd 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -98,6 +98,7 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; + channels: string[] | undefined; } /** @@ -299,6 +300,12 @@ export async function parseArguments( .option('accept-raw-output-risk', { type: 'boolean', description: 'Suppress the security warning when using --raw-output.', + }) + .option('channels', { + type: 'string', + array: true, + description: 'Enable channel message delivery from named MCP servers', + coerce: coerceCommaSeparated, }), ) // Register MCP subcommands @@ -930,6 +937,7 @@ export async function loadCliConfig( }; }, enableConseca: settings.security?.enableConseca, + channels: argv.channels, }); } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 31fec36db0..d9f3353dce 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + channels: undefined, }); await act(async () => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4722bb73f3..cebf839a5e 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -259,6 +259,13 @@ export async function main() { }); } + if (argv.channels && argv.channels.length > 0) { + coreEvents.emitFeedback( + 'info', + `Channels enabled: ${argv.channels.join(', ')}`, + ); + } + // Check for invalid input combinations early to prevent crashes if (argv.promptInteractive && !process.stdin.isTTY) { writeToStderr( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9d05f54347..2f836d931e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -67,6 +67,7 @@ import { refreshServerHierarchicalMemory, flattenMemory, type MemoryChangedPayload, + type ChannelMessagePayload, writeToStdout, disableMouseEvents, enterAlternateScreen, @@ -1220,6 +1221,26 @@ Logging in with Google... Restarting Gemini CLI to continue. isMcpReady, }); + // Listen for external channel messages from MCP servers declaring + // experimental['gemini/channel'] and inject them into the message queue. + const channelsEnabled = config.getChannels().length > 0; + useEffect(() => { + if (!channelsEnabled) return; + const handler = (payload: ChannelMessagePayload) => { + const meta = payload.metadata ?? {}; + const user = meta['user'] ?? payload.sender; + const chatId = meta['chat_id'] ?? ''; + const msgId = meta['message_id'] ?? ''; + const imagePath = meta['image_path'] ?? ''; + const formatted = `\n${payload.content}\n`; + addMessage(formatted); + }; + coreEvents.on(CoreEvent.ChannelMessage, handler); + return () => { + coreEvents.off(CoreEvent.ChannelMessage, handler); + }; + }, [channelsEnabled, addMessage]); + cancelHandlerRef.current = useCallback( (shouldRestorePrompt: boolean = true) => { const pendingHistoryItems = [ diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 9c8d90cd19..3ed711a377 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -30,6 +30,7 @@ import { getMCPServerStatus } from '@google/gemini-cli-core'; import { ToolsList } from './views/ToolsList.js'; import { SkillsList } from './views/SkillsList.js'; import { AgentsStatus } from './views/AgentsStatus.js'; +import { ChannelsList } from './views/ChannelsList.js'; import { McpStatus } from './views/McpStatus.js'; import { ChatList } from './views/ChatList.js'; import { ModelMessage } from './messages/ModelMessage.js'; @@ -227,6 +228,9 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth={terminalWidth} /> )} + {itemForDisplay.type === 'channels_list' && ( + + )} {itemForDisplay.type === 'mcp_status' && ( )} diff --git a/packages/cli/src/ui/components/views/ChannelsList.test.tsx b/packages/cli/src/ui/components/views/ChannelsList.test.tsx new file mode 100644 index 0000000000..542ea0d8f9 --- /dev/null +++ b/packages/cli/src/ui/components/views/ChannelsList.test.tsx @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { ChannelsList } from './ChannelsList.js'; +import { type ChannelInfo } from '../../types.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; + +const mockChannels: ChannelInfo[] = [ + { + name: 'telegram', + displayName: 'Telegram', + supportsReply: true, + }, + { + name: 'minimal-channel', + supportsReply: false, + }, +]; + +describe('', () => { + it('renders correctly with active channels', async () => { + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with no active channels', async () => { + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/views/ChannelsList.tsx b/packages/cli/src/ui/components/views/ChannelsList.tsx new file mode 100644 index 0000000000..ccd87fb866 --- /dev/null +++ b/packages/cli/src/ui/components/views/ChannelsList.tsx @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import type { ChannelInfo } from '../../types.js'; + +interface ChannelsListProps { + channels: readonly ChannelInfo[]; +} + +export const ChannelsList: React.FC = ({ channels }) => { + if (channels.length === 0) { + return ( + + No active channels. + + { + "MCP servers must declare `experimental['gemini/channel']` in their capabilities to act as channels." + } + + + ); + } + + return ( + + + Active channels: + + + + {channels.map((channel) => ( + + {' '}- + + + {channel.displayName || channel.name} ({channel.name}) + + + Direction:{' '} + + {channel.supportsReply ? 'two-way' : 'one-way'} + + + + + ))} + + + ); +}; diff --git a/packages/cli/src/ui/components/views/__snapshots__/ChannelsList.test.tsx.snap b/packages/cli/src/ui/components/views/__snapshots__/ChannelsList.test.tsx.snap new file mode 100644 index 0000000000..e3973d0db0 --- /dev/null +++ b/packages/cli/src/ui/components/views/__snapshots__/ChannelsList.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders correctly with active channels 1`] = ` +"Active channels: + + - Telegram (telegram) + Direction: two-way + - minimal-channel (minimal-channel) + Direction: one-way +" +`; + +exports[` > renders correctly with no active channels 1`] = ` +"No active channels. +MCP servers must declare \`experimental['gemini/channel']\` in their capabilities to act as channels. +" +`; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 2f8e414a83..d254f621fc 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -295,6 +295,17 @@ export type AgentDefinitionJson = Pick< 'name' | 'displayName' | 'description' | 'kind' >; +export interface ChannelInfo { + name: string; + displayName?: string; + supportsReply: boolean; +} + +export type HistoryItemChannelsList = HistoryItemBase & { + type: 'channels_list'; + channels: ChannelInfo[]; +}; + export type HistoryItemAgentsList = HistoryItemBase & { type: 'agents_list'; agents: AgentDefinitionJson[]; @@ -377,6 +388,7 @@ export type HistoryItemWithoutId = | HistoryItemToolsList | HistoryItemSkillsList | HistoryItemAgentsList + | HistoryItemChannelsList | HistoryItemMcpStatus | HistoryItemChatList | HistoryItemThinking @@ -402,6 +414,7 @@ export enum MessageType { TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', AGENTS_LIST = 'agents_list', + CHANNELS_LIST = 'channels_list', MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', HINT = 'hint', diff --git a/packages/core/src/channels/types.ts b/packages/core/src/channels/types.ts new file mode 100644 index 0000000000..47564e4733 --- /dev/null +++ b/packages/core/src/channels/types.ts @@ -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 tag. */ + metadata?: Record; +} + +/** + * 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(); + +/** + * Returns the names of all MCP servers currently registered as channels. + */ +export function getActiveChannelNames(): string[] { + return Array.from(activeChannels.keys()); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5bac6d086c..dd1bd93645 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 32572c86a0..8401628864 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 58b7b6c8e2..7dfda997d2 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -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 = + 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 = 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[]; } /** diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 47c42c93ba..722688841a 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -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 { 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.