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: 1 addition & 1 deletion apps/backend/src/app/integrations/integrations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,6 @@ import { PrismaService } from '../../prisma/prisma.service';
inject: [PrismaService],
},
],
exports: [ApolloIntegrationService, ApolloMcpService],
exports: [ApolloIntegrationService, ApolloMcpService, ApolloSequencesService],
})
export class IntegrationsModule {}
159 changes: 159 additions & 0 deletions apps/backend/src/app/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DEAL_STAGE_VALUES } from '@zuko/sales';
import { z } from 'zod';
import type { IcpService } from '../icp/icp.service';
import type { LeadsService } from '../leads/leads.service';
import type { ApolloSequencesService } from '../integrations/apollo/sequences/apollo-sequences.service';

export interface McpAuthContext {
userId: number;
Expand All @@ -28,6 +29,7 @@ export interface McpDeps {
contacts: ContactsService;
icps: IcpService;
leads: LeadsService;
campaigns: ApolloSequencesService;
}

/** Prisma Decimal (e.g. Deal.value) does not JSON-serialize to a number. */
Expand Down Expand Up @@ -1747,5 +1749,162 @@ export function buildMcpServer(
},
);

server.registerTool(
'list_campaigns',
{
description:
'List outreach campaigns (Apollo sequences tracked in Zuko) for an organization, optionally filtered by ICP profile.',
inputSchema: {
organizationId: z
.int()
.optional()
.describe('Target organization (optional if user has exactly one)'),
icpProfileId: z
.int()
.optional()
.describe('Restrict to campaigns linked to this ICP profile'),
},
},
async ({ organizationId, icpProfileId }) => {
if (!authCtx.scopes.includes('campaigns:read')) {
return missingScope('campaigns:read');
}
if (!deps?.campaigns) {
return toolError('Campaign management is not available.');
}

const orgIds = await memberOrgIds();
let resolvedOrgId = organizationId;
if (resolvedOrgId === undefined) {
if (orgIds.length === 0) {
return toolError('User is not a member of any organization.');
}
if (orgIds.length > 1) {
return toolError(
'User belongs to multiple organizations. Call list_organizations to find the correct id, then pass it as organizationId.',
);
}
resolvedOrgId = orgIds[0];
} else if (!orgIds.includes(resolvedOrgId)) {
return toolError(
`You do not have access to organization ${resolvedOrgId}.`,
);
}

const campaigns = icpProfileId
? await deps.campaigns.getCampaignsByIcpProfile(
resolvedOrgId,
icpProfileId,
)
: await deps.campaigns.getAllCampaigns(resolvedOrgId);
return json(campaigns);
},
);

server.registerTool(
'get_campaign',
{
description: 'Get a single campaign by its Zuko database ID.',
inputSchema: {
campaignId: z.int().describe('The ID of the campaign to retrieve'),
},
},
async ({ campaignId }) => {
if (!authCtx.scopes.includes('campaigns:read')) {
return missingScope('campaigns:read');
}
if (!deps?.campaigns) {
return toolError('Campaign management is not available.');
}

const orgIds = await memberOrgIds();
const existing = await prisma.campaign.findFirst({
where: { id: campaignId, organizationId: { in: orgIds } },
select: { organizationId: true },
});
if (!existing) {
return toolError(
`Campaign with ID ${campaignId} not found or not accessible.`,
);
}

try {
const campaign = await deps.campaigns.getZukoCampaignById(
existing.organizationId,
campaignId,
);
return json(campaign);
} catch (error: unknown) {
return toolError(
error instanceof Error ? error.message : String(error),
);
}
},
);

server.registerTool(
'create_campaign',
{
description:
'Create a campaign record by name (and optional ICP profile link). This only creates the ' +
'Zuko-side metadata row — it does not create or activate an Apollo sequence. Use the web ' +
'UI or Apollo sequence tools to build and launch the actual outreach sequence afterward. ' +
'organizationId is optional when the user belongs to exactly one organization.',
inputSchema: {
organizationId: z
.int()
.optional()
.describe(
'Organization ID to create the campaign in. Omit if you belong to exactly one organization; call list_organizations to find the correct id otherwise.',
),
name: z.string().describe('Campaign name'),
icpProfileId: z
.int()
.optional()
.describe('ICP profile to link this campaign to (optional)'),
},
},
async (args) => {
if (!authCtx.scopes.includes('campaigns:write')) {
return missingScope('campaigns:write');
}
if (!deps?.campaigns) {
return toolError('Campaign management is not available.');
}

const orgIds = await memberOrgIds();

let resolvedOrgId = args.organizationId;
if (resolvedOrgId === undefined) {
if (orgIds.length === 0) {
return toolError('User is not a member of any organization.');
}
if (orgIds.length > 1) {
return toolError(
'User belongs to multiple organizations. Call list_organizations to find the correct id, then pass it as organizationId.',
);
}
resolvedOrgId = orgIds[0];
} else if (!orgIds.includes(resolvedOrgId)) {
return toolError(
`You do not have access to organization ${resolvedOrgId}.`,
);
}

try {
const campaign = await deps.campaigns.createCampaignMeta(
resolvedOrgId,
authCtx.userId,
{ name: args.name, icpProfileId: args.icpProfileId },
);
return json(campaign);
} catch (error: unknown) {
return toolError(
error instanceof Error ? error.message : String(error),
);
}
},
);

return server;
}
149 changes: 149 additions & 0 deletions apps/backend/src/app/mcp/mcp-write-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,155 @@ describe('revert_lead tool', () => {
});
});

function campaignDb(over: Record<string, unknown> = {}) {
return {
member: { findMany: vi.fn(async () => [{ organizationId: 1 }]) },
campaign: {
findFirst: vi.fn(async () => ({ id: 7, organizationId: 1 })),
},
...over,
} as never;
}

function campaignsSvc(over: Record<string, unknown> = {}) {
return {
campaigns: {
getAllCampaigns: vi.fn(async () => []),
getCampaignsByIcpProfile: vi.fn(async () => []),
getZukoCampaignById: vi.fn(async () => ({
id: 7,
name: 'Existing Campaign',
})),
createCampaignMeta: vi.fn(async () => ({
id: 100,
name: 'New Campaign',
})),
...over,
},
};
}

describe('list_campaigns tool', () => {
it('rejects when token lacks campaigns:read', async () => {
const client = await connect(
campaignDb(),
['campaigns:write'],
campaignsSvc(),
);
const res = (await client.callTool({
name: 'list_campaigns',
arguments: {},
})) as { isError?: boolean };
expect(res.isError).toBe(true);
});

it('auto-resolves org and calls getAllCampaigns when no icpProfileId given', async () => {
const svc = campaignsSvc();
const client = await connect(campaignDb(), ['campaigns:read'], svc);
await client.callTool({ name: 'list_campaigns', arguments: {} });
expect(svc.campaigns.getAllCampaigns).toHaveBeenCalledWith(1);
expect(svc.campaigns.getCampaignsByIcpProfile).not.toHaveBeenCalled();
});

it('calls getCampaignsByIcpProfile when icpProfileId given', async () => {
const svc = campaignsSvc();
const client = await connect(campaignDb(), ['campaigns:read'], svc);
await client.callTool({
name: 'list_campaigns',
arguments: { icpProfileId: 5 },
});
expect(svc.campaigns.getCampaignsByIcpProfile).toHaveBeenCalledWith(1, 5);
});

it('errors when user belongs to multiple orgs and no organizationId given', async () => {
const db = campaignDb({
member: {
findMany: vi.fn(async () => [
{ organizationId: 1 },
{ organizationId: 2 },
]),
},
});
const client = await connect(db, ['campaigns:read'], campaignsSvc());
const res = (await client.callTool({
name: 'list_campaigns',
arguments: {},
})) as { isError?: boolean };
expect(res.isError).toBe(true);
});
});

describe('get_campaign tool', () => {
it('errors when campaign not found or inaccessible', async () => {
const db = campaignDb({
campaign: { findFirst: vi.fn(async () => null) },
});
const client = await connect(db, ['campaigns:read'], campaignsSvc());
const res = (await client.callTool({
name: 'get_campaign',
arguments: { campaignId: 99 },
})) as { isError?: boolean };
expect(res.isError).toBe(true);
});

it('returns campaign data when found', async () => {
const client = await connect(
campaignDb(),
['campaigns:read'],
campaignsSvc(),
);
const res = await client.callTool({
name: 'get_campaign',
arguments: { campaignId: 7 },
});
const campaign = parse(res);
expect(campaign.id).toBe(7);
});
});

describe('create_campaign tool', () => {
it('rejects when token lacks campaigns:write', async () => {
const client = await connect(
campaignDb(),
['campaigns:read'],
campaignsSvc(),
);
const res = (await client.callTool({
name: 'create_campaign',
arguments: { name: 'New Campaign' },
})) as { isError?: boolean };
expect(res.isError).toBe(true);
});

it('auto-resolves org and calls createCampaignMeta with the authorized user id', async () => {
const svc = campaignsSvc();
const client = await connect(campaignDb(), ['campaigns:write'], svc);
await client.callTool({
name: 'create_campaign',
arguments: { name: 'New Campaign', icpProfileId: 3 },
});
expect(svc.campaigns.createCampaignMeta).toHaveBeenCalledTimes(1);
const [orgId, userId, dto] = svc.campaigns.createCampaignMeta.mock.calls[0];
expect(orgId).toBe(1);
expect(userId).toBe(42);
expect(dto).toEqual({ name: 'New Campaign', icpProfileId: 3 });
});

it('surfaces service errors', async () => {
const svc = campaignsSvc({
createCampaignMeta: vi.fn(async () => {
throw new Error('Name is required');
}),
});
const client = await connect(campaignDb(), ['campaigns:write'], svc);
const res = (await client.callTool({
name: 'create_campaign',
arguments: { name: '' },
})) as { isError?: boolean };
expect(res.isError).toBe(true);
});
});

describe('update_company_summary tool', () => {
it('rejects when token lacks companies:write', async () => {
const client = await connect(
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/app/mcp/mcp.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DealsService, CompaniesService, ContactsService } from '@zuko/sales';
import { PrismaService } from '../../prisma/prisma.service';
import { IcpService } from '../icp/icp.service';
import { LeadsService } from '../leads/leads.service';
import { ApolloSequencesService } from '../integrations/apollo/sequences/apollo-sequences.service';
import { buildMcpServer } from './mcp-server';
import { McpBearerGuard, type McpAuthedRequest } from './mcp-bearer.guard';

Expand All @@ -28,6 +29,7 @@ export class McpController {
private readonly contacts: ContactsService,
private readonly icps: IcpService,
private readonly leads: LeadsService,
private readonly campaigns: ApolloSequencesService,
) {}

@All()
Expand All @@ -53,6 +55,7 @@ export class McpController {
contacts: this.contacts,
icps: this.icps,
leads: this.leads,
campaigns: this.campaigns,
},
);
const transport = new StreamableHTTPServerTransport({
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/app/mcp/mcp.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { PrismaService } from '../../prisma/prisma.service';
import { SalesModule } from '../sales/sales.module';
import { IcpModule } from '../icp/icp.module';
import { LeadsModule } from '../leads/leads.module';
import { IntegrationsModule } from '../integrations/integrations.module';

@Module({
imports: [SalesModule, IcpModule, LeadsModule],
imports: [SalesModule, IcpModule, LeadsModule, IntegrationsModule],
controllers: [McpController, WellKnownController],
providers: [McpBearerGuard, PrismaService],
})
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/libs/better-auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const MCP_SCOPES = [
'icps:write',
'leads:read',
'leads:write',
'campaigns:read',
'campaigns:write',
];

const AGENT_CAPABILITIES: Capability[] = [
Expand Down
Loading
Loading