Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-ends-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Adds per-client rate limiting to the unauthenticated sendForgotPasswordEmail method, matching the REST users.forgotPassword endpoint
7 changes: 7 additions & 0 deletions .changeset/native-mcp-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
'@rocket.chat/rest-typings': minor
---

Adds an AI add-on-gated native Model Context Protocol endpoint and its administration controls in AI Center
5 changes: 5 additions & 0 deletions .changeset/shy-actors-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes special characters not being escaped in the visitor name shown in the Omnichannel queue side panel's message preview
13 changes: 13 additions & 0 deletions apps/meteor/client/views/admin/aiCenter/AICenterOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ const AICenterOverview = (): ReactElement => {
const router = useRouter();
const { data: hasAILicense, isPending } = useHasLicenseModule(AI_LICENSE_MODULE);
const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false);
const mcpEnabled = useSetting('MCP_Enabled', false);
const searchSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'search' } });
const llmSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'llm-providers' } });
const mcpSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'mcp' } });
const subscriptionHref = router.buildRoutePath({ name: 'subscription' });

if (isPending) {
Expand All @@ -24,12 +26,15 @@ const AICenterOverview = (): ReactElement => {

let aiSearchStatus: ReactNode;
let llmProviderStatus: ReactNode;
let mcpStatus: ReactNode;
if (hasAILicense === false) {
aiSearchStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
llmProviderStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
mcpStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
} else if (hasAILicense) {
aiSearchStatus = intelligentSearchEnabled ? <Tag variant='primary'>{t('Enabled')}</Tag> : <Tag>{t('Disabled')}</Tag>;
llmProviderStatus = <Tag>{t('Available')}</Tag>;
mcpStatus = mcpEnabled ? <Tag variant='primary'>{t('Enabled')}</Tag> : <Tag>{t('Disabled')}</Tag>;
}

return (
Expand Down Expand Up @@ -65,6 +70,14 @@ const AICenterOverview = (): ReactElement => {
actionLabel={t('Manage')}
href={llmSettingsHref}
/>
<AICenterCapabilityCard
icon='link'
title={t('MCP')}
description={t('AI_Center_MCP_card_description')}
status={mcpStatus}
actionLabel={t('Configure')}
href={mcpSettingsHref}
/>
</CardGrid>
</Box>
</PageScrollableContentWithShadow>
Expand Down
4 changes: 4 additions & 0 deletions apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const AICenterRoute = (): ReactElement => {
return <AISettingsSection section='AI_LLM_Provider' />;
}

if (section === 'mcp') {
return <AISettingsSection section='MCP' />;
}

return <AICenterOverview />;
};

Expand Down
10 changes: 8 additions & 2 deletions apps/meteor/client/views/admin/aiCenter/AISettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ import type { ReactElement } from 'react';
import EditableSettingsProvider from '../settings/EditableSettingsProvider';
import GenericGroupPage from '../settings/groups/GenericGroupPage';

export type AISettingsSectionName = 'Intelligent_Search' | 'AI_LLM_Provider';
export type AISettingsSectionName = 'Intelligent_Search' | 'AI_LLM_Provider' | 'MCP';

export type AISettingsSectionProps = {
section: AISettingsSectionName;
};

const sectionTitles: Record<AISettingsSectionName, string> = {
Intelligent_Search: 'Intelligent_Search',
AI_LLM_Provider: 'AI_Center_LLM_Providers',
MCP: 'MCP',
};

const AISettingsSection = ({ section }: AISettingsSectionProps): ReactElement => {
const router = useRouter();
const title = section === 'Intelligent_Search' ? 'Intelligent_Search' : 'AI_Center_LLM_Providers';
const title = sectionTitles[section];

return (
<EditableSettingsProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { SidebarV2ItemIcon as SidebarItemIcon } from '@rocket.chat/fuselage';
import { escapeHTML } from '@rocket.chat/string-helpers';

Check failure on line 3 in apps/meteor/client/views/navigation/sidepanel/omnichannel/InquireSidePanelItem.tsx

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Unable to resolve path to module '@rocket.chat/string-helpers'

Check failure on line 3 in apps/meteor/client/views/navigation/sidepanel/omnichannel/InquireSidePanelItem.tsx

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Unable to resolve path to module '@rocket.chat/string-helpers'
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useUserId } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
Expand Down Expand Up @@ -29,7 +30,8 @@

const time = 'lastMessage' in room ? room.lastMessage?.ts : undefined;
const message =
room.lastMessage && `${room.lastMessage.u.name || room.lastMessage.u.username}: ${normalizeMessagePreview(room.lastMessage, t)}`;
room.lastMessage &&
`${escapeHTML(room.lastMessage.u.name || room.lastMessage.u.username)}: ${normalizeMessagePreview(room.lastMessage, t)}`;
const title = roomCoordinator.getRoomName(room.t, room) || '';
const href = roomCoordinator.getRouteLink(room.t, room) || '';

Expand Down
1 change: 1 addition & 0 deletions apps/meteor/ee/server/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ import '../apps/communication/uikit';
import './engagementDashboard';
import './audit';
import './abac';
import './mcp';
235 changes: 235 additions & 0 deletions apps/meteor/ee/server/api/mcp/catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { getCuratedTools, getExtendedTools } from './catalog';

jest.mock('../../../../server/api', () => ({
API: {
api: {
typedRoutes: {
'/api/v1/chat.postMessage': {
post: {
tags: ['Chat'],
requestBody: {
content: {
'application/json': {
schema: {
oneOf: [
{
type: 'object',
description: 'Post by room id',
properties: { roomId: { type: 'string' }, text: { type: 'string', nullable: true } },
required: ['roomId'],
},
{
type: 'object',
description: 'Post by channel',
properties: { channel: { type: 'string' }, text: { type: 'string', nullable: true } },
required: ['channel'],
},
],
},
},
},
},
},
},
'/api/v1/rooms.get': {
get: {
tags: ['Rooms'],
parameters: [
{
schema: {
type: 'object',
properties: { updatedSince: { type: 'string' } },
additionalProperties: { type: 'string', nullable: true },
},
},
],
},
},
'/api/v1/channels.create': {
post: {
tags: ['Missing Documentation'],
requestBody: {
content: {
'application/json': {
schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
},
},
},
},
},
'/api/v1/channels.list.joined': {
get: {
tags: ['Missing Documentation'],
parameters: [{ schema: { type: 'object', properties: { count: { type: 'number' } } } }],
},
},
'/api/v1/rooms.isMember': {
get: {
tags: ['Rooms'],
parameters: [
{
schema: {
type: 'object',
properties: {
roomId: { type: 'string' },
userId: { type: 'string' },
username: { type: 'string' },
},
oneOf: [
{ type: 'object', required: ['roomId', 'userId'] },
{ type: 'object', required: ['roomId', 'username'] },
],
additionalProperties: false,
},
},
],
},
},
'/api/v1/teams.listRoomsOfUser': {
get: {
tags: ['Teams'],
parameters: [
{
schema: {
type: 'object',
properties: {
teamId: { type: 'string' },
teamName: { type: 'string' },
userId: { type: 'string' },
},
oneOf: [
{ type: 'object', required: ['teamId'] },
{ type: 'object', required: ['teamName'] },
],
required: ['userId'],
additionalProperties: false,
},
},
],
},
},
'/api/v1/dm.files': {
get: {
tags: ['DM'],
parameters: [
{
schema: {
oneOf: [
{
type: 'object',
properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha: { type: 'string' } },
required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha'],
},
{
type: 'object',
properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta: { type: 'string' } },
required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta'],
},
],
},
},
],
},
},
'/api/v1/users.delete': {
post: {
tags: ['Users'],
requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
},
},
'/api/v1/users.register': {
post: {
tags: ['Users'],
requestBody: {
content: {
'application/json': {
schema: { type: 'object', properties: { username: { type: 'string' } }, required: ['username'] },
},
},
},
},
},
},
},
},
}));

describe('MCP tool catalog', () => {
it('creates one curated tool per discriminated request variant', () => {
const tools = getCuratedTools();

expect(tools.map(({ name }) => name)).toEqual([
'post_chat_postMessage_by_roomId',
'post_chat_postMessage_by_channel',
'post_channels_create',
'get_channels_list_joined',
'get_rooms_get',
]);
expect(tools[0]?.inputSchema).toEqual({
type: 'object',
description: 'Post by room id',
properties: { roomId: { type: 'string' }, text: { type: 'string' } },
required: ['roomId'],
});
expect(tools[4]?.inputSchema).toMatchObject({ additionalProperties: { type: 'string' } });
});

it('only exposes allow-listed routes in the extended catalog', () => {
const tools = getExtendedTools();

expect(tools.map(({ name }) => name)).toEqual(
expect.arrayContaining([
'post_chat_postMessage_by_roomId',
'post_chat_postMessage_by_channel',
'post_channels_create',
'get_channels_list_joined',
'get_rooms_get',
'get_rooms_isMember_by_roomId_userId',
'get_rooms_isMember_by_roomId_username',
]),
);
expect(tools.some(({ name }) => name.includes('users_delete'))).toBe(false);
expect(tools.some(({ name }) => name.includes('users_register'))).toBe(false);
});

it('keeps curated tool names stable when the extended catalog is enabled', () => {
const extendedNames = new Set(getExtendedTools().map(({ name }) => name));

expect(getCuratedTools().every(({ name }) => extendedNames.has(name))).toBe(true);
});

it('generates unique valid names when variant discriminators exceed the MCP limit', () => {
const tools = getExtendedTools();
const names = tools.map(({ name }) => name);
const dmFileNames = tools.filter(({ path }) => path === '/api/v1/dm.files').map(({ name }) => name);

expect(dmFileNames).toHaveLength(2);
expect(new Set(names).size).toBe(names.length);
expect(names.every((name) => name.length <= 64 && /^[a-zA-Z0-9_-]+$/.test(name))).toBe(true);
});

it('preserves parent properties when variants only declare required fields', () => {
const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_rooms_isMember'));

for (const { inputSchema } of tools) {
const properties = inputSchema.properties as Record<string, unknown>;
for (const requiredProperty of inputSchema.required as string[]) {
expect(properties).toHaveProperty(requiredProperty);
}
}
});

it('preserves shared required fields without changing variant discriminators', () => {
const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_teams_listRoomsOfUser'));

expect(tools.map(({ name }) => name)).toEqual(['get_teams_listRoomsOfUser_by_teamId', 'get_teams_listRoomsOfUser_by_teamName']);
for (const { inputSchema } of tools) {
expect(inputSchema.required).toEqual(expect.arrayContaining(['userId']));
}
});

it('reuses the generated catalogs between requests', () => {
expect(getCuratedTools()).toBe(getCuratedTools());
expect(getExtendedTools()).toBe(getExtendedTools());
});
});
Loading
Loading