fix: stamp MCP and AI Agent writes with FieldActorSource.AGENT (#22215)

## Description
Fixes #21437 MCP and AI Agent writes now correctly stamped with

### Problem
Records created through MCP server were stamped as `WORKFLOW`, making
them indistinguishable from workflow-created records. This breaks
loop-protection filters that skip workflow-originated records.

### Solution
- MCP writes now correctly stamped with `createdBy.source = AGENT`
- AI Agent execution now uses `AGENT` instead of `MANUAL`
- Added `WorkspaceCacheModule` to MCP module
- Updated tests to verify AGENT source

### Files Changed
- `mcp.module.ts`: Added WorkspaceCacheModule import
- `mcp-protocol.service.ts`: Set AGENT source in buildMcpToolSet
- `mcp-protocol.service.spec.ts`: Updated tests
- `agent-actor-context.service.ts`: Changed MANUAL → AGENT
- 

## Type of Change
- [x] Bug fix (non-breaking change)

## Checklist
- [x] Code follows project style
- [x] Tests added/updated
- [x] Issue linked

Fixes #21437

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22215?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Amresh Chaurasiya
2026-06-26 21:08:05 +05:30
committed by GitHub
parent c891258f34
commit e1120d38b6
4 changed files with 106 additions and 6 deletions
@@ -17,6 +17,8 @@ import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
ApiKeyModule,
@@ -28,6 +30,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ToolProviderModule,
SkillModule,
TwentyConfigModule,
WorkspaceCacheModule,
],
controllers: [McpCoreController],
exports: [McpProtocolService],
@@ -1,5 +1,6 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { FieldActorSource } from 'twenty-shared/types';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const';
@@ -23,6 +24,7 @@ import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/servi
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
describe('McpProtocolService', () => {
@@ -122,6 +124,17 @@ describe('McpProtocolService', () => {
}),
},
},
{
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn().mockResolvedValue({
flatWorkspaceMemberMaps: {
idByUserId: {},
byId: {},
},
}),
},
},
],
}).compile();
@@ -340,6 +353,31 @@ describe('McpProtocolService', () => {
);
});
it('should pass actorContext with FieldActorSource.AGENT to getToolsByName', async () => {
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/list',
id: '123',
};
await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: undefined,
});
expect(_toolRegistryService.getToolsByName).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
actorContext: expect.objectContaining({
source: FieldActorSource.AGENT,
}),
}),
);
});
it('should return prompts list without role resolution', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
@@ -2,6 +2,7 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { type ToolSet, zodSchema } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const';
@@ -10,7 +11,7 @@ import { MCP_EXECUTE_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-e
import { MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpInstructionBuilderService } from 'src/engine/api/mcp/services/mcp-instruction-builder.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import {
@@ -54,6 +55,7 @@ import {
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
type McpAnnotatedTool = ToolSet[string] & {
@@ -93,6 +95,7 @@ export class McpProtocolService {
private readonly skillService: SkillService,
private readonly mcpInstructionBuilderService: McpInstructionBuilderService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async handleInitialize(requestId: string | number, workspaceId: string) {
@@ -144,6 +147,50 @@ export class McpProtocolService {
return roleId;
}
private async buildActorContext(
workspaceId: string,
userId?: string,
apiKey?: FlatApiKey,
): Promise<ActorMetadata> {
let actorContext: ActorMetadata = {
source: FieldActorSource.AGENT,
workspaceMemberId: null,
name: 'Agent',
context: {},
};
if (isDefined(apiKey)) {
actorContext = {
source: FieldActorSource.AGENT,
workspaceMemberId: null,
name: apiKey.name,
context: {},
};
} else if (isDefined(userId)) {
const { flatWorkspaceMemberMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatWorkspaceMemberMaps',
]);
const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[userId];
const workspaceMember = isDefined(workspaceMemberId)
? flatWorkspaceMemberMaps.byId[workspaceMemberId]
: undefined;
if (isDefined(workspaceMember)) {
actorContext = {
source: FieldActorSource.AGENT,
workspaceMemberId: workspaceMember.id,
name:
`${workspaceMember.name?.firstName ?? ''} ${workspaceMember.name?.lastName ?? ''}`.trim() ||
'Agent',
context: {},
};
}
}
return actorContext;
}
private async buildMcpToolSet(
workspace: FlatWorkspace,
roleId: string,
@@ -151,14 +198,22 @@ export class McpProtocolService {
authContext?: WorkspaceAuthContext;
userId?: string;
userWorkspaceId?: string;
apiKey?: FlatApiKey;
},
): Promise<ToolSet> {
const actorContext = await this.buildActorContext(
workspace.id,
options?.userId,
options?.apiKey,
);
const toolContext = {
workspaceId: workspace.id,
roleId,
authContext: options?.authContext,
userId: options?.userId,
userWorkspaceId: options?.userWorkspaceId,
actorContext,
};
const preloadedTools = await this.toolRegistry.getToolsByName(
@@ -289,6 +344,7 @@ export class McpProtocolService {
authContext,
userId,
userWorkspaceId,
apiKey,
});
if (method === 'tools/call') {
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type ActorMetadata } from 'twenty-shared/types';
import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types';
import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@@ -90,10 +90,13 @@ export class AgentActorContextService {
);
}
const actorContext = buildCreatedByFromFullNameMetadata({
fullNameMetadata: workspaceMember.name,
workspaceMemberId: workspaceMember.id,
});
const actorContext: ActorMetadata = {
...buildCreatedByFromFullNameMetadata({
fullNameMetadata: workspaceMember.name,
workspaceMemberId: workspaceMember.id,
}),
source: FieldActorSource.AGENT,
};
const userContext: UserContext = {
firstName: workspaceMember.name?.firstName ?? '',