From dc0e2ed6eeaae4286a5cf83f3b2d73679d151db3 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Wed, 16 Sep 2026 16:51:22 +0530 Subject: [PATCH] feat(mcp): add campaign MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds list_campaigns, get_campaign, and create_campaign tools backed by ApolloSequencesService's DB-only methods (getAllCampaigns, getCampaignsByIcpProfile, getZukoCampaignById, createCampaignMeta). Scoped to campaigns:read/campaigns:write. create_campaign only writes the Zuko-side metadata row — building and activating the actual Apollo sequence (createSequence, approve, deactivate, addContactsToSequence, saveSequenceForCampaign) is left out of MCP for now since those call the live Apollo API and warrant their own review before exposing to an agent. Co-Authored-By: Claude Sonnet 5 --- .../app/integrations/integrations.module.ts | 2 +- apps/backend/src/app/mcp/mcp-server.ts | 159 ++++++++++++++++++ .../src/app/mcp/mcp-write-tools.spec.ts | 149 ++++++++++++++++ apps/backend/src/app/mcp/mcp.controller.ts | 3 + apps/backend/src/app/mcp/mcp.module.ts | 3 +- apps/backend/src/libs/better-auth/auth.ts | 2 + docs/concepts/mcp-server.mdx | 41 +++++ 7 files changed, 357 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/app/integrations/integrations.module.ts b/apps/backend/src/app/integrations/integrations.module.ts index 92bd00cf..55dda31e 100644 --- a/apps/backend/src/app/integrations/integrations.module.ts +++ b/apps/backend/src/app/integrations/integrations.module.ts @@ -54,6 +54,6 @@ import { PrismaService } from '../../prisma/prisma.service'; inject: [PrismaService], }, ], - exports: [ApolloIntegrationService, ApolloMcpService], + exports: [ApolloIntegrationService, ApolloMcpService, ApolloSequencesService], }) export class IntegrationsModule {} diff --git a/apps/backend/src/app/mcp/mcp-server.ts b/apps/backend/src/app/mcp/mcp-server.ts index 691da4b8..09f52ce0 100644 --- a/apps/backend/src/app/mcp/mcp-server.ts +++ b/apps/backend/src/app/mcp/mcp-server.ts @@ -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; @@ -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. */ @@ -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; } diff --git a/apps/backend/src/app/mcp/mcp-write-tools.spec.ts b/apps/backend/src/app/mcp/mcp-write-tools.spec.ts index dec2e6ff..6e2f54b3 100644 --- a/apps/backend/src/app/mcp/mcp-write-tools.spec.ts +++ b/apps/backend/src/app/mcp/mcp-write-tools.spec.ts @@ -1117,6 +1117,155 @@ describe('revert_lead tool', () => { }); }); +function campaignDb(over: Record = {}) { + return { + member: { findMany: vi.fn(async () => [{ organizationId: 1 }]) }, + campaign: { + findFirst: vi.fn(async () => ({ id: 7, organizationId: 1 })), + }, + ...over, + } as never; +} + +function campaignsSvc(over: Record = {}) { + 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( diff --git a/apps/backend/src/app/mcp/mcp.controller.ts b/apps/backend/src/app/mcp/mcp.controller.ts index 0c0f7ccf..7b94ec2e 100644 --- a/apps/backend/src/app/mcp/mcp.controller.ts +++ b/apps/backend/src/app/mcp/mcp.controller.ts @@ -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'; @@ -28,6 +29,7 @@ export class McpController { private readonly contacts: ContactsService, private readonly icps: IcpService, private readonly leads: LeadsService, + private readonly campaigns: ApolloSequencesService, ) {} @All() @@ -53,6 +55,7 @@ export class McpController { contacts: this.contacts, icps: this.icps, leads: this.leads, + campaigns: this.campaigns, }, ); const transport = new StreamableHTTPServerTransport({ diff --git a/apps/backend/src/app/mcp/mcp.module.ts b/apps/backend/src/app/mcp/mcp.module.ts index 8c6c53d6..19a1f056 100644 --- a/apps/backend/src/app/mcp/mcp.module.ts +++ b/apps/backend/src/app/mcp/mcp.module.ts @@ -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], }) diff --git a/apps/backend/src/libs/better-auth/auth.ts b/apps/backend/src/libs/better-auth/auth.ts index f2ab0e75..8822d335 100644 --- a/apps/backend/src/libs/better-auth/auth.ts +++ b/apps/backend/src/libs/better-auth/auth.ts @@ -23,6 +23,8 @@ export const MCP_SCOPES = [ 'icps:write', 'leads:read', 'leads:write', + 'campaigns:read', + 'campaigns:write', ]; const AGENT_CAPABILITIES: Capability[] = [ diff --git a/docs/concepts/mcp-server.mdx b/docs/concepts/mcp-server.mdx index a7f881ea..eac50cb8 100644 --- a/docs/concepts/mcp-server.mdx +++ b/docs/concepts/mcp-server.mdx @@ -41,6 +41,8 @@ Tokens are JWTs verified locally against `{BACKEND_URL}/auth/jwks` — no round- | `icps:write` | Create, update, and delete ICP profiles | | `leads:read` | List and retrieve leads | | `leads:write` | Create, update, delete, convert, and revert leads | +| `campaigns:read` | List and retrieve campaigns | +| `campaigns:write` | Create campaign records | ## Tools @@ -522,6 +524,45 @@ Reverts a converted lead back to `"replied"` status, deleting the deal that was | -------- | --------- | ------------------------ | | `leadId` | `integer` | ID of the lead to revert | +--- + +### `list_campaigns` + +**Scope:** `campaigns:read` + +Lists outreach campaigns (Apollo sequences tracked in Zuko) for an organization, optionally filtered by ICP profile. + +| Input | Type | Description | +| ---------------- | --------- | ----------------------------------------------------------- | +| `organizationId` | `integer` | Target organization (optional if user has exactly one) | +| `icpProfileId` | `integer` | Restrict to campaigns linked to this ICP profile (optional) | + +--- + +### `get_campaign` + +**Scope:** `campaigns:read` + +Gets a single campaign by its Zuko database ID. + +| Input | Type | Description | +| ------------ | --------- | ------------------------------ | +| `campaignId` | `integer` | ID of the campaign to retrieve | + +--- + +### `create_campaign` + +**Scope:** `campaigns:write` + +Creates 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. + +| Input | Type | Description | +| ---------------- | --------- | ------------------------------------------------------ | +| `name` | `string` | Campaign name (required) | +| `organizationId` | `integer` | Target organization (optional if user has exactly one) | +| `icpProfileId` | `integer` | ICP profile to link this campaign to (optional) | + ## Connecting a client ### Claude Desktop