test(server): strengthen MCP catalog and gating coverage (#23630)

The three MCP testing improvements discussed in #23613 (now merged; this
branch has been rebased onto main).

## What

**1. Name-set assertions in `mcp-protocol.service.spec.ts`.** The two
"exactly 6 tools" tests only used `expect.objectContaining`
subset matches, so the size claim in the titles was never enforced and a
new meta-tool would pass silently. They now assert the exact sorted key
set of the ToolSet handed to the executor. This immediately caught real
drift: the toolset has seven tools, and `get_tool_catalog` was missing
from the expected list.

**2. Catalog contract integration test**
(`test/integration/ai/suites/mcp-tool-catalog.integration-spec.ts`).
Calls `get_tool_catalog` over real HTTP with an API-key bearer, then for
every advertised category dispatches one read-only tool
(`find_/list_/get_/search_` prefixed, up to 3 candidates) through
`execute_tool` and asserts a success envelope. Any newly registered
provider is covered the moment it appears in the catalog, with no new
test code. Categories with no read-only tool are compared exactly
against a deliberate exception list, currently empty since every
advertised category ships a read-only tool, so drift in either direction
fails loudly.

**3. Permission gating integration test** (same suite). Creates two API
keys: one bound to Admin, one bound to a freshly created role with
`canUpdateAllSettings: false` and no settings flags. Asserts the ROLE
category (from #23613) is present in the admin catalog and absent from
the restricted one, while the restricted key still sees DATABASE_CRUD
read tools, proving it is gating rather than a broken catalog. This
locks the provider `isAvailable` contract at the real HTTP boundary,
which the unit mocks cannot.

## Testing

- `npx jest src/engine/api/mcp` — 43 tests pass
- `test/integration/ai/suites` — 4 suites, 24 tests pass locally against
a reset DB
- `npx nx typecheck twenty-server` clean; oxlint and oxfmt clean on the
touched files


---
_Generated by [Claude
Code](https://claude.ai/code/session_0131sLKVsRuaDoaKCxFM8g4Z)_


<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23630?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
This commit is contained in:
Félix Malfait
2026-07-31 14:10:30 +02:00
committed by GitHub
parent f74f71785e
commit fec266a5ae
2 changed files with 267 additions and 2 deletions
@@ -18,6 +18,7 @@ import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annot
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { EXECUTE_TOOL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
import { GET_TOOL_CATALOG_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool';
import { LEARN_TOOLS_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
import { LOAD_SKILL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/load-skill.tool';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
@@ -46,6 +47,7 @@ describe('McpProtocolService', () => {
const EXPECTED_MCP_TOOL_NAMES = [
LEARN_TOOLS_TOOL_NAME,
EXECUTE_TOOL_TOOL_NAME,
GET_TOOL_CATALOG_TOOL_NAME,
LOAD_SKILL_TOOL_NAME,
LIST_OBJECT_METADATA_NAMES_TOOL_NAME,
LIST_SKILLS_TOOL_NAME,
@@ -58,6 +60,7 @@ describe('McpProtocolService', () => {
> = {
[LEARN_TOOLS_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
[EXECUTE_TOOL_TOOL_NAME]: MCP_EXECUTE_TOOL_ANNOTATIONS,
[GET_TOOL_CATALOG_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
[LOAD_SKILL_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
[LIST_OBJECT_METADATA_NAMES_TOOL_NAME]:
MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
@@ -265,7 +268,7 @@ describe('McpProtocolService', () => {
expect(result).toBeNull();
});
it('should build a ToolSet with exactly 6 tools and pass it to executor for tools/call', async () => {
it('should build the meta-tool set by name and pass it to executor for tools/call', async () => {
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
const mockToolCallResponse = {
@@ -314,9 +317,15 @@ describe('McpProtocolService', () => {
mockRequest.params,
undefined,
);
const [, toolSet] = mcpToolExecutorService.handleToolCall.mock.calls[0];
expect(Object.keys(toolSet).sort()).toEqual(
[...EXPECTED_MCP_TOOL_NAMES].sort(),
);
});
it('should build a ToolSet with exactly 6 tools and pass it to executor for tools/list', async () => {
it('should build the meta-tool set by name and pass it to executor for tools/list', async () => {
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
mcpToolExecutorService.handleToolsListing.mockReturnValue({
@@ -351,6 +360,13 @@ describe('McpProtocolService', () => {
),
),
);
const [, toolSet] =
mcpToolExecutorService.handleToolsListing.mock.calls[0];
expect(Object.keys(toolSet).sort()).toEqual(
[...EXPECTED_MCP_TOOL_NAMES].sort(),
);
});
it('should pass actorContext with FieldActorSource.AGENT to getToolsByName', async () => {