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
2 changes: 2 additions & 0 deletions .cursor/skills/nv-onboard-dcr-mcp/blocked-mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Copy the template below to the **Open blockers** table (newest first). Fill ever

| ID | Name | MCP URL | Blocked | Category | Reason | Docs | Next step |
|----|------|---------|---------|----------|--------|------|-----------|
| `new-relic` | New Relic | `https://mcp.newrelic.com/mcp/` | 2026-08-30 | `redirect-whitelist` | Was wrongly catalogued `dcr` because AS metadata advertises `registration_endpoint` `https://mcp.newrelic.com/register`. In practice OAuth is a single pre-registered public client (`client_id: pUWGgnjsQ0bydqCbavTPpw==`, `client_authentication_required: false`) whose redirect allowlist covers only loopback/CLI hosts, and `authorization_endpoint` is the `https://login.newrelic.com/login` page rather than a spec authorize endpoint — after successful login the browser stays on New Relic's success page and the hosted Novu callback never receives the code. Docs list only CLI/desktop clients (Claude Code/Desktop, Gemini CLI, Kiro CLI, Windsurf, VS Code); SSO-enforced orgs fail too. PRM is valid (`resource: https://mcp.newrelic.com/mcp/`, S256, `pkce_required: true`) | [NR MCP overview](https://docs.newrelic.com/docs/agentic-ai/mcp/overview/), [Troubleshoot NR MCP](https://docs.newrelic.com/docs/agentic-ai/mcp/troubleshoot/), [OAuth never redirects to web clients #7](https://github.com/newrelic/mcp-server/issues/7), [OAuth/SSO auth failure #4](https://github.com/newrelic/mcp-server/issues/4) | Keep `provider-managed`; ask New Relic to allowlist `https://api.novu.co/v1/agents/mcp/oauth/callback` on the static client or ship real per-client DCR. API-key auth (`api-key: NRAK-…`) is their documented workaround but needs the unwired `user-app` mode |
| `canva` | Canva | `https://mcp.canva.com/mcp` | 2026-08-30 | `client-allowlist` | Was wrongly catalogued `dcr` because `https://mcp.canva.com/register` exists. Official docs require applying to a Waitlist form to get a redirect URI onto Canva's allowlist, and explicitly deprecate DCR in favour of CIMD (`client_id_metadata_document_supported: true` on AS metadata) — so a DCR client Novu registers still fails at authorize for unapproved hosts. PRM/AS otherwise healthy (issuer `https://mcp.canva.com`, S256, path-suffixed PRM `resource: https://mcp.canva.com/mcp`) but advertises 16 scopes, so any future `dcr` re-enable must pin `oauth.scopes`. Browser clients must also allow responses from both `canva.com` and `canva.ai` | [Canva MCP docs](https://www.canva.dev/docs/mcp/), [Canva MCP troubleshooting](https://www.canva.dev/docs/mcp/troubleshooting/) | Keep `provider-managed`; apply to the Canva waitlist for `https://api.novu.co/v1/agents/mcp/oauth/callback`, or implement CIMD (publish a client metadata document at a Novu HTTPS URL and pass that URL as `client_id`) which is Canva's recommended path |
| `fmp` | FMP | `https://financialmodelingprep.com/mcp` | 2026-06-15 | `other` | Official MCP docs require dashboard API key (`?apikey=`) with no OAuth/DCR path; root AS metadata has S256 but no `registration_endpoint`; DCR probes return 401 `Invalid API KEY` | [FMP MCP docs](https://site.financialmodelingprep.com/developer/docs/mcp-server) | Keep `provider-managed`; re-probe only if FMP ships MCP OAuth/DCR with a working registration endpoint |
| `adobe-journey-optimizer` | Adobe Journey Optimizer | `https://ajo-mcp.adobe.io/mcp` | 2026-06-15 | `partner-approval` | Beta docs require Adobe-rep provisioning of org-specific endpoint + fixed credentials; only Claude Web/Desktop supported — no self-serve DCR; IMS DCR rejects Novu callback (`invalid_redirect_uri`) while localhost succeeds | [AJO MCP docs](https://experienceleague.adobe.com/en/docs/journey-optimizer/using/ai-capabilities/mcp-server) | Keep `provider-managed`; request Adobe partner approval + Novu callback allowlist |
| `adobe-marketing-agent` | Adobe Marketing Agent | `https://aep-ai-ama.adobe.io/mcp` | 2026-06-15 | `other` | DCR at `/register` succeeds for Novu callback (S256, `client_secret_post`) but RFC 9728 PRM is non-compliant — path-suffixed PRM 404; host root returns `resources[]` without `authorization_servers`, blocking Novu `discoverProtectedResource` | [Adobe Marketing Agent](https://claude.com/connectors/adobe-marketing-agent) | Keep `provider-managed`; ask Adobe to publish valid PRM with `authorization_servers`, then re-probe |
Expand Down
35 changes: 35 additions & 0 deletions apps/api/src/app/events/e2e/trigger-event.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,41 @@ describe('Trigger event - /v1/events/trigger (POST) #novu-v2', () => {
expect(message?.providerId).to.equal(payload.providerId);
});

it('should use JsonLogic conditions to select integration by subscriber', async () => {
const payload = {
providerId: EmailProviderIdEnum.Mailgun,
channel: 'email',
credentials: { apiKey: '123', secretKey: 'abc' },
_environmentId: session.environment._id,
rules: {
'==': [{ var: 'subscriber.subscriberId' }, subscriber.subscriberId],
},
active: true,
check: false,
};

await session.testAgent.post('/v1/integrations').send(payload);

template = await createTemplate(session, ChannelTypeEnum.EMAIL);

await sendTrigger(template, subscriber.subscriberId, {});

await session.waitForJobCompletion(template._id);

const createdSubscriber = await subscriberRepository.findBySubscriberId(
session.environment._id,
subscriber.subscriberId
);

const message = await messageRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: createdSubscriber?._id,
channel: ChannelTypeEnum.EMAIL,
});

expect(message?.providerId).to.equal(payload.providerId);
});

it('should use or conditions to select integration', async () => {
const payload = {
providerId: EmailProviderIdEnum.Mailgun,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ export class ParseEventRequest {

if (!command.skipQueueInsertion) {
await this.workflowQueueService.add({ name: transactionId, data: jobData, groupId: command.organizationId });
this.logger.info(
this.logger.debug(
{ ...command, transactionId, discoveredWorkflowId: discoveredWorkflow?.workflowId },
'Event dispatched to [Workflow] Queue'
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
IsObject,
IsOptional,
IsString,
ValidateIf,
ValidateNested,
} from 'class-validator';

Expand Down Expand Up @@ -76,13 +77,29 @@ export class CreateIntegrationRequestDto implements ICreateIntegrationBodyDto {

@ApiPropertyOptional({
type: [StepFilterDto],
description: 'Conditions for the integration',
deprecated: true,
description: 'Legacy StepFilter conditions. Ignored when `rules` is also set.',
})
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
conditions?: StepFilterDto[];

@ApiPropertyOptional({
type: 'object',
additionalProperties: true,
nullable: true,
description:
'JSONLogic used at send time to select this integration. Takes precedence over `conditions`.',
example: {
'==': [{ var: 'context.tenant.id' }, 'acme'],
},
})
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsObject()
rules?: Record<string, unknown> | null;

@ApiPropertyOptional({
type: Object,
description: 'Configurations for the integration',
Expand Down
28 changes: 27 additions & 1 deletion apps/api/src/app/integrations/dtos/update-integration.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger';
import { CredentialsDto, StepFilterDto } from '@novu/application-generic';
import { IUpdateIntegrationBodyDto } from '@novu/shared';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsMongoId, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import {
IsArray,
IsBoolean,
IsMongoId,
IsObject,
IsOptional,
IsString,
ValidateIf,
ValidateNested,
} from 'class-validator';

export class UpdateIntegrationRequestDto implements IUpdateIntegrationBodyDto {
@ApiPropertyOptional({ type: String })
Expand Down Expand Up @@ -43,12 +52,29 @@ export class UpdateIntegrationRequestDto implements IUpdateIntegrationBodyDto {

@ApiPropertyOptional({
type: [StepFilterDto],
deprecated: true,
description: 'Legacy StepFilter conditions. Ignored when `rules` is also set.',
})
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
conditions?: StepFilterDto[];

@ApiPropertyOptional({
type: 'object',
additionalProperties: true,
nullable: true,
description:
'JSONLogic used at send time to select this integration. Takes precedence over `conditions`.',
example: {
'==': [{ var: 'context.tenant.id' }, 'acme'],
},
})
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsObject()
rules?: Record<string, unknown> | null;

@ApiPropertyOptional({
type: Object,
description: 'Configurations for the integration',
Expand Down
69 changes: 69 additions & 0 deletions apps/api/src/app/integrations/e2e/create-integration.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,75 @@ describe('Create Integration - /integration (POST) #novu-v2', () => {
expect(body.data.conditions[0].children[0].operator).to.equal('EQUAL');
});

it('should create integration with JsonLogic conditions', async () => {
const payload = {
providerId: EmailProviderIdEnum.SendGrid,
channel: ChannelTypeEnum.EMAIL,
identifier: 'identifier-conditions-logic',
active: false,
check: false,
rules: {
'==': [{ var: 'context.tenant.id' }, 'acme'],
},
};

const { body } = await session.testAgent.post('/v1/integrations').send(payload);

expect(body.data.rules).to.deep.equal(payload.rules);
expect(body.data.primary).to.equal(false);
});

it('should reject JsonLogic conditions on a disallowed field', async () => {
const payload = {
providerId: EmailProviderIdEnum.SendGrid,
channel: ChannelTypeEnum.EMAIL,
identifier: 'identifier-conditions-logic-invalid',
active: false,
check: false,
rules: {
'==': [{ var: 'payload.foo' }, 'bar'],
},
};

const { body } = await session.testAgent.post('/v1/integrations').send(payload);

expect(body.statusCode).to.equal(400);
});

it('should reject JsonLogic conditions on deprecated tenant fields', async () => {
const payload = {
providerId: EmailProviderIdEnum.SendGrid,
channel: ChannelTypeEnum.EMAIL,
identifier: 'identifier-conditions-logic-tenant',
active: false,
check: false,
rules: {
'==': [{ var: 'tenant.identifier' }, 'acme'],
},
};

const { body } = await session.testAgent.post('/v1/integrations').send(payload);

expect(body.statusCode).to.equal(400);
});

it('should reject JsonLogic conditions with unsupported operators', async () => {
const payload = {
providerId: EmailProviderIdEnum.SendGrid,
channel: ChannelTypeEnum.EMAIL,
identifier: 'identifier-conditions-logic-log',
active: false,
check: false,
rules: {
log: { var: 'subscriber.email' },
},
};

const { body } = await session.testAgent.post('/v1/integrations').send(payload);

expect(body.statusCode).to.equal(400);
});

it('should return error with malformed conditions', async () => {
const payload = {
providerId: EmailProviderIdEnum.SendGrid,
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/app/integrations/e2e/set-itegration-as-primary.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,36 @@ describe('Set Integration As Primary - /integrations/:integrationId/set-primary
expect(found?.primary).to.equal(true);
});

it('clears JsonLogic conditions when set as primary', async () => {
await integrationRepository.deleteMany({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
});

const integration = await integrationRepository.create({
name: 'Email with jsonlogic conditions',
identifier: 'identifier-logic-1',
providerId: EmailProviderIdEnum.SendGrid,
channel: ChannelTypeEnum.EMAIL,
active: false,
_organizationId: session.organization._id,
_environmentId: session.environment._id,
rules: {
'==': [{ var: 'context.tenant.id' }, 'acme'],
},
});

await session.testAgent.post(`/v1/integrations/${integration._id}/set-primary`).send({});

const found = await integrationRepository.findOne({
_id: integration._id,
_organizationId: session.organization._id,
});

expect(found?.rules).to.equal(null);
expect(found?.primary).to.equal(true);
});

it('push channel does not support primary flag, then for integration it should throw bad request exception', async () => {
await integrationRepository.deleteMany({
_organizationId: session.organization._id,
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/app/integrations/e2e/update-integration.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,34 @@ describe('Update Integration - /integrations/:integrationId (PUT) #novu-v2', ()
expect((result?.conditions?.at(0)?.children.at(0) as ITenantFilterPart)?.operator).to.equal('EQUAL');
});

it('should update JsonLogic conditions on integration', async () => {
const payload = {
providerId: EmailProviderIdEnum.SendGrid,
channel: ChannelTypeEnum.EMAIL,
credentials: { apiKey: 'SG.123', secretKey: 'abc' },
active: true,
check: false,
rules: {
'==': [{ var: 'subscriber.locale' }, 'fr'],
},
};

const { data } = (await session.testAgent.get(`/v1/integrations`)).body;

const integration = data.find((i) => i.primary && i.channel === 'email');

await session.testAgent.put(`/v1/integrations/${integration._id}`).send(payload);

const result = await integrationRepository.findOne({
_id: integration._id,
_organizationId: session.organization._id,
});

expect(result?.rules).to.deep.equal(payload.rules);
expect(result?.conditions).to.deep.equal([]);
expect(result?.primary).to.equal(false);
});

it('should return error with malformed conditions', async () => {
const payload = {
providerId: EmailProviderIdEnum.SendGrid,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app/integrations/integrations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export class IntegrationsController {
active: body.active ?? false,
check: body.check ?? false,
conditions: body.conditions,
rules: body.rules,
configurations: body.configurations,
})
);
Expand Down Expand Up @@ -349,6 +350,7 @@ export class IntegrationsController {
active: body.active,
check: body.check ?? false,
conditions: body.conditions,
rules: body.rules,
configurations: body.configurations,
restrictToUserEnvironment: isEnvironmentScopedAuthScheme(user.scheme),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export class CreateIntegrationCommand extends EnvironmentCommand {
@ValidateNested({ each: true })
conditions?: MessageFilter[];

@IsOptional()
@IsObject()
rules?: Record<string, unknown> | null;

@IsOptional()
@IsObject()
configurations?: Record<string, string>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
slugify,
} from '@novu/shared';
import shortid from 'shortid';
import { assertValidIntegrationRules } from '../../utils/assert-integration-rules';
import { validateOutboundIntegrationCredentials } from '../../utils/validate-outbound-integration-credentials';
import { CheckIntegrationCommand } from '../check-integration/check-integration.command';
import { CheckIntegration } from '../check-integration/check-integration.usecase';
Expand Down Expand Up @@ -162,6 +163,7 @@ export class CreateIntegration {
}

await this.validate(command);
assertValidIntegrationRules(command.rules);

const isAgentKind = command.kind === IntegrationKindEnum.AGENT;

Expand Down Expand Up @@ -209,6 +211,7 @@ export class CreateIntegration {
credentials: encryptCredentials(managedCredentials),
active: command.active,
conditions: command.conditions,
rules: command.rules ?? undefined,
configurations: command.configurations,
kind: command.kind ?? IntegrationKindEnum.DELIVERY,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class SetIntegrationAsPrimary {
active: true,
primary: true,
conditions: [],
rules: null,
},
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export class UpdateIntegrationCommand extends OrganizationCommand {
@ValidateNested({ each: true })
conditions?: MessageFilter[];

@IsOptional()
@IsObject()
rules?: Record<string, unknown> | null;

@IsOptional()
@IsObject()
configurations?: IConfigurations;
Expand Down
Loading
Loading