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