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
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
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
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([
Comment thread
Dnouv marked this conversation as resolved.
'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);
Comment thread
Dnouv marked this conversation as resolved.
});

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