mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-04 16:12:39 -07:00
feat: add channels support
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
channels: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = `<channel source="${payload.channelName}" chat_id="${chatId}" message_id="${msgId}" user="${user}"${imagePath ? ` image_path="${imagePath}"` : ''}>\n${payload.content}\n</channel>`;
|
||||
addMessage(formatted);
|
||||
};
|
||||
coreEvents.on(CoreEvent.ChannelMessage, handler);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ChannelMessage, handler);
|
||||
};
|
||||
}, [channelsEnabled, addMessage]);
|
||||
|
||||
cancelHandlerRef.current = useCallback(
|
||||
(shouldRestorePrompt: boolean = true) => {
|
||||
const pendingHistoryItems = [
|
||||
|
||||
@@ -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<HistoryItemDisplayProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'channels_list' && (
|
||||
<ChannelsList channels={itemForDisplay.channels} />
|
||||
)}
|
||||
{itemForDisplay.type === 'mcp_status' && (
|
||||
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
||||
)}
|
||||
|
||||
@@ -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('<ChannelsList />', () => {
|
||||
it('renders correctly with active channels', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ChannelsList channels={mockChannels} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly with no active channels', async () => {
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<ChannelsList channels={[]} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -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<ChannelsListProps> = ({ channels }) => {
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary}>No active channels.</Text>
|
||||
<Text color="gray">
|
||||
{
|
||||
"MCP servers must declare `experimental['gemini/channel']` in their capabilities to act as channels."
|
||||
}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Active channels:
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="column">
|
||||
{channels.map((channel) => (
|
||||
<Box key={channel.name} flexDirection="row">
|
||||
<Text color={theme.text.primary}>{' '}- </Text>
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.text.accent}>
|
||||
{channel.displayName || channel.name} ({channel.name})
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
Direction:{' '}
|
||||
<Text color={channel.supportsReply ? 'green' : 'gray'}>
|
||||
{channel.supportsReply ? 'two-way' : 'one-way'}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ChannelsList /> > renders correctly with active channels 1`] = `
|
||||
"Active channels:
|
||||
|
||||
- Telegram (telegram)
|
||||
Direction: two-way
|
||||
- minimal-channel (minimal-channel)
|
||||
Direction: one-way
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ChannelsList /> > renders correctly with no active channels 1`] = `
|
||||
"No active channels.
|
||||
MCP servers must declare \`experimental['gemini/channel']\` in their capabilities to act as channels.
|
||||
"
|
||||
`;
|
||||
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user