chore: update channels

This commit is contained in:
Jack Wotherspoon
2026-03-27 13:09:59 -04:00
parent e10b2d708c
commit 78bd526792
8 changed files with 203 additions and 58 deletions
+1 -17
View File
@@ -1218,24 +1218,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const channelsEnabled = config.getChannels().length > 0;
useEffect(() => {
if (!channelsEnabled) return;
const escapeAttr = (s: string) =>
s
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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 safeContent = payload.content.replace(
/<\/channel>/gi,
'&lt;/channel&gt;',
);
const formatted = `<channel source="${escapeAttr(payload.channelName)}" chat_id="${escapeAttr(chatId)}" message_id="${escapeAttr(msgId)}" user="${escapeAttr(user)}"${imagePath ? ` image_path="${escapeAttr(imagePath)}"` : ''}>\n${safeContent}\n</channel>`;
addMessage(formatted);
addMessage(payload.content);
};
coreEvents.on(CoreEvent.ChannelMessage, handler);
return () => {
@@ -46,7 +46,11 @@ export const ChannelsList: React.FC<ChannelsListProps> = ({ channels }) => {
<Text color={theme.text.secondary}>
Direction:{' '}
<Text
color={channel.supportsReply ? 'green' : theme.text.secondary}
color={
channel.supportsReply
? theme.status.success
: theme.text.secondary
}
>
{channel.supportsReply ? 'two-way' : 'one-way'}
</Text>
+12 -9
View File
@@ -8,20 +8,16 @@
* 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;
/** Sender identifier (e.g. Telegram username, Discord user ID). */
sender: string;
/** The message body. */
/** Pre-formatted, escaped `<channel>` XML string. */
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>;
}
/**
@@ -47,3 +43,10 @@ export const activeChannels = new Map<string, ChannelCapability>();
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);
}
+1 -1
View File
@@ -1387,7 +1387,7 @@ export class Config implements McpContext, AgentLoopContext {
if (active.length > 0) {
coreEvents.emitFeedback(
'info',
`Channels listening for messages: ${active.join(', ')}`,
`Channels listening for messages: ${active.join(', ')}\n Only use channels you trust — messages are injected into the conversation.`,
undefined,
{ style: 'channel' },
);
+135
View File
@@ -43,6 +43,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { coreEvents } from '../utils/events.js';
import { activeChannels } from '../channels/types.js';
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
interface TestableTransport {
@@ -63,6 +64,7 @@ const MOCK_CONTEXT_DEFAULT = {
emitMcpDiagnostic: vi.fn(),
setUserInteractedWithMcp: vi.fn(),
isTrustedFolder: vi.fn().mockReturnValue(true),
getChannels: vi.fn().mockReturnValue([]),
};
let MOCK_CONTEXT: McpContext = MOCK_CONTEXT_DEFAULT;
@@ -80,6 +82,8 @@ vi.mock('../utils/events.js', () => ({
coreEvents: {
emitFeedback: vi.fn(),
emitConsoleLog: vi.fn(),
emitChannelMessage: vi.fn(),
emitMcpProgress: vi.fn(),
},
}));
@@ -93,6 +97,7 @@ describe('mcp-client', () => {
emitMcpDiagnostic: vi.fn(),
setUserInteractedWithMcp: vi.fn(),
isTrustedFolder: vi.fn().mockReturnValue(true),
getChannels: vi.fn().mockReturnValue([]),
};
// create a tmp dir for this test
// Create a unique temporary directory for the workspace to avoid conflicts
@@ -1110,12 +1115,16 @@ describe('mcp-client', () => {
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
expect(mockedPromptRegistry.registerPrompt).toHaveBeenCalledOnce();
// Simulate a channel entry being registered for this server
activeChannels.set('test-server', { supportsReply: false });
await client.disconnect();
expect(mockedClient.close).toHaveBeenCalledOnce();
expect(mockedToolRegistry.removeMcpToolsByServer).toHaveBeenCalledOnce();
expect(mockedPromptRegistry.removePromptsByServer).toHaveBeenCalledOnce();
expect(resourceRegistry.removeResourcesByServer).toHaveBeenCalledOnce();
expect(activeChannels.has('test-server')).toBe(false);
});
});
@@ -1731,6 +1740,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('&lt;/channel');
expect(xml).toContain('user="evil&lt;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', () => {
it('should do nothing if no MCP servers or command are configured', () => {
const out = populateMcpServerCommand({}, undefined);
+23 -13
View File
@@ -68,11 +68,12 @@ import type {
WorkspaceContext,
} from '../utils/workspaceContext.js';
import { getToolCallContext } from '../utils/toolCallContext.js';
import { escapeXml, sanitizeXmlKey } from '../utils/textUtils.js';
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 { activeChannels, removeChannel } from '../channels/types.js';
import {
type ResourceRegistry,
type MCPResource,
@@ -289,6 +290,7 @@ export class McpClient implements McpProgressReporter {
registries.promptRegistry.removePromptsByServer(this.serverName);
registries.resourceRegistry.removeResourcesByServer(this.serverName);
}
removeChannel(this.serverName);
this.updateStatus(MCPServerStatus.DISCONNECTING);
const client = this.client;
this.client = undefined;
@@ -486,7 +488,7 @@ export class McpClient implements McpProgressReporter {
// 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?.() ?? [];
const enabledChannels = this.cliConfig.getChannels();
if (channelCap && enabledChannels.includes(this.serverName)) {
debugLogger.log(
`Server '${this.serverName}' declares gemini/channel capability. Listening for channel messages...`,
@@ -496,12 +498,11 @@ export class McpClient implements McpProgressReporter {
channelCap != null && typeof channelCap === 'object'
? Object.fromEntries(Object.entries(channelCap))
: {};
const rawDisplayName = channelCapRecord['displayName'];
activeChannels.set(this.serverName, {
supportsReply: capabilities?.tools != null,
displayName:
typeof channelCapRecord['displayName'] === 'string'
? channelCapRecord['displayName']
: undefined,
typeof rawDisplayName === 'string' ? rawDisplayName : undefined,
});
const ChannelNotificationSchema = NotificationSchema.extend({
@@ -517,19 +518,28 @@ export class McpClient implements McpProgressReporter {
if (typeof content !== 'string' || !content) return;
const rawMeta = params['meta'];
const meta =
const metaObj: Record<string, string> =
rawMeta != null && typeof rawMeta === 'object'
? Object.fromEntries(
Object.entries(rawMeta).map(([k, v]) => [k, String(v)]),
)
: undefined;
: {};
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, '&lt;/channel');
const source = escapeXml(this.serverName);
const formattedXml = `<channel source="${source}"${attrs ? ' ' + attrs : ''}>\n${safeContent}\n</channel>`;
coreEvents.emitChannelMessage({
channelName: this.serverName,
sender: String(params['sender'] ?? 'unknown'),
content,
timestamp: Date.now(),
replyTo: params['replyTo'] ? String(params['replyTo']) : undefined,
metadata: meta,
content: formattedXml,
});
},
);
@@ -1817,7 +1827,7 @@ export interface McpContext {
source?: string;
}>;
};
getChannels?(): string[];
getChannels(): string[];
}
/**
+5 -17
View File
@@ -19,7 +19,7 @@ import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
import { getResponseText } from '../utils/partUtils.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 {
logWebFetchFallbackAttempt,
@@ -188,18 +188,6 @@ function isGroundingSupportItem(item: unknown): item is GroundingSupportItem {
return typeof item === 'object' && item !== null;
}
/**
* Sanitizes text for safe embedding in XML tags.
*/
function sanitizeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Parameters for the WebFetch tool
*/
@@ -434,10 +422,10 @@ class WebFetchToolInvocation extends BaseToolInvocation<
.map((url) => {
const content = finalContentsByUrl.get(url);
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);
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');
@@ -446,7 +434,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
const fallbackPrompt = `Follow the user's instructions below using the provided webpage content.
<user_instructions>
${sanitizeXml(this.params.prompt ?? '')}
${escapeXml(this.params.prompt ?? '')}
</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.
@@ -771,7 +759,7 @@ Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response trun
const sanitizedPrompt = `Follow the user's instructions to process the authorized URLs.
<user_instructions>
${sanitizeXml(userPrompt)}
${escapeXml(userPrompt)}
</user_instructions>
<authorized_urls>
+21
View File
@@ -121,6 +121,27 @@ export function truncateString(
* @param replacements A record of keys to their replacement values.
* @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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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(
template: string,
replacements: Record<string, string>,