Skip to content

Commit 18f5eb4

Browse files
rodrigokclaudeDnouv
authored
feat(api): native MCP (Model Context Protocol) server (#41082)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Dnouv <evan.shu.dev@gmail.com>
1 parent 8e44b5c commit 18f5eb4

28 files changed

Lines changed: 2401 additions & 9 deletions

File tree

.changeset/native-mcp-server.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@rocket.chat/i18n': minor
3+
'@rocket.chat/meteor': minor
4+
'@rocket.chat/rest-typings': minor
5+
---
6+
7+
Adds an AI add-on-gated native Model Context Protocol endpoint and its administration controls in AI Center

apps/meteor/client/views/admin/aiCenter/AICenterOverview.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ const AICenterOverview = (): ReactElement => {
1414
const router = useRouter();
1515
const { data: hasAILicense, isPending } = useHasLicenseModule(AI_LICENSE_MODULE);
1616
const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false);
17+
const mcpEnabled = useSetting('MCP_Enabled', false);
1718
const searchSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'search' } });
1819
const llmSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'llm-providers' } });
20+
const mcpSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'mcp' } });
1921
const subscriptionHref = router.buildRoutePath({ name: 'subscription' });
2022

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

2527
let aiSearchStatus: ReactNode;
2628
let llmProviderStatus: ReactNode;
29+
let mcpStatus: ReactNode;
2730
if (hasAILicense === false) {
2831
aiSearchStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
2932
llmProviderStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
33+
mcpStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
3034
} else if (hasAILicense) {
3135
aiSearchStatus = intelligentSearchEnabled ? <Tag variant='primary'>{t('Enabled')}</Tag> : <Tag>{t('Disabled')}</Tag>;
3236
llmProviderStatus = <Tag>{t('Available')}</Tag>;
37+
mcpStatus = mcpEnabled ? <Tag variant='primary'>{t('Enabled')}</Tag> : <Tag>{t('Disabled')}</Tag>;
3338
}
3439

3540
return (
@@ -65,6 +70,14 @@ const AICenterOverview = (): ReactElement => {
6570
actionLabel={t('Manage')}
6671
href={llmSettingsHref}
6772
/>
73+
<AICenterCapabilityCard
74+
icon='link'
75+
title={t('MCP')}
76+
description={t('AI_Center_MCP_card_description')}
77+
status={mcpStatus}
78+
actionLabel={t('Configure')}
79+
href={mcpSettingsHref}
80+
/>
6881
</CardGrid>
6982
</Box>
7083
</PageScrollableContentWithShadow>

apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const AICenterRoute = (): ReactElement => {
2121
return <AISettingsSection section='AI_LLM_Provider' />;
2222
}
2323

24+
if (section === 'mcp') {
25+
return <AISettingsSection section='MCP' />;
26+
}
27+
2428
return <AICenterOverview />;
2529
};
2630

apps/meteor/client/views/admin/aiCenter/AISettingsSection.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ import type { ReactElement } from 'react';
44
import EditableSettingsProvider from '../settings/EditableSettingsProvider';
55
import GenericGroupPage from '../settings/groups/GenericGroupPage';
66

7-
export type AISettingsSectionName = 'Intelligent_Search' | 'AI_LLM_Provider';
7+
export type AISettingsSectionName = 'Intelligent_Search' | 'AI_LLM_Provider' | 'MCP';
88

99
export type AISettingsSectionProps = {
1010
section: AISettingsSectionName;
1111
};
1212

13+
const sectionTitles: Record<AISettingsSectionName, string> = {
14+
Intelligent_Search: 'Intelligent_Search',
15+
AI_LLM_Provider: 'AI_Center_LLM_Providers',
16+
MCP: 'MCP',
17+
};
18+
1319
const AISettingsSection = ({ section }: AISettingsSectionProps): ReactElement => {
1420
const router = useRouter();
15-
const title = section === 'Intelligent_Search' ? 'Intelligent_Search' : 'AI_Center_LLM_Providers';
21+
const title = sectionTitles[section];
1622

1723
return (
1824
<EditableSettingsProvider>

apps/meteor/ee/server/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ import '../apps/communication/uikit';
88
import './engagementDashboard';
99
import './audit';
1010
import './abac';
11+
import './mcp';
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { getCuratedTools, getExtendedTools } from './catalog';
2+
3+
jest.mock('../../../../server/api', () => ({
4+
API: {
5+
api: {
6+
typedRoutes: {
7+
'/api/v1/chat.postMessage': {
8+
post: {
9+
tags: ['Chat'],
10+
requestBody: {
11+
content: {
12+
'application/json': {
13+
schema: {
14+
oneOf: [
15+
{
16+
type: 'object',
17+
description: 'Post by room id',
18+
properties: { roomId: { type: 'string' }, text: { type: 'string', nullable: true } },
19+
required: ['roomId'],
20+
},
21+
{
22+
type: 'object',
23+
description: 'Post by channel',
24+
properties: { channel: { type: 'string' }, text: { type: 'string', nullable: true } },
25+
required: ['channel'],
26+
},
27+
],
28+
},
29+
},
30+
},
31+
},
32+
},
33+
},
34+
'/api/v1/rooms.get': {
35+
get: {
36+
tags: ['Rooms'],
37+
parameters: [
38+
{
39+
schema: {
40+
type: 'object',
41+
properties: { updatedSince: { type: 'string' } },
42+
additionalProperties: { type: 'string', nullable: true },
43+
},
44+
},
45+
],
46+
},
47+
},
48+
'/api/v1/channels.create': {
49+
post: {
50+
tags: ['Missing Documentation'],
51+
requestBody: {
52+
content: {
53+
'application/json': {
54+
schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
55+
},
56+
},
57+
},
58+
},
59+
},
60+
'/api/v1/channels.list.joined': {
61+
get: {
62+
tags: ['Missing Documentation'],
63+
parameters: [{ schema: { type: 'object', properties: { count: { type: 'number' } } } }],
64+
},
65+
},
66+
'/api/v1/rooms.isMember': {
67+
get: {
68+
tags: ['Rooms'],
69+
parameters: [
70+
{
71+
schema: {
72+
type: 'object',
73+
properties: {
74+
roomId: { type: 'string' },
75+
userId: { type: 'string' },
76+
username: { type: 'string' },
77+
},
78+
oneOf: [
79+
{ type: 'object', required: ['roomId', 'userId'] },
80+
{ type: 'object', required: ['roomId', 'username'] },
81+
],
82+
additionalProperties: false,
83+
},
84+
},
85+
],
86+
},
87+
},
88+
'/api/v1/teams.listRoomsOfUser': {
89+
get: {
90+
tags: ['Teams'],
91+
parameters: [
92+
{
93+
schema: {
94+
type: 'object',
95+
properties: {
96+
teamId: { type: 'string' },
97+
teamName: { type: 'string' },
98+
userId: { type: 'string' },
99+
},
100+
oneOf: [
101+
{ type: 'object', required: ['teamId'] },
102+
{ type: 'object', required: ['teamName'] },
103+
],
104+
required: ['userId'],
105+
additionalProperties: false,
106+
},
107+
},
108+
],
109+
},
110+
},
111+
'/api/v1/dm.files': {
112+
get: {
113+
tags: ['DM'],
114+
parameters: [
115+
{
116+
schema: {
117+
oneOf: [
118+
{
119+
type: 'object',
120+
properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha: { type: 'string' } },
121+
required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha'],
122+
},
123+
{
124+
type: 'object',
125+
properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta: { type: 'string' } },
126+
required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta'],
127+
},
128+
],
129+
},
130+
},
131+
],
132+
},
133+
},
134+
'/api/v1/users.delete': {
135+
post: {
136+
tags: ['Users'],
137+
requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
138+
},
139+
},
140+
'/api/v1/users.register': {
141+
post: {
142+
tags: ['Users'],
143+
requestBody: {
144+
content: {
145+
'application/json': {
146+
schema: { type: 'object', properties: { username: { type: 'string' } }, required: ['username'] },
147+
},
148+
},
149+
},
150+
},
151+
},
152+
},
153+
},
154+
},
155+
}));
156+
157+
describe('MCP tool catalog', () => {
158+
it('creates one curated tool per discriminated request variant', () => {
159+
const tools = getCuratedTools();
160+
161+
expect(tools.map(({ name }) => name)).toEqual([
162+
'post_chat_postMessage_by_roomId',
163+
'post_chat_postMessage_by_channel',
164+
'post_channels_create',
165+
'get_channels_list_joined',
166+
'get_rooms_get',
167+
]);
168+
expect(tools[0]?.inputSchema).toEqual({
169+
type: 'object',
170+
description: 'Post by room id',
171+
properties: { roomId: { type: 'string' }, text: { type: 'string' } },
172+
required: ['roomId'],
173+
});
174+
expect(tools[4]?.inputSchema).toMatchObject({ additionalProperties: { type: 'string' } });
175+
});
176+
177+
it('only exposes allow-listed routes in the extended catalog', () => {
178+
const tools = getExtendedTools();
179+
180+
expect(tools.map(({ name }) => name)).toEqual(
181+
expect.arrayContaining([
182+
'post_chat_postMessage_by_roomId',
183+
'post_chat_postMessage_by_channel',
184+
'post_channels_create',
185+
'get_channels_list_joined',
186+
'get_rooms_get',
187+
'get_rooms_isMember_by_roomId_userId',
188+
'get_rooms_isMember_by_roomId_username',
189+
]),
190+
);
191+
expect(tools.some(({ name }) => name.includes('users_delete'))).toBe(false);
192+
expect(tools.some(({ name }) => name.includes('users_register'))).toBe(false);
193+
});
194+
195+
it('keeps curated tool names stable when the extended catalog is enabled', () => {
196+
const extendedNames = new Set(getExtendedTools().map(({ name }) => name));
197+
198+
expect(getCuratedTools().every(({ name }) => extendedNames.has(name))).toBe(true);
199+
});
200+
201+
it('generates unique valid names when variant discriminators exceed the MCP limit', () => {
202+
const tools = getExtendedTools();
203+
const names = tools.map(({ name }) => name);
204+
const dmFileNames = tools.filter(({ path }) => path === '/api/v1/dm.files').map(({ name }) => name);
205+
206+
expect(dmFileNames).toHaveLength(2);
207+
expect(new Set(names).size).toBe(names.length);
208+
expect(names.every((name) => name.length <= 64 && /^[a-zA-Z0-9_-]+$/.test(name))).toBe(true);
209+
});
210+
211+
it('preserves parent properties when variants only declare required fields', () => {
212+
const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_rooms_isMember'));
213+
214+
for (const { inputSchema } of tools) {
215+
const properties = inputSchema.properties as Record<string, unknown>;
216+
for (const requiredProperty of inputSchema.required as string[]) {
217+
expect(properties).toHaveProperty(requiredProperty);
218+
}
219+
}
220+
});
221+
222+
it('preserves shared required fields without changing variant discriminators', () => {
223+
const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_teams_listRoomsOfUser'));
224+
225+
expect(tools.map(({ name }) => name)).toEqual(['get_teams_listRoomsOfUser_by_teamId', 'get_teams_listRoomsOfUser_by_teamName']);
226+
for (const { inputSchema } of tools) {
227+
expect(inputSchema.required).toEqual(expect.arrayContaining(['userId']));
228+
}
229+
});
230+
231+
it('reuses the generated catalogs between requests', () => {
232+
expect(getCuratedTools()).toBe(getCuratedTools());
233+
expect(getExtendedTools()).toBe(getExtendedTools());
234+
});
235+
});

0 commit comments

Comments
 (0)