Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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';
171 changes: 171 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,171 @@
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/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/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(['chat_postMessage_by_roomId', 'chat_postMessage_by_channel', '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[2]?.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.
Resolved
'post_chat_postMessage_by_roomId',
'post_chat_postMessage_by_channel',
'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('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('reuses the generated catalogs between requests', () => {
expect(getCuratedTools()).toBe(getCuratedTools());
expect(getExtendedTools()).toBe(getExtendedTools());
});
});
Loading
Loading