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.