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:
committed by
GitHub
parent
c891258f34
commit
e1120d38b6
@@ -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],
|
||||
|
||||
+38
@@ -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') {
|
||||
|
||||
+8
-5
@@ -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 ?? '',
|
||||
|
||||
Reference in New Issue
Block a user