@totalaudiopromo/tap-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @total-audio/tap-mcp
2
+
3
+ MCP server for [Total Audio Promo](https://totalaudiopromo.com) -- the first music PR tool with programmatic access.
4
+
5
+ Exposes 10 tools and 3 resources via the [Model Context Protocol](https://modelcontextprotocol.io), giving AI agents full access to the music PR campaign lifecycle.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ | ---------------------- | ------------------------------------------------------------ |
11
+ | `tap_list_campaigns` | List campaigns with optional status filter |
12
+ | `tap_get_campaign` | Get campaign details with contact counts and pitch stats |
13
+ | `tap_list_contacts` | List contacts with filters (status, genre, BBC-only, search) |
14
+ | `tap_search_contacts` | Search contacts by name, outlet, genre, or BBC station |
15
+ | `tap_contact_insights` | Get enrichment and relationship insights for a contact |
16
+ | `tap_draft_pitch` | Generate AI-drafted pitches (subject, body, variants) |
17
+ | `tap_log_outcome` | Log campaign outcomes (replied, coverage, playlist, etc.) |
18
+ | `tap_get_action_queue` | Get today's prioritised action queue |
19
+
20
+ ## Resources
21
+
22
+ | URI | Description |
23
+ | ----------------- | ---------------------------- |
24
+ | `tap://campaigns` | Browse all campaigns as JSON |
25
+ | `tap://contacts` | Browse all contacts as JSON |
26
+ | `tap://artists` | Browse all artists as JSON |
27
+
28
+ ## Setup
29
+
30
+ ### Claude Code
31
+
32
+ Add to `.claude/settings.json`:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "tap": {
38
+ "command": "npx",
39
+ "args": ["@total-audio/tap-mcp"],
40
+ "env": {
41
+ "TAP_SUPABASE_URL": "your-supabase-url",
42
+ "TAP_SUPABASE_KEY": "your-service-role-key"
43
+ }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ### From source
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "tap": {
55
+ "command": "npx",
56
+ "args": ["tsx", "packages/tap-mcp/src/index.ts"],
57
+ "env": {
58
+ "TAP_SUPABASE_URL": "your-supabase-url",
59
+ "TAP_SUPABASE_KEY": "your-service-role-key"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Environment Variables
67
+
68
+ | Variable | Description |
69
+ | ------------------ | ------------------------- |
70
+ | `TAP_SUPABASE_URL` | Supabase project URL |
71
+ | `TAP_SUPABASE_KEY` | Supabase service role key |
72
+
73
+ ## REST API
74
+
75
+ TAP also has a REST API with 7 endpoints for contacts, enrichment, validation, and API key management. See the [developer docs](https://totalaudiopromo.com/developers) and [OpenAPI spec](https://totalaudiopromo.com/api/openapi.json).
76
+
77
+ ## Licence
78
+
79
+ MIT
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,894 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // src/tools/campaigns.ts
8
+ import { z } from "zod";
9
+
10
+ // src/auth.ts
11
+ import { createClient } from "@supabase/supabase-js";
12
+ var adminClient = null;
13
+ function getSupabaseUrl() {
14
+ const url = process.env.TAP_SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
15
+ if (!url) throw new Error("TAP_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL is required");
16
+ return url;
17
+ }
18
+ function getWorkspaceId() {
19
+ const wsId = process.env.TAP_WORKSPACE_ID;
20
+ if (!wsId) {
21
+ throw new Error(
22
+ "TAP_WORKSPACE_ID environment variable is required. Set it to the workspace ID this MCP server should operate within."
23
+ );
24
+ }
25
+ return wsId;
26
+ }
27
+ function getTapUrl() {
28
+ return process.env.TAP_URL || "https://totalaudiopromo.com";
29
+ }
30
+ function getAdminClient() {
31
+ if (adminClient) return adminClient;
32
+ const url = getSupabaseUrl();
33
+ const key = process.env.TAP_SUPABASE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
34
+ if (!key) throw new Error("TAP_SUPABASE_KEY or SUPABASE_SERVICE_ROLE_KEY is required");
35
+ adminClient = createClient(url, key, {
36
+ auth: { persistSession: false, autoRefreshToken: false }
37
+ });
38
+ return adminClient;
39
+ }
40
+
41
+ // src/tools/campaigns.ts
42
+ function registerCampaignTools(server2) {
43
+ server2.tool(
44
+ "tap_list_campaigns",
45
+ "List campaigns in the workspace, optionally filtered by status",
46
+ {
47
+ status: z.enum(["draft", "active", "paused", "completed", "archived"]).optional(),
48
+ limit: z.number().optional().describe("Max results (default 50)")
49
+ },
50
+ async ({ status, limit }) => {
51
+ const supabase = getAdminClient();
52
+ const wsId = getWorkspaceId();
53
+ let query = supabase.from("tap_projects").select("id, name, artist_name, status, release_name, release_date, goal, created_at", {
54
+ count: "exact"
55
+ }).eq("workspace_id", wsId).order("created_at", { ascending: false }).limit(limit || 50);
56
+ if (status) query = query.eq("status", status);
57
+ const { data, count, error } = await query;
58
+ if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
59
+ return {
60
+ content: [
61
+ {
62
+ type: "text",
63
+ text: JSON.stringify({ campaigns: data || [], total: count || 0 }, null, 2)
64
+ }
65
+ ]
66
+ };
67
+ }
68
+ );
69
+ server2.tool(
70
+ "tap_get_campaign",
71
+ "Get campaign details including contacts, outcomes, and pitch stats",
72
+ {
73
+ campaign_id: z.string().describe("Campaign ID")
74
+ },
75
+ async ({ campaign_id }) => {
76
+ const supabase = getAdminClient();
77
+ const wsId = getWorkspaceId();
78
+ const { data: campaign, error } = await supabase.from("tap_projects").select("*").eq("id", campaign_id).eq("workspace_id", wsId).single();
79
+ if (error || !campaign) {
80
+ return {
81
+ content: [
82
+ { type: "text", text: `Campaign not found in workspace: ${campaign_id}` }
83
+ ]
84
+ };
85
+ }
86
+ const [contactsResult, outcomesResult, pitchesResult] = await Promise.all([
87
+ supabase.from("campaign_contacts").select("contact_id, pitch_status", { count: "exact" }).eq("project_id", campaign_id),
88
+ supabase.from("tap_contact_outcomes").select("outcome_type").eq("project_id", campaign_id).eq("workspace_id", wsId),
89
+ supabase.from("campaign_pitch_drafts").select("id", { count: "exact", head: true }).eq("campaign_id", campaign_id)
90
+ ]);
91
+ const contacts = contactsResult.data || [];
92
+ const outcomes = outcomesResult.data || [];
93
+ const pitched = contacts.filter(
94
+ (c) => ["sent", "opened", "replied"].includes(c.pitch_status)
95
+ ).length;
96
+ const replied = contacts.filter((c) => c.pitch_status === "replied").length;
97
+ return {
98
+ content: [
99
+ {
100
+ type: "text",
101
+ text: JSON.stringify(
102
+ {
103
+ ...campaign,
104
+ stats: {
105
+ totalContacts: contactsResult.count || 0,
106
+ pitched,
107
+ replied,
108
+ responseRate: contacts.length > 0 ? Math.round(replied / contacts.length * 100) : 0,
109
+ outcomes: outcomes.length,
110
+ pitchDrafts: pitchesResult.count || 0
111
+ }
112
+ },
113
+ null,
114
+ 2
115
+ )
116
+ }
117
+ ]
118
+ };
119
+ }
120
+ );
121
+ server2.tool(
122
+ "tap_create_campaign",
123
+ "Create a new campaign in the workspace",
124
+ {
125
+ name: z.string().describe("Campaign name"),
126
+ artist_name: z.string().optional().describe("Artist name"),
127
+ release_name: z.string().optional().describe("Release name"),
128
+ release_date: z.string().optional().describe("Release date (YYYY-MM-DD)"),
129
+ goal: z.string().optional().describe("Campaign goal"),
130
+ channels: z.array(z.string()).optional().describe("Target channels (radio, press, playlist)")
131
+ },
132
+ async ({ name, artist_name, release_name, release_date, goal, channels }) => {
133
+ const supabase = getAdminClient();
134
+ const wsId = getWorkspaceId();
135
+ const { data, error } = await supabase.from("tap_projects").insert({
136
+ workspace_id: wsId,
137
+ name,
138
+ artist_name: artist_name || null,
139
+ release_name: release_name || null,
140
+ release_date: release_date || null,
141
+ goal: goal || null,
142
+ services: channels || null,
143
+ status: "draft"
144
+ }).select("*").single();
145
+ if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
146
+ return {
147
+ content: [
148
+ {
149
+ type: "text",
150
+ text: JSON.stringify(
151
+ { campaign: data, message: `Campaign "${name}" created as draft` },
152
+ null,
153
+ 2
154
+ )
155
+ }
156
+ ]
157
+ };
158
+ }
159
+ );
160
+ server2.tool(
161
+ "tap_suggest_contacts",
162
+ "Get AI-scored contact suggestions for a campaign based on warmth, genre overlap, response rate, and past outcomes",
163
+ {
164
+ campaign_id: z.string().describe("Campaign ID"),
165
+ limit: z.number().optional().describe("Max suggestions (default 20)")
166
+ },
167
+ async ({ campaign_id, limit }) => {
168
+ const supabase = getAdminClient();
169
+ const wsId = getWorkspaceId();
170
+ const { data: campaign, error } = await supabase.from("tap_projects").select("workspace_id, services, artist_id").eq("id", campaign_id).eq("workspace_id", wsId).single();
171
+ if (error || !campaign) {
172
+ return {
173
+ content: [
174
+ { type: "text", text: `Campaign not found in workspace: ${campaign_id}` }
175
+ ]
176
+ };
177
+ }
178
+ const { data: existingLinks } = await supabase.from("campaign_contacts").select("contact_id").eq("project_id", campaign_id);
179
+ const excludeIds = new Set((existingLinks || []).map((l) => l.contact_id));
180
+ const { data: contacts } = await supabase.from("tap_contacts").select(
181
+ "id, name, email, outlet, platform_type, genres, bbc_station, enrichment_confidence"
182
+ ).eq("workspace_id", wsId).order("updated_at", { ascending: false }).limit(200);
183
+ if (!contacts || contacts.length === 0) {
184
+ return {
185
+ content: [{ type: "text", text: JSON.stringify({ suggestions: [], total: 0 }) }]
186
+ };
187
+ }
188
+ const contactIds = contacts.filter((c) => !excludeIds.has(c.id)).map((c) => c.id);
189
+ const { data: metricsRows } = await supabase.from("contact_relationship_metrics").select("contact_id, warmth_level, response_rate, avg_response_days").in("contact_id", contactIds);
190
+ const metricsMap = /* @__PURE__ */ new Map();
191
+ for (const m of metricsRows ?? []) {
192
+ metricsMap.set(m.contact_id, m);
193
+ }
194
+ const channelMap = {
195
+ radio: "radio",
196
+ press: "press",
197
+ playlists: "playlist"
198
+ };
199
+ const targetPlatforms = new Set(
200
+ (campaign.services || []).map((s) => channelMap[s]).filter(Boolean)
201
+ );
202
+ const scored = contacts.filter((c) => !excludeIds.has(c.id)).map((c) => {
203
+ let score = 0;
204
+ const reasons = [];
205
+ const metrics = metricsMap.get(c.id);
206
+ if (targetPlatforms.size > 0 && c.platform_type && targetPlatforms.has(c.platform_type)) {
207
+ score += 3;
208
+ reasons.push(`${c.platform_type} match`);
209
+ }
210
+ if (metrics?.warmth_level === "hot") {
211
+ score += 3;
212
+ reasons.push("hot");
213
+ } else if (metrics?.warmth_level === "warm") {
214
+ score += 2;
215
+ reasons.push("warm");
216
+ }
217
+ if (metrics?.response_rate != null && metrics.response_rate > 0.25) {
218
+ score += 2;
219
+ reasons.push("good response rate");
220
+ }
221
+ if (metrics?.avg_response_days != null && metrics.avg_response_days <= 7) {
222
+ score += 1;
223
+ reasons.push("fast responder");
224
+ }
225
+ return { ...c, score, reason: reasons.join(", ") || "workspace contact" };
226
+ }).filter((c) => c.score >= 2).sort((a, b) => b.score - a.score).slice(0, limit || 20);
227
+ return {
228
+ content: [
229
+ {
230
+ type: "text",
231
+ text: JSON.stringify({ suggestions: scored, total: scored.length }, null, 2)
232
+ }
233
+ ]
234
+ };
235
+ }
236
+ );
237
+ server2.tool(
238
+ "tap_add_contact_to_campaign",
239
+ "Add a contact to a campaign (creates a campaign_contacts link)",
240
+ {
241
+ campaign_id: z.string().describe("Campaign ID"),
242
+ contact_id: z.string().describe("Contact ID to add"),
243
+ notes: z.string().optional().describe("Notes about why this contact was added")
244
+ },
245
+ async ({ campaign_id, contact_id, notes }) => {
246
+ const supabase = getAdminClient();
247
+ const wsId = getWorkspaceId();
248
+ const { data: campaign, error: campError } = await supabase.from("tap_projects").select("id, name").eq("id", campaign_id).eq("workspace_id", wsId).maybeSingle();
249
+ if (campError || !campaign) {
250
+ return {
251
+ content: [
252
+ { type: "text", text: `Campaign not found in workspace: ${campaign_id}` }
253
+ ]
254
+ };
255
+ }
256
+ const { data: contact, error: contactError } = await supabase.from("tap_contacts").select("id, name, email").eq("id", contact_id).eq("workspace_id", wsId).maybeSingle();
257
+ if (contactError || !contact) {
258
+ return {
259
+ content: [
260
+ { type: "text", text: `Contact not found in workspace: ${contact_id}` }
261
+ ]
262
+ };
263
+ }
264
+ const { data: existing } = await supabase.from("campaign_contacts").select("id").eq("project_id", campaign_id).eq("contact_id", contact_id).maybeSingle();
265
+ if (existing) {
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text",
270
+ text: `${contact.name} is already in campaign "${campaign.name}"`
271
+ }
272
+ ]
273
+ };
274
+ }
275
+ const { data, error } = await supabase.from("campaign_contacts").insert({
276
+ project_id: campaign_id,
277
+ contact_id,
278
+ pitch_status: "not_pitched",
279
+ notes: notes || null,
280
+ added_at: (/* @__PURE__ */ new Date()).toISOString()
281
+ }).select("id, project_id, contact_id, pitch_status, added_at").single();
282
+ if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
283
+ return {
284
+ content: [
285
+ {
286
+ type: "text",
287
+ text: JSON.stringify(
288
+ {
289
+ link: data,
290
+ message: `Added ${contact.name} (${contact.email}) to campaign "${campaign.name}"`
291
+ },
292
+ null,
293
+ 2
294
+ )
295
+ }
296
+ ]
297
+ };
298
+ }
299
+ );
300
+ server2.tool(
301
+ "tap_campaign_memory",
302
+ "Get or set campaign learnings/memory",
303
+ {
304
+ campaign_id: z.string(),
305
+ action: z.enum(["get", "set"]).describe("Get or set campaign memory"),
306
+ what_worked: z.string().optional().describe("What worked well (for set action)"),
307
+ do_differently: z.string().optional().describe("What to do differently (for set action)"),
308
+ best_angle: z.string().optional().describe("Best pitch angle (for set action)")
309
+ },
310
+ async ({ campaign_id, action, what_worked, do_differently, best_angle }) => {
311
+ const supabase = getAdminClient();
312
+ const wsId = getWorkspaceId();
313
+ if (action === "get") {
314
+ const { data, error: error2 } = await supabase.from("tap_projects").select("memory_what_worked, memory_do_differently, memory_best_angle, memory_notes").eq("id", campaign_id).eq("workspace_id", wsId).single();
315
+ if (error2) return { content: [{ type: "text", text: `Error: ${error2.message}` }] };
316
+ return {
317
+ content: [
318
+ {
319
+ type: "text",
320
+ text: JSON.stringify(data, null, 2)
321
+ }
322
+ ]
323
+ };
324
+ }
325
+ const updates = {};
326
+ if (what_worked !== void 0) updates.memory_what_worked = what_worked;
327
+ if (do_differently !== void 0) updates.memory_do_differently = do_differently;
328
+ if (best_angle !== void 0) updates.memory_best_angle = best_angle;
329
+ const { error } = await supabase.from("tap_projects").update(updates).eq("id", campaign_id).eq("workspace_id", wsId);
330
+ if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
331
+ return {
332
+ content: [
333
+ {
334
+ type: "text",
335
+ text: "Campaign memory updated successfully"
336
+ }
337
+ ]
338
+ };
339
+ }
340
+ );
341
+ }
342
+
343
+ // src/tools/contacts.ts
344
+ import { z as z2 } from "zod";
345
+ function registerContactTools(server2) {
346
+ server2.tool(
347
+ "tap_list_contacts",
348
+ "List contacts in the workspace with optional filters",
349
+ {
350
+ status: z2.string().optional().describe("Pipeline status filter"),
351
+ genre: z2.string().optional().describe("Genre filter"),
352
+ bbc_only: z2.boolean().optional().describe("Only show BBC contacts"),
353
+ search: z2.string().optional().describe("Search name, email, outlet"),
354
+ limit: z2.number().optional().describe("Max results (default 50)"),
355
+ offset: z2.number().optional()
356
+ },
357
+ async ({ status, genre, bbc_only, search, limit, offset }) => {
358
+ const supabase = getAdminClient();
359
+ const wsId = getWorkspaceId();
360
+ let query = supabase.from("tap_contacts").select(
361
+ "id, name, email, outlet, role, genres, bbc_station, pipeline_status, enrichment_confidence, platform_type",
362
+ { count: "exact" }
363
+ ).eq("workspace_id", wsId).order("name", { ascending: true }).limit(limit || 50);
364
+ if (status) query = query.eq("pipeline_status", status);
365
+ if (genre) query = query.contains("genres", [genre]);
366
+ if (bbc_only) query = query.not("bbc_station", "is", null);
367
+ if (search) {
368
+ const q = search.replace(/'/g, "''");
369
+ query = query.or(
370
+ `name.ilike.%${q}%,email.ilike.%${q}%,outlet.ilike.%${q}%,bbc_station.ilike.%${q}%`
371
+ );
372
+ }
373
+ if (offset) query = query.range(offset, offset + (limit || 50) - 1);
374
+ const { data, count, error } = await query;
375
+ if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
376
+ return {
377
+ content: [
378
+ {
379
+ type: "text",
380
+ text: JSON.stringify({ contacts: data || [], total: count || 0 }, null, 2)
381
+ }
382
+ ]
383
+ };
384
+ }
385
+ );
386
+ server2.tool(
387
+ "tap_search_contacts",
388
+ "Search contacts by name, outlet, genre, or BBC station",
389
+ {
390
+ query: z2.string().describe("Search query"),
391
+ limit: z2.number().optional().describe("Max results (default 20)")
392
+ },
393
+ async ({ query, limit }) => {
394
+ const supabase = getAdminClient();
395
+ const wsId = getWorkspaceId();
396
+ const q = query.replace(/'/g, "''");
397
+ const { data, error } = await supabase.from("tap_contacts").select(
398
+ "id, name, email, outlet, role, genres, bbc_station, pipeline_status, enrichment_confidence"
399
+ ).eq("workspace_id", wsId).or(
400
+ `name.ilike.%${q}%,email.ilike.%${q}%,outlet.ilike.%${q}%,bbc_station.ilike.%${q}%,role.ilike.%${q}%`
401
+ ).order("name", { ascending: true }).limit(limit || 20);
402
+ if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
403
+ return {
404
+ content: [
405
+ {
406
+ type: "text",
407
+ text: JSON.stringify({ results: data || [], count: data?.length || 0 }, null, 2)
408
+ }
409
+ ]
410
+ };
411
+ }
412
+ );
413
+ server2.tool(
414
+ "tap_contact_insights",
415
+ "Get relationship insights for a contact (warmth, response rate, outcomes)",
416
+ {
417
+ contact_id: z2.string().describe("Contact ID")
418
+ },
419
+ async ({ contact_id }) => {
420
+ const supabase = getAdminClient();
421
+ const wsId = getWorkspaceId();
422
+ const { data: metrics } = await supabase.from("contact_relationship_metrics").select(
423
+ "warmth_level, warmth_score, response_rate, total_pitches, positive_outcomes, avg_response_days, last_contacted_at"
424
+ ).eq("contact_id", contact_id).maybeSingle();
425
+ const { data: outcomes } = await supabase.from("tap_contact_outcomes").select("outcome_type, channel, occurred_at, notes").eq("contact_id", contact_id).eq("workspace_id", wsId).order("occurred_at", { ascending: false }).limit(5);
426
+ const { data: contact } = await supabase.from("tap_contacts").select("name, email, outlet").eq("id", contact_id).eq("workspace_id", wsId).maybeSingle();
427
+ if (!contact) {
428
+ return {
429
+ content: [
430
+ { type: "text", text: `Contact not found in workspace: ${contact_id}` }
431
+ ]
432
+ };
433
+ }
434
+ return {
435
+ content: [
436
+ {
437
+ type: "text",
438
+ text: JSON.stringify(
439
+ {
440
+ contact: { name: contact.name, email: contact.email, outlet: contact.outlet },
441
+ metrics: metrics ? {
442
+ warmth: metrics.warmth_level,
443
+ warmthScore: metrics.warmth_score,
444
+ responseRate: metrics.response_rate,
445
+ totalPitches: metrics.total_pitches,
446
+ positiveOutcomes: metrics.positive_outcomes,
447
+ avgResponseDays: metrics.avg_response_days,
448
+ lastContactedAt: metrics.last_contacted_at
449
+ } : null,
450
+ recentOutcomes: (outcomes ?? []).map((o) => ({
451
+ type: o.outcome_type,
452
+ channel: o.channel,
453
+ date: o.occurred_at,
454
+ notes: o.notes
455
+ }))
456
+ },
457
+ null,
458
+ 2
459
+ )
460
+ }
461
+ ]
462
+ };
463
+ }
464
+ );
465
+ server2.tool(
466
+ "tap_create_contact",
467
+ "Create a new contact in the workspace",
468
+ {
469
+ name: z2.string().describe("Contact display name"),
470
+ email: z2.string().email().describe("Email address (must be unique in workspace)"),
471
+ outlet: z2.string().optional().describe("Station, publication, or company name"),
472
+ role: z2.string().optional().describe("Job title or role"),
473
+ genres: z2.array(z2.string()).optional().describe('Music genres (e.g. ["pop", "indie"])'),
474
+ platform_type: z2.enum(["radio", "press", "playlist", "tv", "online", "other"]).optional().describe("Contact platform type"),
475
+ bbc_station: z2.string().optional().describe("BBC station name if applicable"),
476
+ notes: z2.string().optional().describe("Free-text notes about this contact")
477
+ },
478
+ async ({ name, email, outlet, role, genres, platform_type, bbc_station, notes }) => {
479
+ const supabase = getAdminClient();
480
+ const wsId = getWorkspaceId();
481
+ const { data: existing } = await supabase.from("tap_contacts").select("id, name").eq("workspace_id", wsId).eq("email", email).maybeSingle();
482
+ if (existing) {
483
+ return {
484
+ content: [
485
+ {
486
+ type: "text",
487
+ text: `Contact with email ${email} already exists: ${existing.name} (${existing.id})`
488
+ }
489
+ ]
490
+ };
491
+ }
492
+ const { data, error } = await supabase.from("tap_contacts").insert({
493
+ workspace_id: wsId,
494
+ name,
495
+ email,
496
+ outlet: outlet || null,
497
+ role: role || null,
498
+ genres: genres || null,
499
+ platform_type: platform_type || null,
500
+ bbc_station: bbc_station || null,
501
+ notes: notes || null,
502
+ source: "mcp",
503
+ pipeline_status: "new",
504
+ enriched: false
505
+ }).select(
506
+ "id, name, email, outlet, role, genres, platform_type, bbc_station, pipeline_status"
507
+ ).single();
508
+ if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
509
+ return {
510
+ content: [
511
+ {
512
+ type: "text",
513
+ text: JSON.stringify({ contact: data, message: `Contact "${name}" created` }, null, 2)
514
+ }
515
+ ]
516
+ };
517
+ }
518
+ );
519
+ server2.tool(
520
+ "tap_enrich_contact",
521
+ "Trigger AI enrichment for a contact (returns enriched data)",
522
+ {
523
+ contact_id: z2.string().describe("Contact ID to enrich")
524
+ },
525
+ async ({ contact_id }) => {
526
+ const supabase = getAdminClient();
527
+ const wsId = getWorkspaceId();
528
+ const { data: contact, error: fetchError } = await supabase.from("tap_contacts").select("id, name, email, outlet").eq("id", contact_id).eq("workspace_id", wsId).single();
529
+ if (fetchError || !contact) {
530
+ return {
531
+ content: [
532
+ { type: "text", text: `Contact not found in workspace: ${contact_id}` }
533
+ ]
534
+ };
535
+ }
536
+ const { error: updateError } = await supabase.from("tap_contacts").update({ enrichment_source: "queued" }).eq("id", contact_id).eq("workspace_id", wsId);
537
+ if (updateError) {
538
+ return {
539
+ content: [
540
+ { type: "text", text: `Failed to queue enrichment: ${updateError.message}` }
541
+ ]
542
+ };
543
+ }
544
+ return {
545
+ content: [
546
+ {
547
+ type: "text",
548
+ text: JSON.stringify(
549
+ {
550
+ message: `Enrichment queued for ${contact.name}`,
551
+ contact: { id: contact.id, name: contact.name, email: contact.email }
552
+ },
553
+ null,
554
+ 2
555
+ )
556
+ }
557
+ ]
558
+ };
559
+ }
560
+ );
561
+ }
562
+
563
+ // src/tools/pitches.ts
564
+ import { z as z3 } from "zod";
565
+ function textResponse(text) {
566
+ return { content: [{ type: "text", text }] };
567
+ }
568
+ function registerPitchTools(server2) {
569
+ server2.tool(
570
+ "tap_draft_pitch",
571
+ "Generate a complete AI-drafted pitch for a contact within a campaign. Returns subject, body, and variants ready to use.",
572
+ {
573
+ campaign_id: z3.string().describe("Campaign ID"),
574
+ contact_id: z3.string().describe("Contact ID"),
575
+ tone: z3.enum(["professional", "casual", "enthusiastic"]).optional().describe("Pitch tone (default: professional)"),
576
+ key_hook: z3.string().optional().describe("Key hook or angle for the pitch")
577
+ },
578
+ async ({ campaign_id, contact_id, tone, key_hook }) => {
579
+ const supabase = getAdminClient();
580
+ const wsId = getWorkspaceId();
581
+ const { data: campaign, error: campaignError } = await supabase.from("tap_projects").select("name, artist_name, release_name, release_date, goal, workspace_id").eq("id", campaign_id).eq("workspace_id", wsId).single();
582
+ if (campaignError || !campaign) {
583
+ return textResponse(`Campaign not found in workspace: ${campaign_id}`);
584
+ }
585
+ const { data: contact, error: contactError } = await supabase.from("tap_contacts").select(
586
+ "name, email, outlet, role, genres, bbc_station, platform_type, enrichment_confidence, submission_guidelines, pitch_tips, best_timing, geographic_scope"
587
+ ).eq("id", contact_id).eq("workspace_id", wsId).single();
588
+ if (contactError || !contact) {
589
+ return textResponse(`Contact not found in workspace: ${contact_id}`);
590
+ }
591
+ const baseUrl = getTapUrl();
592
+ const resolvedTone = tone || "professional";
593
+ const resolvedHook = key_hook || campaign.goal || `New release from ${campaign.artist_name}`;
594
+ const cronSecret = process.env.TAP_CRON_SECRET || process.env.CRON_SECRET;
595
+ if (!cronSecret) {
596
+ return textResponse(
597
+ "TAP_CRON_SECRET is not configured. Cannot generate pitch via service auth. Set TAP_CRON_SECRET in MCP environment."
598
+ );
599
+ }
600
+ const generateBody = {
601
+ artistName: campaign.artist_name,
602
+ releaseName: campaign.release_name,
603
+ releaseDate: campaign.release_date,
604
+ contactName: contact.name,
605
+ keyHook: resolvedHook,
606
+ workspaceId: wsId,
607
+ campaignId: campaign_id,
608
+ tone: resolvedTone,
609
+ contactOutlet: contact.outlet,
610
+ contactRole: contact.role,
611
+ contactGenres: Array.isArray(contact.genres) ? contact.genres : void 0,
612
+ contactBbcStation: contact.bbc_station,
613
+ contactPlatformType: contact.platform_type,
614
+ contactSubmissionGuidelines: contact.submission_guidelines,
615
+ contactPitchTips: Array.isArray(contact.pitch_tips) ? contact.pitch_tips : void 0,
616
+ contactBestTiming: contact.best_timing,
617
+ contactGeographicScope: contact.geographic_scope
618
+ };
619
+ let draft;
620
+ try {
621
+ const res = await fetch(`${baseUrl}/api/pitch/generate`, {
622
+ method: "POST",
623
+ headers: {
624
+ "Content-Type": "application/json",
625
+ Authorization: `Bearer ${cronSecret}`
626
+ },
627
+ body: JSON.stringify(generateBody)
628
+ });
629
+ if (!res.ok) {
630
+ const errText = await res.text();
631
+ return textResponse(`Pitch generation failed (${res.status}): ${errText}`);
632
+ }
633
+ const json = await res.json();
634
+ draft = json.data ?? json;
635
+ } catch (err) {
636
+ return textResponse(
637
+ `Pitch generation request failed: ${err instanceof Error ? err.message : String(err)}`
638
+ );
639
+ }
640
+ return textResponse(
641
+ JSON.stringify(
642
+ {
643
+ campaign: {
644
+ id: campaign_id,
645
+ name: campaign.name,
646
+ artist: campaign.artist_name,
647
+ release: campaign.release_name
648
+ },
649
+ contact: {
650
+ id: contact_id,
651
+ name: contact.name,
652
+ outlet: contact.outlet,
653
+ role: contact.role
654
+ },
655
+ draft
656
+ },
657
+ null,
658
+ 2
659
+ )
660
+ );
661
+ }
662
+ );
663
+ }
664
+
665
+ // src/tools/outcomes.ts
666
+ import { z as z4 } from "zod";
667
+ function registerOutcomeTools(server2) {
668
+ server2.tool(
669
+ "tap_log_outcome",
670
+ "Log a campaign outcome for a contact",
671
+ {
672
+ campaign_id: z4.string().describe("Campaign ID"),
673
+ contact_id: z4.string().describe("Contact ID"),
674
+ outcome_type: z4.enum([
675
+ "no_response",
676
+ "opened",
677
+ "replied_positive",
678
+ "replied_negative",
679
+ "added_to_playlist",
680
+ "interview_booked",
681
+ "coverage",
682
+ "declined",
683
+ "other"
684
+ ]).describe("Outcome type"),
685
+ channel: z4.string().optional().describe("Channel (radio, press, playlist, social)"),
686
+ notes: z4.string().optional().describe("Additional notes")
687
+ },
688
+ async ({ campaign_id, contact_id, outcome_type, channel, notes }) => {
689
+ const supabase = getAdminClient();
690
+ const wsId = getWorkspaceId();
691
+ const { data: campaign, error: campaignError } = await supabase.from("tap_projects").select("id").eq("id", campaign_id).eq("workspace_id", wsId).single();
692
+ if (campaignError || !campaign) {
693
+ return {
694
+ content: [
695
+ { type: "text", text: `Campaign not found in workspace: ${campaign_id}` }
696
+ ]
697
+ };
698
+ }
699
+ const { data, error } = await supabase.from("tap_contact_outcomes").insert({
700
+ workspace_id: wsId,
701
+ project_id: campaign_id,
702
+ contact_id,
703
+ outcome_type,
704
+ channel: channel || null,
705
+ notes: notes || null,
706
+ occurred_at: (/* @__PURE__ */ new Date()).toISOString()
707
+ }).select("id").single();
708
+ if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
709
+ return {
710
+ content: [
711
+ {
712
+ type: "text",
713
+ text: JSON.stringify(
714
+ {
715
+ message: `Outcome "${outcome_type}" logged for campaign`,
716
+ outcomeId: data.id
717
+ },
718
+ null,
719
+ 2
720
+ )
721
+ }
722
+ ]
723
+ };
724
+ }
725
+ );
726
+ server2.tool(
727
+ "tap_get_action_queue",
728
+ "Get today's prioritised action queue (follow-ups, stale contacts, pending pitches)",
729
+ {},
730
+ async () => {
731
+ const supabase = getAdminClient();
732
+ const wsId = getWorkspaceId();
733
+ const { data: campaigns, error: campaignsError } = await supabase.from("tap_projects").select("id, name, artist_name, status").eq("workspace_id", wsId).eq("status", "active");
734
+ if (campaignsError) {
735
+ return { content: [{ type: "text", text: `Error: ${campaignsError.message}` }] };
736
+ }
737
+ if (!campaigns || campaigns.length === 0) {
738
+ return {
739
+ content: [
740
+ {
741
+ type: "text",
742
+ text: JSON.stringify({ actions: [], message: "No active campaigns" }, null, 2)
743
+ }
744
+ ]
745
+ };
746
+ }
747
+ const campaignIds = campaigns.map((c) => c.id);
748
+ const threeDaysAgo = /* @__PURE__ */ new Date();
749
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
750
+ const [followUpsResult, unenrichedResult] = await Promise.all([
751
+ supabase.from("campaign_contacts").select("contact_id, project_id, pitch_status, updated_at").in("project_id", campaignIds).eq("pitch_status", "sent").lt("updated_at", threeDaysAgo.toISOString()).limit(20),
752
+ supabase.from("tap_contacts").select("id", { count: "exact", head: true }).eq("workspace_id", wsId).is("enriched_at", null)
753
+ ]);
754
+ if (followUpsResult.error) {
755
+ return {
756
+ content: [
757
+ {
758
+ type: "text",
759
+ text: `Error fetching follow-ups: ${followUpsResult.error.message}`
760
+ }
761
+ ]
762
+ };
763
+ }
764
+ const pendingFollowUps = followUpsResult.data || [];
765
+ const unenrichedCount = unenrichedResult.count || 0;
766
+ const actions = [
767
+ ...pendingFollowUps.map((c) => ({
768
+ type: "follow_up",
769
+ priority: "high",
770
+ campaignId: c.project_id,
771
+ contactId: c.contact_id,
772
+ description: "Pitch sent > 3 days ago with no response"
773
+ })),
774
+ ...unenrichedCount > 0 ? [
775
+ {
776
+ type: "enrichment",
777
+ priority: "medium",
778
+ description: `${unenrichedCount} contacts need enrichment`
779
+ }
780
+ ] : []
781
+ ];
782
+ return {
783
+ content: [
784
+ {
785
+ type: "text",
786
+ text: JSON.stringify(
787
+ {
788
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
789
+ activeCampaigns: campaigns.length,
790
+ actions
791
+ },
792
+ null,
793
+ 2
794
+ )
795
+ }
796
+ ]
797
+ };
798
+ }
799
+ );
800
+ }
801
+
802
+ // src/resources/index.ts
803
+ function registerResources(server2) {
804
+ server2.resource("tap-campaigns", "tap://campaigns", async () => {
805
+ const supabase = getAdminClient();
806
+ const wsId = getWorkspaceId();
807
+ const { data, error } = await supabase.from("tap_projects").select("id, name, artist_name, status, release_name, created_at").eq("workspace_id", wsId).order("created_at", { ascending: false }).limit(100);
808
+ if (error) {
809
+ return {
810
+ contents: [
811
+ {
812
+ uri: "tap://campaigns",
813
+ mimeType: "application/json",
814
+ text: JSON.stringify({ error: error.message }, null, 2)
815
+ }
816
+ ]
817
+ };
818
+ }
819
+ return {
820
+ contents: [
821
+ {
822
+ uri: "tap://campaigns",
823
+ mimeType: "application/json",
824
+ text: JSON.stringify(data || [], null, 2)
825
+ }
826
+ ]
827
+ };
828
+ });
829
+ server2.resource("tap-contacts", "tap://contacts", async () => {
830
+ const supabase = getAdminClient();
831
+ const wsId = getWorkspaceId();
832
+ const { data, count, error } = await supabase.from("tap_contacts").select("id, name, email, outlet, role, pipeline_status, enrichment_confidence", {
833
+ count: "exact"
834
+ }).eq("workspace_id", wsId).order("name", { ascending: true }).limit(100);
835
+ if (error) {
836
+ return {
837
+ contents: [
838
+ {
839
+ uri: "tap://contacts",
840
+ mimeType: "application/json",
841
+ text: JSON.stringify({ error: error.message }, null, 2)
842
+ }
843
+ ]
844
+ };
845
+ }
846
+ return {
847
+ contents: [
848
+ {
849
+ uri: "tap://contacts",
850
+ mimeType: "application/json",
851
+ text: JSON.stringify({ contacts: data || [], total: count || 0 }, null, 2)
852
+ }
853
+ ]
854
+ };
855
+ });
856
+ server2.resource("tap-artists", "tap://artists", async () => {
857
+ const supabase = getAdminClient();
858
+ const wsId = getWorkspaceId();
859
+ const { data, error } = await supabase.from("tap_artists").select("id, name, spotify_id, genres, created_at").eq("workspace_id", wsId).order("name", { ascending: true }).limit(100);
860
+ if (error) {
861
+ return {
862
+ contents: [
863
+ {
864
+ uri: "tap://artists",
865
+ mimeType: "application/json",
866
+ text: JSON.stringify({ error: error.message }, null, 2)
867
+ }
868
+ ]
869
+ };
870
+ }
871
+ return {
872
+ contents: [
873
+ {
874
+ uri: "tap://artists",
875
+ mimeType: "application/json",
876
+ text: JSON.stringify(data || [], null, 2)
877
+ }
878
+ ]
879
+ };
880
+ });
881
+ }
882
+
883
+ // src/index.ts
884
+ var server = new McpServer({
885
+ name: "tap",
886
+ version: "0.1.0"
887
+ });
888
+ registerCampaignTools(server);
889
+ registerContactTools(server);
890
+ registerPitchTools(server);
891
+ registerOutcomeTools(server);
892
+ registerResources(server);
893
+ var transport = new StdioServerTransport();
894
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@totalaudiopromo/tap-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Total Audio Promo — music PR campaign management via Model Context Protocol. 10 tools + 3 resources for contacts, campaigns, pitches, and outcomes.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "tap-mcp": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/totalaudiopromo/total-audio-platform",
21
+ "directory": "packages/tap-mcp"
22
+ },
23
+ "homepage": "https://totalaudiopromo.com/developers",
24
+ "keywords": [
25
+ "mcp",
26
+ "model-context-protocol",
27
+ "music-pr",
28
+ "music-promotion",
29
+ "ai-agent",
30
+ "contacts",
31
+ "campaigns",
32
+ "enrichment",
33
+ "total-audio-promo"
34
+ ],
35
+ "license": "MIT",
36
+ "scripts": {
37
+ "build": "tsup src/index.ts --format esm --dts --clean",
38
+ "dev": "tsx src/index.ts",
39
+ "typecheck": "tsc --noEmit",
40
+ "prepublishOnly": "pnpm build"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.28.0",
44
+ "@supabase/supabase-js": "^2.100.1",
45
+ "zod": "^3.24.2"
46
+ },
47
+ "devDependencies": {
48
+ "tsup": "^8.4.0",
49
+ "tsx": "^4.19.4",
50
+ "typescript": "^5.9.3"
51
+ }
52
+ }