mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 21:51:11 -07:00
Compare commits
17 Commits
experimental
...
channels
| Author | SHA1 | Date | |
|---|---|---|---|
| c6755e0ac7 | |||
| 2afbd2c99f | |||
| a565bd1f22 | |||
| a7f903b139 | |||
| d7002fa530 | |||
| 70b9e18a71 | |||
| f66fec3682 | |||
| df3e81a44e | |||
| 3861ecf7d4 | |||
| 78bd526792 | |||
| e10b2d708c | |||
| d695803f1f | |||
| 3980d90e0f | |||
| 5fdc2d6fbd | |||
| 4335517475 | |||
| 79225898c1 | |||
| 0b94546ee4 |
@@ -2350,6 +2350,11 @@ for that specific session.
|
|||||||
with screen readers.
|
with screen readers.
|
||||||
- **`--version`**:
|
- **`--version`**:
|
||||||
- Displays the version of the CLI.
|
- Displays the version of the CLI.
|
||||||
|
- **`--channels <channel1,channel2,...>`**:
|
||||||
|
- A comma-separated list of MCP server names to enable as message channels.
|
||||||
|
- When specified, the CLI will listen for asynchronous messages from these
|
||||||
|
servers and inject them into the conversation.
|
||||||
|
- Example: `gemini --channels telegram,slack`
|
||||||
- **`--yolo`**:
|
- **`--yolo`**:
|
||||||
- Enables YOLO mode, which automatically approves all tool calls.
|
- Enables YOLO mode, which automatically approves all tool calls.
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ async function testMCPConnection(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isTrustedFolder: () => isTrusted,
|
isTrustedFolder: () => isTrusted,
|
||||||
|
getChannels: () => [],
|
||||||
};
|
};
|
||||||
|
|
||||||
let transport;
|
let transport;
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export interface CliArgs {
|
|||||||
rawOutput: boolean | undefined;
|
rawOutput: boolean | undefined;
|
||||||
acceptRawOutputRisk: boolean | undefined;
|
acceptRawOutputRisk: boolean | undefined;
|
||||||
isCommand: boolean | undefined;
|
isCommand: boolean | undefined;
|
||||||
|
channels: string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -443,6 +444,12 @@ export async function parseArguments(
|
|||||||
.option('accept-raw-output-risk', {
|
.option('accept-raw-output-risk', {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Suppress the security warning when using --raw-output.',
|
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,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.version(await getVersion()) // This will enable the --version flag based on package.json
|
.version(await getVersion()) // This will enable the --version flag based on package.json
|
||||||
@@ -1048,6 +1055,7 @@ export async function loadCliConfig(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
enableConseca: settings.security?.enableConseca,
|
enableConseca: settings.security?.enableConseca,
|
||||||
|
channels: argv.channels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
rawOutput: undefined,
|
rawOutput: undefined,
|
||||||
acceptRawOutputRisk: undefined,
|
acceptRawOutputRisk: undefined,
|
||||||
isCommand: undefined,
|
isCommand: undefined,
|
||||||
|
channels: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -574,6 +575,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
rawOutput: undefined,
|
rawOutput: undefined,
|
||||||
acceptRawOutputRisk: undefined,
|
acceptRawOutputRisk: undefined,
|
||||||
isCommand: undefined,
|
isCommand: undefined,
|
||||||
|
channels: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
|||||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||||
import { authCommand } from '../ui/commands/authCommand.js';
|
import { authCommand } from '../ui/commands/authCommand.js';
|
||||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||||
|
import { channelsCommand } from '../ui/commands/channelsCommand.js';
|
||||||
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
|
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
|
||||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||||
import { commandsCommand } from '../ui/commands/commandsCommand.js';
|
import { commandsCommand } from '../ui/commands/commandsCommand.js';
|
||||||
@@ -121,6 +122,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
|
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
|
||||||
authCommand,
|
authCommand,
|
||||||
bugCommand,
|
bugCommand,
|
||||||
|
channelsCommand,
|
||||||
{
|
{
|
||||||
...chatCommand,
|
...chatCommand,
|
||||||
subCommands: chatResumeSubCommands,
|
subCommands: chatResumeSubCommands,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { checkPermissions } from './hooks/atCommandProcessor.js';
|
import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||||
import { MessageType, StreamingState } from './types.js';
|
import { MessageType, StreamingState } from './types.js';
|
||||||
|
import { theme } from './semantic-colors.js';
|
||||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||||
import { MouseProvider } from './contexts/MouseContext.js';
|
import { MouseProvider } from './contexts/MouseContext.js';
|
||||||
import { ScrollProvider } from './contexts/ScrollProvider.js';
|
import { ScrollProvider } from './contexts/ScrollProvider.js';
|
||||||
@@ -69,6 +70,7 @@ import {
|
|||||||
refreshServerHierarchicalMemory,
|
refreshServerHierarchicalMemory,
|
||||||
flattenMemory,
|
flattenMemory,
|
||||||
type MemoryChangedPayload,
|
type MemoryChangedPayload,
|
||||||
|
type ChannelMessagePayload,
|
||||||
writeToStdout,
|
writeToStdout,
|
||||||
disableMouseEvents,
|
disableMouseEvents,
|
||||||
enterAlternateScreen,
|
enterAlternateScreen,
|
||||||
@@ -1273,6 +1275,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isMcpReady,
|
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) => {
|
||||||
|
addMessage(payload.content);
|
||||||
|
};
|
||||||
|
coreEvents.on(CoreEvent.ChannelMessage, handler);
|
||||||
|
return () => {
|
||||||
|
coreEvents.off(CoreEvent.ChannelMessage, handler);
|
||||||
|
};
|
||||||
|
}, [channelsEnabled, addMessage]);
|
||||||
|
|
||||||
cancelHandlerRef.current = useCallback(
|
cancelHandlerRef.current = useCallback(
|
||||||
(shouldRestorePrompt: boolean = true) => {
|
(shouldRestorePrompt: boolean = true) => {
|
||||||
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||||
@@ -2094,10 +2110,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isChannel = payload.style === 'channel';
|
||||||
historyManager.addItem(
|
historyManager.addItem(
|
||||||
{
|
{
|
||||||
type,
|
type,
|
||||||
text: payload.message,
|
text: payload.message,
|
||||||
|
...(isChannel && {
|
||||||
|
icon: '» ',
|
||||||
|
color: theme.text.secondary,
|
||||||
|
marginTop: 0,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SlashCommand, CommandContext } from './types.js';
|
||||||
|
import { CommandKind } from './types.js';
|
||||||
|
import {
|
||||||
|
MessageType,
|
||||||
|
type ChannelInfo,
|
||||||
|
type HistoryItemChannelsList,
|
||||||
|
} from '../types.js';
|
||||||
|
import { activeChannels } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
export const channelsCommand: SlashCommand = {
|
||||||
|
name: 'channels',
|
||||||
|
description: 'List active message channels from MCP servers',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
autoExecute: true,
|
||||||
|
action: async (context: CommandContext) => {
|
||||||
|
const channels: ChannelInfo[] = Array.from(activeChannels.entries()).map(
|
||||||
|
([name, capability]) => ({
|
||||||
|
name,
|
||||||
|
displayName: capability.displayName,
|
||||||
|
supportsReply: capability.supportsReply,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelsListItem: HistoryItemChannelsList = {
|
||||||
|
type: MessageType.CHANNELS_LIST,
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
|
||||||
|
context.ui.addItem(channelsListItem);
|
||||||
|
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -31,6 +31,7 @@ import { getMCPServerStatus } from '@google/gemini-cli-core';
|
|||||||
import { ToolsList } from './views/ToolsList.js';
|
import { ToolsList } from './views/ToolsList.js';
|
||||||
import { SkillsList } from './views/SkillsList.js';
|
import { SkillsList } from './views/SkillsList.js';
|
||||||
import { AgentsStatus } from './views/AgentsStatus.js';
|
import { AgentsStatus } from './views/AgentsStatus.js';
|
||||||
|
import { ChannelsList } from './views/ChannelsList.js';
|
||||||
import { McpStatus } from './views/McpStatus.js';
|
import { McpStatus } from './views/McpStatus.js';
|
||||||
import { ChatList } from './views/ChatList.js';
|
import { ChatList } from './views/ChatList.js';
|
||||||
import { ModelMessage } from './messages/ModelMessage.js';
|
import { ModelMessage } from './messages/ModelMessage.js';
|
||||||
@@ -136,6 +137,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
|||||||
secondaryText={itemForDisplay.secondaryText}
|
secondaryText={itemForDisplay.secondaryText}
|
||||||
icon={itemForDisplay.icon}
|
icon={itemForDisplay.icon}
|
||||||
color={itemForDisplay.color}
|
color={itemForDisplay.color}
|
||||||
|
marginTop={itemForDisplay.marginTop}
|
||||||
marginBottom={itemForDisplay.marginBottom}
|
marginBottom={itemForDisplay.marginBottom}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -238,6 +240,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
|||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{itemForDisplay.type === 'channels_list' && (
|
||||||
|
<ChannelsList channels={itemForDisplay.channels} />
|
||||||
|
)}
|
||||||
{itemForDisplay.type === 'mcp_status' && (
|
{itemForDisplay.type === 'mcp_status' && (
|
||||||
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface InfoMessageProps {
|
|||||||
secondaryText?: string;
|
secondaryText?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
marginTop?: number;
|
||||||
marginBottom?: number;
|
marginBottom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
|
|||||||
secondaryText,
|
secondaryText,
|
||||||
icon,
|
icon,
|
||||||
color,
|
color,
|
||||||
|
marginTop,
|
||||||
marginBottom,
|
marginBottom,
|
||||||
}) => {
|
}) => {
|
||||||
color ??= theme.status.warning;
|
color ??= theme.status.warning;
|
||||||
@@ -29,7 +31,11 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
|
|||||||
const prefixWidth = prefix.length;
|
const prefixWidth = prefix.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" marginTop={1} marginBottom={marginBottom ?? 0}>
|
<Box
|
||||||
|
flexDirection="row"
|
||||||
|
marginTop={marginTop ?? 1}
|
||||||
|
marginBottom={marginBottom ?? 0}
|
||||||
|
>
|
||||||
<Box width={prefixWidth}>
|
<Box width={prefixWidth}>
|
||||||
<Text color={color}>{prefix}</Text>
|
<Text color={color}>{prefix}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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,64 @@
|
|||||||
|
/**
|
||||||
|
* @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 { RenderInline } from '../../utils/InlineMarkdownRenderer.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={theme.text.secondary}>
|
||||||
|
<RenderInline
|
||||||
|
text="Use `--channels <name>` to listen for channel messages from MCP servers."
|
||||||
|
defaultColor={theme.text.secondary}
|
||||||
|
/>
|
||||||
|
</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={theme.text.secondary}>
|
||||||
|
Direction:{' '}
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
channel.supportsReply
|
||||||
|
? theme.status.success
|
||||||
|
: theme.text.secondary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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.
|
||||||
|
Use --channels <name> to listen for channel messages from MCP servers.
|
||||||
|
"
|
||||||
|
`;
|
||||||
@@ -176,6 +176,7 @@ export type HistoryItemInfo = HistoryItemBase & {
|
|||||||
secondaryText?: string;
|
secondaryText?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
marginTop?: number;
|
||||||
marginBottom?: number;
|
marginBottom?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,6 +322,17 @@ export type AgentDefinitionJson = Pick<
|
|||||||
'name' | 'displayName' | 'description' | 'kind'
|
'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 & {
|
export type HistoryItemAgentsList = HistoryItemBase & {
|
||||||
type: 'agents_list';
|
type: 'agents_list';
|
||||||
agents: AgentDefinitionJson[];
|
agents: AgentDefinitionJson[];
|
||||||
@@ -400,6 +412,7 @@ export type HistoryItemWithoutId =
|
|||||||
| HistoryItemToolsList
|
| HistoryItemToolsList
|
||||||
| HistoryItemSkillsList
|
| HistoryItemSkillsList
|
||||||
| HistoryItemAgentsList
|
| HistoryItemAgentsList
|
||||||
|
| HistoryItemChannelsList
|
||||||
| HistoryItemMcpStatus
|
| HistoryItemMcpStatus
|
||||||
| HistoryItemChatList
|
| HistoryItemChatList
|
||||||
| HistoryItemThinking
|
| HistoryItemThinking
|
||||||
@@ -426,6 +439,7 @@ export enum MessageType {
|
|||||||
TOOLS_LIST = 'tools_list',
|
TOOLS_LIST = 'tools_list',
|
||||||
SKILLS_LIST = 'skills_list',
|
SKILLS_LIST = 'skills_list',
|
||||||
AGENTS_LIST = 'agents_list',
|
AGENTS_LIST = 'agents_list',
|
||||||
|
CHANNELS_LIST = 'channels_list',
|
||||||
MCP_STATUS = 'mcp_status',
|
MCP_STATUS = 'mcp_status',
|
||||||
CHAT_LIST = 'chat_list',
|
CHAT_LIST = 'chat_list',
|
||||||
HINT = 'hint',
|
HINT = 'hint',
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* @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.
|
||||||
|
*
|
||||||
|
* XML formatting and escaping happens at the trust boundary in mcp-client.ts,
|
||||||
|
* so `content` is a pre-formatted, escaped `<channel>` XML string ready for
|
||||||
|
* injection into the conversation.
|
||||||
|
*/
|
||||||
|
export interface ChannelMessagePayload {
|
||||||
|
/** Name of the MCP server acting as the channel. */
|
||||||
|
channelName: string;
|
||||||
|
/** Pre-formatted, escaped `<channel>` XML string. */
|
||||||
|
content: 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a channel entry when its MCP server disconnects.
|
||||||
|
*/
|
||||||
|
export function removeChannel(name: string): void {
|
||||||
|
activeChannels.delete(name);
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
type TelemetryTarget,
|
type TelemetryTarget,
|
||||||
} from '../telemetry/index.js';
|
} from '../telemetry/index.js';
|
||||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
import { coreEvents, CoreEvent } from '../utils/events.js';
|
||||||
|
import { activeChannels } from '../channels/types.js';
|
||||||
import { tokenLimit } from '../core/tokenLimits.js';
|
import { tokenLimit } from '../core/tokenLimits.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||||
@@ -725,6 +726,7 @@ export interface ConfigParameters {
|
|||||||
billing?: {
|
billing?: {
|
||||||
overageStrategy?: OverageStrategy;
|
overageStrategy?: OverageStrategy;
|
||||||
};
|
};
|
||||||
|
channels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config implements McpContext, AgentLoopContext {
|
export class Config implements McpContext, AgentLoopContext {
|
||||||
@@ -938,6 +940,8 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
private readonly skillsSupport: boolean;
|
private readonly skillsSupport: boolean;
|
||||||
private disabledSkills: string[];
|
private disabledSkills: string[];
|
||||||
private readonly adminSkillsEnabled: boolean;
|
private readonly adminSkillsEnabled: boolean;
|
||||||
|
|
||||||
|
private readonly channels: string[];
|
||||||
private readonly experimentalJitContext: boolean;
|
private readonly experimentalJitContext: boolean;
|
||||||
private readonly experimentalMemoryManager: boolean;
|
private readonly experimentalMemoryManager: boolean;
|
||||||
private readonly memoryBoundaryMarkers: readonly string[];
|
private readonly memoryBoundaryMarkers: readonly string[];
|
||||||
@@ -1273,6 +1277,7 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
this.fileExclusions = new FileExclusions(this);
|
this.fileExclusions = new FileExclusions(this);
|
||||||
this.eventEmitter = params.eventEmitter;
|
this.eventEmitter = params.eventEmitter;
|
||||||
this.enableConseca = params.enableConseca ?? false;
|
this.enableConseca = params.enableConseca ?? false;
|
||||||
|
this.channels = params.channels ?? [];
|
||||||
|
|
||||||
// Initialize Safety Infrastructure
|
// Initialize Safety Infrastructure
|
||||||
const contextBuilder = new ContextBuilder(this);
|
const contextBuilder = new ContextBuilder(this);
|
||||||
@@ -1456,6 +1461,26 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
debugLogger.error('Error initializing MCP clients:', result.reason);
|
debugLogger.error('Error initializing MCP clients:', result.reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Report channel status after all MCP servers have initialized.
|
||||||
|
if (this.channels.length > 0) {
|
||||||
|
const active = this.channels.filter((name) => activeChannels.has(name));
|
||||||
|
if (active.length > 0) {
|
||||||
|
coreEvents.emitFeedback(
|
||||||
|
'info',
|
||||||
|
`Channels listening for messages: ${active.join(', ')}\nOnly use channels you trust — messages are injected into the conversation.`,
|
||||||
|
undefined,
|
||||||
|
{ style: 'channel' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const name of this.channels) {
|
||||||
|
if (!activeChannels.has(name)) {
|
||||||
|
coreEvents.emitFeedback(
|
||||||
|
'warning',
|
||||||
|
`Channel "${name}" was requested but the MCP server did not declare channel capability.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!this.interactive || this.acpMode) {
|
if (!this.interactive || this.acpMode) {
|
||||||
@@ -2238,6 +2263,10 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
return this.mcpEnabled;
|
return this.mcpEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getChannels(): string[] {
|
||||||
|
return this.channels;
|
||||||
|
}
|
||||||
|
|
||||||
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
||||||
return this.mcpEnablementCallbacks;
|
return this.mcpEnablementCallbacks;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export * from './utils/checkpointUtils.js';
|
|||||||
export * from './utils/secure-browser-launcher.js';
|
export * from './utils/secure-browser-launcher.js';
|
||||||
export * from './utils/apiConversionUtils.js';
|
export * from './utils/apiConversionUtils.js';
|
||||||
export * from './utils/channel.js';
|
export * from './utils/channel.js';
|
||||||
|
export * from './channels/types.js';
|
||||||
export * from './utils/constants.js';
|
export * from './utils/constants.js';
|
||||||
export * from './utils/sessionUtils.js';
|
export * from './utils/sessionUtils.js';
|
||||||
export * from './utils/cache.js';
|
export * from './utils/cache.js';
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import * as fs from 'node:fs';
|
|||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { coreEvents } from '../utils/events.js';
|
import { coreEvents } from '../utils/events.js';
|
||||||
|
import { activeChannels } from '../channels/types.js';
|
||||||
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
|
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
|
||||||
|
|
||||||
interface TestableTransport {
|
interface TestableTransport {
|
||||||
@@ -63,6 +64,7 @@ const MOCK_CONTEXT_DEFAULT = {
|
|||||||
emitMcpDiagnostic: vi.fn(),
|
emitMcpDiagnostic: vi.fn(),
|
||||||
setUserInteractedWithMcp: vi.fn(),
|
setUserInteractedWithMcp: vi.fn(),
|
||||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||||
|
getChannels: vi.fn().mockReturnValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
let MOCK_CONTEXT: McpContext = MOCK_CONTEXT_DEFAULT;
|
let MOCK_CONTEXT: McpContext = MOCK_CONTEXT_DEFAULT;
|
||||||
@@ -80,6 +82,8 @@ vi.mock('../utils/events.js', () => ({
|
|||||||
coreEvents: {
|
coreEvents: {
|
||||||
emitFeedback: vi.fn(),
|
emitFeedback: vi.fn(),
|
||||||
emitConsoleLog: vi.fn(),
|
emitConsoleLog: vi.fn(),
|
||||||
|
emitChannelMessage: vi.fn(),
|
||||||
|
emitMcpProgress: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -88,11 +92,13 @@ describe('mcp-client', () => {
|
|||||||
let testWorkspace: string;
|
let testWorkspace: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
activeChannels.clear();
|
||||||
MOCK_CONTEXT = {
|
MOCK_CONTEXT = {
|
||||||
sanitizationConfig: EMPTY_CONFIG,
|
sanitizationConfig: EMPTY_CONFIG,
|
||||||
emitMcpDiagnostic: vi.fn(),
|
emitMcpDiagnostic: vi.fn(),
|
||||||
setUserInteractedWithMcp: vi.fn(),
|
setUserInteractedWithMcp: vi.fn(),
|
||||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||||
|
getChannels: vi.fn().mockReturnValue([]),
|
||||||
};
|
};
|
||||||
// create a tmp dir for this test
|
// create a tmp dir for this test
|
||||||
// Create a unique temporary directory for the workspace to avoid conflicts
|
// Create a unique temporary directory for the workspace to avoid conflicts
|
||||||
@@ -103,6 +109,7 @@ describe('mcp-client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
activeChannels.clear();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@@ -1110,12 +1117,16 @@ describe('mcp-client', () => {
|
|||||||
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
|
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
|
||||||
expect(mockedPromptRegistry.registerPrompt).toHaveBeenCalledOnce();
|
expect(mockedPromptRegistry.registerPrompt).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// Simulate a channel entry being registered for this server
|
||||||
|
activeChannels.set('test-server', { supportsReply: false });
|
||||||
|
|
||||||
await client.disconnect();
|
await client.disconnect();
|
||||||
|
|
||||||
expect(mockedClient.close).toHaveBeenCalledOnce();
|
expect(mockedClient.close).toHaveBeenCalledOnce();
|
||||||
expect(mockedToolRegistry.removeMcpToolsByServer).toHaveBeenCalledOnce();
|
expect(mockedToolRegistry.removeMcpToolsByServer).toHaveBeenCalledOnce();
|
||||||
expect(mockedPromptRegistry.removePromptsByServer).toHaveBeenCalledOnce();
|
expect(mockedPromptRegistry.removePromptsByServer).toHaveBeenCalledOnce();
|
||||||
expect(resourceRegistry.removeResourcesByServer).toHaveBeenCalledOnce();
|
expect(resourceRegistry.removeResourcesByServer).toHaveBeenCalledOnce();
|
||||||
|
expect(activeChannels.has('test-server')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1731,6 +1742,132 @@ describe('mcp-client', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Channel notifications', () => {
|
||||||
|
const CHANNEL_CAPABILITIES = {
|
||||||
|
experimental: { 'gemini/channel': { displayName: 'Test' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock MCP client, connects a McpClient, and returns
|
||||||
|
* the channel notification handler (or null if none was registered).
|
||||||
|
* The channel handler is always the last setNotificationHandler call
|
||||||
|
* when the server is in the --channels list (registered after Progress).
|
||||||
|
*/
|
||||||
|
async function connectWithChannels(channels: string[]) {
|
||||||
|
const mockedClient = {
|
||||||
|
connect: vi.fn(),
|
||||||
|
getServerCapabilities: vi.fn().mockReturnValue(CHANNEL_CAPABILITIES),
|
||||||
|
setNotificationHandler: vi.fn(),
|
||||||
|
request: vi.fn().mockResolvedValue({}),
|
||||||
|
registerCapabilities: vi.fn(),
|
||||||
|
setRequestHandler: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
||||||
|
mockedClient as unknown as ClientLib.Client,
|
||||||
|
);
|
||||||
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
||||||
|
{} as SdkClientStdioLib.StdioClientTransport,
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = new McpClient(
|
||||||
|
'test-server',
|
||||||
|
{ command: 'test-command' },
|
||||||
|
workspaceContext,
|
||||||
|
{ ...MOCK_CONTEXT, getChannels: vi.fn().mockReturnValue(channels) },
|
||||||
|
false,
|
||||||
|
'0.0.1',
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const handlerCalls = mockedClient.setNotificationHandler.mock.calls;
|
||||||
|
return { mockedClient, handlerCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastHandler(
|
||||||
|
handlerCalls: any[][],
|
||||||
|
): ((notification: any) => void) | undefined {
|
||||||
|
return handlerCalls.length > 0
|
||||||
|
? handlerCalls[handlerCalls.length - 1][1]
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmittedContent(): string {
|
||||||
|
return (coreEvents.emitChannelMessage as any).mock.calls[0][0].content;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should register handler when server declares capability and is in --channels list', async () => {
|
||||||
|
const { handlerCalls: withChannel } = await connectWithChannels([
|
||||||
|
'test-server',
|
||||||
|
]);
|
||||||
|
const { handlerCalls: withoutChannel } = await connectWithChannels([]);
|
||||||
|
|
||||||
|
// When in --channels list, an extra handler is registered (the channel one).
|
||||||
|
expect(withChannel.length).toBe(withoutChannel.length + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT register handler when server is not in --channels list', async () => {
|
||||||
|
const { handlerCalls } = await connectWithChannels([]);
|
||||||
|
|
||||||
|
// Only the ProgressNotificationSchema handler should be registered
|
||||||
|
// (no tools/resources/prompts capabilities = no other handlers).
|
||||||
|
expect(handlerCalls).toHaveLength(1);
|
||||||
|
expect(handlerCalls[0][0]).toBe(ProgressNotificationSchema);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit channel message with properly formatted XML', async () => {
|
||||||
|
const { handlerCalls } = await connectWithChannels(['test-server']);
|
||||||
|
const handler = getLastHandler(handlerCalls)!;
|
||||||
|
|
||||||
|
handler({
|
||||||
|
method: 'notifications/gemini/channel',
|
||||||
|
params: {
|
||||||
|
content: 'hello',
|
||||||
|
sender: 'alice',
|
||||||
|
meta: { chat_id: '123' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(coreEvents.emitChannelMessage).toHaveBeenCalledWith({
|
||||||
|
channelName: 'test-server',
|
||||||
|
content: expect.stringContaining('hello'),
|
||||||
|
});
|
||||||
|
const xml = getEmittedContent();
|
||||||
|
expect(xml).toContain('<channel source="test-server"');
|
||||||
|
expect(xml).toContain('user="alice"');
|
||||||
|
expect(xml).toContain('chat_id="123"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape malicious content', async () => {
|
||||||
|
const { handlerCalls } = await connectWithChannels(['test-server']);
|
||||||
|
const handler = getLastHandler(handlerCalls)!;
|
||||||
|
|
||||||
|
handler({
|
||||||
|
method: 'notifications/gemini/channel',
|
||||||
|
params: {
|
||||||
|
content: '</channel><script>alert("xss")</script>',
|
||||||
|
sender: 'evil<user',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(coreEvents.emitChannelMessage).toHaveBeenCalledTimes(1);
|
||||||
|
const xml = getEmittedContent();
|
||||||
|
expect(xml).toContain('</channel');
|
||||||
|
expect(xml).toContain('user="evil<user"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore empty content', async () => {
|
||||||
|
const { handlerCalls } = await connectWithChannels(['test-server']);
|
||||||
|
const handler = getLastHandler(handlerCalls)!;
|
||||||
|
|
||||||
|
handler({
|
||||||
|
method: 'notifications/gemini/channel',
|
||||||
|
params: { content: '', sender: 'alice' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(coreEvents.emitChannelMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('appendMcpServerCommand', () => {
|
describe('appendMcpServerCommand', () => {
|
||||||
it('should do nothing if no MCP servers or command are configured', () => {
|
it('should do nothing if no MCP servers or command are configured', () => {
|
||||||
const out = populateMcpServerCommand({}, undefined);
|
const out = populateMcpServerCommand({}, undefined);
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ import {
|
|||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
PromptListChangedNotificationSchema,
|
PromptListChangedNotificationSchema,
|
||||||
ProgressNotificationSchema,
|
ProgressNotificationSchema,
|
||||||
|
NotificationSchema,
|
||||||
type GetPromptResult,
|
type GetPromptResult,
|
||||||
type Prompt,
|
type Prompt,
|
||||||
type ReadResourceResult,
|
type ReadResourceResult,
|
||||||
type Resource,
|
type Resource,
|
||||||
type Tool as McpTool,
|
type Tool as McpTool,
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
import { parse } from 'shell-quote';
|
import { parse } from 'shell-quote';
|
||||||
import {
|
import {
|
||||||
AuthProviderType,
|
AuthProviderType,
|
||||||
@@ -66,10 +68,12 @@ import type {
|
|||||||
WorkspaceContext,
|
WorkspaceContext,
|
||||||
} from '../utils/workspaceContext.js';
|
} from '../utils/workspaceContext.js';
|
||||||
import { getToolCallContext } from '../utils/toolCallContext.js';
|
import { getToolCallContext } from '../utils/toolCallContext.js';
|
||||||
|
import { escapeXml, sanitizeXmlKey } from '../utils/textUtils.js';
|
||||||
import type { ToolRegistry } from './tool-registry.js';
|
import type { ToolRegistry } from './tool-registry.js';
|
||||||
import { debugLogger } from '../utils/debugLogger.js';
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
import { type MessageBus } from '../confirmation-bus/message-bus.js';
|
import { type MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
import { coreEvents } from '../utils/events.js';
|
import { coreEvents } from '../utils/events.js';
|
||||||
|
import { activeChannels, removeChannel } from '../channels/types.js';
|
||||||
import {
|
import {
|
||||||
type ResourceRegistry,
|
type ResourceRegistry,
|
||||||
type MCPResource,
|
type MCPResource,
|
||||||
@@ -286,6 +290,7 @@ export class McpClient implements McpProgressReporter {
|
|||||||
registries.promptRegistry.removePromptsByServer(this.serverName);
|
registries.promptRegistry.removePromptsByServer(this.serverName);
|
||||||
registries.resourceRegistry.removeResourcesByServer(this.serverName);
|
registries.resourceRegistry.removeResourcesByServer(this.serverName);
|
||||||
}
|
}
|
||||||
|
removeChannel(this.serverName);
|
||||||
this.updateStatus(MCPServerStatus.DISCONNECTING);
|
this.updateStatus(MCPServerStatus.DISCONNECTING);
|
||||||
const client = this.client;
|
const client = this.client;
|
||||||
this.client = undefined;
|
this.client = undefined;
|
||||||
@@ -478,6 +483,67 @@ 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))
|
||||||
|
: {};
|
||||||
|
const rawDisplayName = channelCapRecord['displayName'];
|
||||||
|
activeChannels.set(this.serverName, {
|
||||||
|
supportsReply: capabilities?.tools != null,
|
||||||
|
displayName:
|
||||||
|
typeof rawDisplayName === 'string' ? rawDisplayName : 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 content = params['content'];
|
||||||
|
if (typeof content !== 'string' || !content) return;
|
||||||
|
|
||||||
|
const rawMeta = params['meta'];
|
||||||
|
const metaObj: Record<string, string> =
|
||||||
|
rawMeta != null && typeof rawMeta === 'object'
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(rawMeta).map(([k, v]) => [k, String(v)]),
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
metaObj['user'] =
|
||||||
|
metaObj['user'] ?? String(params['sender'] ?? 'unknown');
|
||||||
|
|
||||||
|
const attrs = Object.entries(metaObj)
|
||||||
|
.filter(([, v]) => v !== '')
|
||||||
|
.map(([k, v]) => `${sanitizeXmlKey(k)}="${escapeXml(v)}"`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const safeContent = content.replace(/<\/channel/gi, '</channel');
|
||||||
|
|
||||||
|
const source = escapeXml(this.serverName);
|
||||||
|
const formattedXml = `<channel source="${source}"${attrs ? ' ' + attrs : ''}>\n${safeContent}\n</channel>`;
|
||||||
|
|
||||||
|
coreEvents.emitChannelMessage({
|
||||||
|
channelName: this.serverName,
|
||||||
|
content: formattedXml,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1761,6 +1827,7 @@ export interface McpContext {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
getChannels(): string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { ToolErrorType } from './tool-error.js';
|
|||||||
import { getErrorMessage } from '../utils/errors.js';
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
import { getResponseText } from '../utils/partUtils.js';
|
import { getResponseText } from '../utils/partUtils.js';
|
||||||
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
|
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
|
||||||
import { truncateString } from '../utils/textUtils.js';
|
import { truncateString, escapeXml } from '../utils/textUtils.js';
|
||||||
import { convert } from 'html-to-text';
|
import { convert } from 'html-to-text';
|
||||||
import {
|
import {
|
||||||
logWebFetchFallbackAttempt,
|
logWebFetchFallbackAttempt,
|
||||||
@@ -188,18 +188,6 @@ function isGroundingSupportItem(item: unknown): item is GroundingSupportItem {
|
|||||||
return typeof item === 'object' && item !== null;
|
return typeof item === 'object' && item !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes text for safe embedding in XML tags.
|
|
||||||
*/
|
|
||||||
function sanitizeXml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for the WebFetch tool
|
* Parameters for the WebFetch tool
|
||||||
*/
|
*/
|
||||||
@@ -444,10 +432,10 @@ class WebFetchToolInvocation extends BaseToolInvocation<
|
|||||||
.map((url) => {
|
.map((url) => {
|
||||||
const content = finalContentsByUrl.get(url);
|
const content = finalContentsByUrl.get(url);
|
||||||
if (content !== undefined) {
|
if (content !== undefined) {
|
||||||
return `<source url="${sanitizeXml(url)}">\n${sanitizeXml(content)}\n</source>`;
|
return `<source url="${escapeXml(url)}">\n${escapeXml(content)}\n</source>`;
|
||||||
}
|
}
|
||||||
const error = errors.find((e) => e.url === url);
|
const error = errors.find((e) => e.url === url);
|
||||||
return `<source url="${sanitizeXml(url)}">\nError: ${sanitizeXml(error?.message || 'Unknown error')}\n</source>`;
|
return `<source url="${escapeXml(url)}">\nError: ${escapeXml(error?.message || 'Unknown error')}\n</source>`;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
@@ -456,7 +444,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
|
|||||||
const fallbackPrompt = `Follow the user's instructions below using the provided webpage content.
|
const fallbackPrompt = `Follow the user's instructions below using the provided webpage content.
|
||||||
|
|
||||||
<user_instructions>
|
<user_instructions>
|
||||||
${sanitizeXml(this.params.prompt ?? '')}
|
${escapeXml(this.params.prompt ?? '')}
|
||||||
</user_instructions>
|
</user_instructions>
|
||||||
|
|
||||||
I was unable to access the URL(s) directly using the primary fetch tool. Instead, I have fetched the raw content of the page(s). Please use the following content to answer the request. Do not attempt to access the URL(s) again.
|
I was unable to access the URL(s) directly using the primary fetch tool. Instead, I have fetched the raw content of the page(s). Please use the following content to answer the request. Do not attempt to access the URL(s) again.
|
||||||
@@ -789,7 +777,7 @@ Response: ${rawResponseText}`;
|
|||||||
const sanitizedPrompt = `Follow the user's instructions to process the authorized URLs.
|
const sanitizedPrompt = `Follow the user's instructions to process the authorized URLs.
|
||||||
|
|
||||||
<user_instructions>
|
<user_instructions>
|
||||||
${sanitizeXml(userPrompt)}
|
${escapeXml(userPrompt)}
|
||||||
</user_instructions>
|
</user_instructions>
|
||||||
|
|
||||||
<authorized_urls>
|
<authorized_urls>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
TokenStorageInitializationEvent,
|
TokenStorageInitializationEvent,
|
||||||
KeychainAvailabilityEvent,
|
KeychainAvailabilityEvent,
|
||||||
} from '../telemetry/types.js';
|
} from '../telemetry/types.js';
|
||||||
|
import type { ChannelMessagePayload } from '../channels/types.js';
|
||||||
import { debugLogger } from './debugLogger.js';
|
import { debugLogger } from './debugLogger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,12 @@ export interface UserFeedbackPayload {
|
|||||||
* or verbose output, while keeping the 'message' field clean for end users.
|
* or verbose output, while keeping the 'message' field clean for end users.
|
||||||
*/
|
*/
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
|
/**
|
||||||
|
* Optional semantic style hint for the UI.
|
||||||
|
* 'channel' renders with secondary text color and a » icon,
|
||||||
|
* suitable for channel status messages.
|
||||||
|
*/
|
||||||
|
style?: 'channel';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,6 +202,7 @@ export enum CoreEvent {
|
|||||||
QuotaChanged = 'quota-changed',
|
QuotaChanged = 'quota-changed',
|
||||||
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
|
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
|
||||||
TelemetryTokenStorageType = 'telemetry-token-storage-type',
|
TelemetryTokenStorageType = 'telemetry-token-storage-type',
|
||||||
|
ChannelMessage = 'channel-message',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,6 +236,7 @@ export interface CoreEvents extends ExtensionEvents {
|
|||||||
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
|
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
|
||||||
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
|
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
|
||||||
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
|
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
|
||||||
|
[CoreEvent.ChannelMessage]: [ChannelMessagePayload];
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventBacklogItem = {
|
type EventBacklogItem = {
|
||||||
@@ -282,8 +291,14 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
|||||||
severity: FeedbackSeverity,
|
severity: FeedbackSeverity,
|
||||||
message: string,
|
message: string,
|
||||||
error?: unknown,
|
error?: unknown,
|
||||||
|
options?: { style?: 'channel' },
|
||||||
): void {
|
): void {
|
||||||
const payload: UserFeedbackPayload = { severity, message, error };
|
const payload: UserFeedbackPayload = {
|
||||||
|
severity,
|
||||||
|
message,
|
||||||
|
error,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
this._emitOrQueue(CoreEvent.UserFeedback, payload);
|
this._emitOrQueue(CoreEvent.UserFeedback, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +418,15 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
|||||||
this.emit(CoreEvent.QuotaChanged, payload);
|
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
|
* Flushes buffered messages. Call this immediately after primary UI listener
|
||||||
* subscribes.
|
* subscribes.
|
||||||
|
|||||||
@@ -121,6 +121,27 @@ export function truncateString(
|
|||||||
* @param replacements A record of keys to their replacement values.
|
* @param replacements A record of keys to their replacement values.
|
||||||
* @returns The resulting string with placeholders replaced.
|
* @returns The resulting string with placeholders replaced.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Escapes a string for safe embedding in XML content or attributes.
|
||||||
|
* Replaces &, <, >, ", and ' with their XML entity equivalents.
|
||||||
|
*/
|
||||||
|
export function escapeXml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips characters that are not valid in XML element/attribute names.
|
||||||
|
* Only allows alphanumeric characters and underscores.
|
||||||
|
*/
|
||||||
|
export function sanitizeXmlKey(s: string): string {
|
||||||
|
return s.replace(/[^a-zA-Z0-9_]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
export function safeTemplateReplace(
|
export function safeTemplateReplace(
|
||||||
template: string,
|
template: string,
|
||||||
replacements: Record<string, string>,
|
replacements: Record<string, string>,
|
||||||
|
|||||||
Reference in New Issue
Block a user