Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
100 changes: 100 additions & 0 deletions apps/api/src/app/agents/agent-chat/activity-to-events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,104 @@ describe('activity-to-events run lifecycle', () => {
)
).to.deep.equal(['approval-activity-1', 'approval-activity-2']);
});

it('derives trust action ids at emit time for managed MCP approvals', () => {
const envelopes = mapNewestFirstEventActivities(
[
activity({
type: ConversationActivityTypeEnum.TOOL_APPROVAL_REQUEST,
identifier: 'approval-activity-mcp',
sequence: 1,
toolData: {
approvalId: 'call_1',
toolCallId: 'call_1',
toolName: 'create_issue',
approveActionId: 'mcp-approval:approve:call_1',
denyActionId: 'mcp-approval:deny:call_1',
mcpServerName: 'GitHub',
},
}),
],
context
);

expect(envelopes[0]?.event).to.deep.equal({
type: 'tool-approval-request',
messageId: 'approval-activity-mcp',
approvalId: 'call_1',
toolUseId: 'call_1',
toolName: 'create_issue',
input: undefined,
approveActionId: 'mcp-approval:approve:call_1',
denyActionId: 'mcp-approval:deny:call_1',
trustToolActionId: 'mcp-approval:approve-tool:call_1:create_issue:GitHub',
trustServerActionId: 'mcp-approval:approve-server:call_1:create_issue:GitHub',
source: { type: 'mcp', serverName: 'GitHub' },
});
});

it('derives trust action ids at emit time for managed direct approvals', () => {
const envelopes = mapNewestFirstEventActivities(
[
activity({
type: ConversationActivityTypeEnum.TOOL_APPROVAL_REQUEST,
identifier: 'approval-activity-direct',
sequence: 1,
toolData: {
approvalId: 'call_2',
toolCallId: 'call_2',
toolName: 'deleteOrder',
approveActionId: 'direct-approval:approve:call_2',
denyActionId: 'direct-approval:deny:call_2',
},
}),
],
context
);

expect(envelopes[0]?.event).to.deep.equal({
type: 'tool-approval-request',
messageId: 'approval-activity-direct',
approvalId: 'call_2',
toolUseId: 'call_2',
toolName: 'deleteOrder',
input: undefined,
approveActionId: 'direct-approval:approve:call_2',
denyActionId: 'direct-approval:deny:call_2',
trustToolActionId: 'direct-approval:approve-tool:call_2:deleteOrder',
source: undefined,
});
});

it('omits trust action ids for self-hosted approvals', () => {
const envelopes = mapNewestFirstEventActivities(
[
activity({
type: ConversationActivityTypeEnum.TOOL_APPROVAL_REQUEST,
identifier: 'approval-activity-self-hosted',
sequence: 1,
toolData: {
approvalId: 'apr_1',
toolCallId: 'tool-use-1',
toolName: 'runCommand',
approveActionId: 'tool-approval:approve:apr_1',
denyActionId: 'tool-approval:deny:apr_1',
},
}),
],
context
);

expect(envelopes[0]?.event).to.deep.equal({
type: 'tool-approval-request',
messageId: 'approval-activity-self-hosted',
approvalId: 'apr_1',
toolUseId: 'tool-use-1',
toolName: 'runCommand',
input: undefined,
approveActionId: 'tool-approval:approve:apr_1',
denyActionId: 'tool-approval:deny:apr_1',
source: undefined,
});
});
});
39 changes: 37 additions & 2 deletions apps/api/src/app/agents/agent-chat/activity-to-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
mapRunLifecycleActivityToEvent,
runIdFromLifecycleIdentifier,
} from '../conversation-runtime/conversation/run-lifecycle-activity';
import { mintApprovalActionIds } from '../shared/tool-approval/mint-approval-action-ids';
import { DIRECT_TOOL_APPROVAL_ACTION_PREFIX, MCP_TOOL_APPROVAL_ACTION_PREFIX } from '../shared/tool-approval/action-id';
import { mintApprovalActionIds, mintManagedApprovalActionIds } from '../shared/tool-approval/mint-approval-action-ids';

type McpConnectionActivityData = {
actionId?: string;
Expand All @@ -40,6 +41,35 @@ function isCardTree(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && (value as { type?: unknown }).type === 'card';
}

function isManagedToolApprovalRequest(toolData: ConversationActivityEntity['toolData']): boolean {
const approveActionId = toolData?.approveActionId;
if (!approveActionId) {
return false;
}

return (
approveActionId.startsWith(`${MCP_TOOL_APPROVAL_ACTION_PREFIX}:`) ||
approveActionId.startsWith(`${DIRECT_TOOL_APPROVAL_ACTION_PREFIX}:`)
);
}

function mintTrustActionIdsFromStoredToolData(toolData: NonNullable<ConversationActivityEntity['toolData']>) {
if (!isManagedToolApprovalRequest(toolData) || !toolData.toolCallId || !toolData.toolName) {
return {};
}

const managed = mintManagedApprovalActionIds({
toolUseId: toolData.toolCallId,
toolName: toolData.toolName,
mcpServerName: toolData.mcpServerName,
});

return {
trustToolActionId: managed.trustToolActionId,
...(managed.trustServerActionId ? { trustServerActionId: managed.trustServerActionId } : {}),
};
}

/** Prefer the stored Card tree. Fall back to markdown when no Card is present. */
export function messageContentFromStored(params: {
content?: string;
Expand Down Expand Up @@ -84,7 +114,10 @@ function mapActivityToEvent(activity: ConversationActivityEntity): AgentEvent |

const actionIds =
toolData.approveActionId && toolData.denyActionId
? { approveActionId: toolData.approveActionId, denyActionId: toolData.denyActionId }
? {
approveActionId: toolData.approveActionId,
denyActionId: toolData.denyActionId,
}
: mintApprovalActionIds({ approvalId: toolData.approvalId });

return {
Expand All @@ -96,6 +129,8 @@ function mapActivityToEvent(activity: ConversationActivityEntity): AgentEvent |
input: toolData.input,
approveActionId: actionIds.approveActionId,
denyActionId: actionIds.denyActionId,
...mintTrustActionIdsFromStoredToolData(toolData),
source: toolData.mcpServerName ? { type: 'mcp', serverName: toolData.mcpServerName } : undefined,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface PersistToolApprovalRequestParams extends ConversationActivityCo
/** When omitted, self-hosted `tool-approval:*` ids are minted. */
approveActionId?: string;
denyActionId?: string;
mcpServerName?: string;
}

export type MetadataOp =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export class ConversationActivityLedger {
input: params.input,
approveActionId: actionIds.approveActionId,
denyActionId: actionIds.denyActionId,
mcpServerName: params.mcpServerName,
},
sequence,
environmentId: params.environmentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ export class HandleAgentReply {
input: request.input,
approveActionId: request.approveActionId,
denyActionId: request.denyActionId,
mcpServerName: request.mcpServerName,
environmentId: command.environmentId,
organizationId: command.organizationId,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { HandlePlanProgressCommand } from '../../conversation-runtime/reply/hand
import { HandlePlanProgress } from '../../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase';
import { AgentPlatformEnum, usesProtocolEventApprovals } from '../../shared/enums/agent-platform.enum';
import { captureAgentException, captureAgentWarning } from '../../shared/errors/capture-agent-sentry';
import { managedApprovalGrammar, mintApprovalActionIds } from '../../shared/tool-approval/mint-approval-action-ids';
import { mintManagedApprovalActionIds } from '../../shared/tool-approval/mint-approval-action-ids';
import { ManagedAgentService } from '../managed-agent.service';
import { ManagedAgentProviderFactory } from '../managed-agent-provider-factory.service';
import { HandleNovuResolveCommand } from '../novu-resolve/handle-novu-resolve.command';
Expand Down Expand Up @@ -412,9 +412,10 @@ export class HandlePendingToolApprovals {
platform: command.platform,
tool,
});
const actionIds = mintApprovalActionIds({
approvalId: tool.toolUseId,
grammar: managedApprovalGrammar(tool.mcpServerName),
const actionIds = mintManagedApprovalActionIds({
toolUseId: tool.toolUseId,
toolName: tool.toolName,
mcpServerName: tool.mcpServerName,
});

try {
Expand All @@ -434,6 +435,7 @@ export class HandlePendingToolApprovals {
input: tool.input,
approveActionId: actionIds.approveActionId,
denyActionId: actionIds.denyActionId,
mcpServerName: tool.mcpServerName,
},
})
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { SlackNativeDelivery } from '../../conversation-runtime/egress/slac
import type { ReplyContentDto } from '../../shared/dtos/agent-reply-payload.dto';
import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum';
import {
buildDirectToolApprovalPersistActionId,
buildMcpToolApprovalPersistActionId,
buildToolApprovalActionId,
DIRECT_TOOL_APPROVAL_ACTION_PREFIX,
MCP_TOOL_APPROVAL_ACTION_PREFIX,
Expand Down Expand Up @@ -56,26 +58,6 @@ function formatToolArgumentsBody(tool: PendingToolApproval): string | undefined
return truncatedBody.length <= SLACK_CARD_BODY_MAX ? truncatedBody : truncatedBody.slice(0, SLACK_CARD_BODY_MAX);
}

// ---------------------------------------------------------------------------
// Persist ("always allow") action ids — kept in sync with parseToolApprovalActionId
// ---------------------------------------------------------------------------

function buildMcpToolApprovalPersistActionId(
verdict: 'approve-tool' | 'approve-server',
tool: PendingToolApproval
): string {
const toolName = encodeURIComponent(tool.toolName);
const mcpServerName = encodeURIComponent(tool.mcpServerName ?? '');

return `${MCP_TOOL_APPROVAL_ACTION_PREFIX}:${verdict}:${tool.toolUseId}:${toolName}:${mcpServerName}`;
}

function buildDirectToolApprovalPersistActionId(tool: PendingToolApproval): string {
const toolName = encodeURIComponent(tool.toolName);

return `${DIRECT_TOOL_APPROVAL_ACTION_PREFIX}:approve-tool:${tool.toolUseId}:${toolName}`;
}

// ---------------------------------------------------------------------------
// Thalamus adapter
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,14 @@ export class ToolApprovalRequestPayloadDto {
@IsOptional()
@IsString()
denyActionId?: string;

@ApiPropertyOptional({
description: 'MCP server name when the gated tool is from an MCP server (for UI labels).',
example: 'GitHub',
})
@IsOptional()
@IsString()
mcpServerName?: string;
}

@ApiExtraModels(MarkdownReplyContentDto, CardReplyContentDto, ToolApprovalCardReplyContentDto, FileRefDto)
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/app/agents/shared/tool-approval/action-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,30 @@ export function buildToolApprovalActionId(
return `${prefix}:${verdict}:${toolUseId}`;
}

export type ToolApprovalPersistTarget = {
toolUseId: string;
toolName: string;
mcpServerName?: string;
};

/** Persist-verdict ids for MCP tools — keep in sync with {@link parseToolApprovalActionId}. */
export function buildMcpToolApprovalPersistActionId(
verdict: 'approve-tool' | 'approve-server',
tool: ToolApprovalPersistTarget
): string {
const toolName = encodeURIComponent(tool.toolName);
const mcpServerName = encodeURIComponent(tool.mcpServerName ?? '');

return `${MCP_TOOL_APPROVAL_ACTION_PREFIX}:${verdict}:${tool.toolUseId}:${toolName}:${mcpServerName}`;
}

/** Persist-verdict id for direct (non-MCP) managed tools. */
export function buildDirectToolApprovalPersistActionId(tool: ToolApprovalPersistTarget): string {
const toolName = encodeURIComponent(tool.toolName);

return `${DIRECT_TOOL_APPROVAL_ACTION_PREFIX}:approve-tool:${tool.toolUseId}:${toolName}`;
}

const TOOL_APPROVAL_VERDICTS = ['approve', 'deny', 'approve-tool', 'approve-server'] as const;
type ToolApprovalVerdict = (typeof TOOL_APPROVAL_VERDICTS)[number];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { buildApprovalActionId } from '@novu/framework/internal';
import {
buildDirectToolApprovalPersistActionId,
buildMcpToolApprovalPersistActionId,
buildToolApprovalActionId,
DIRECT_TOOL_APPROVAL_ACTION_PREFIX,
MCP_TOOL_APPROVAL_ACTION_PREFIX,
type ToolApprovalActionPrefix,
type ToolApprovalPersistTarget,
} from './action-id';

export type ApprovalActionIdGrammar = { kind: 'self-hosted' } | { kind: 'managed'; prefix: ToolApprovalActionPrefix };
Expand All @@ -13,6 +16,11 @@ export type MintedApprovalActionIds = {
denyActionId: string;
};

export type MintedManagedApprovalActionIds = MintedApprovalActionIds & {
trustToolActionId: string;
trustServerActionId?: string;
};

/**
* Mint the same action-id grammar Slack/Teams cards already put on buttons.
* Self-hosted: `tool-approval:{approve|deny}:{approvalId}`
Expand Down Expand Up @@ -43,3 +51,23 @@ export function managedApprovalGrammar(mcpServerName: string | undefined): Appro
prefix: mcpServerName !== undefined ? MCP_TOOL_APPROVAL_ACTION_PREFIX : DIRECT_TOOL_APPROVAL_ACTION_PREFIX,
};
}

/**
* Mint all four managed approval action ids (approve once, deny, always-allow tool, always-allow server).
* Same ids Slack/Teams cards put on buttons — Agent Chat protocol reuses them.
*/
export function mintManagedApprovalActionIds(tool: ToolApprovalPersistTarget): MintedManagedApprovalActionIds {
const base = mintApprovalActionIds({
approvalId: tool.toolUseId,
grammar: managedApprovalGrammar(tool.mcpServerName),
});
const isMcp = tool.mcpServerName !== undefined;

return {
...base,
trustToolActionId: isMcp
? buildMcpToolApprovalPersistActionId('approve-tool', tool)
: buildDirectToolApprovalPersistActionId(tool),
...(isMcp ? { trustServerActionId: buildMcpToolApprovalPersistActionId('approve-server', tool) } : {}),
};
}
Loading
Loading