leadslokal-mcp 1.0.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.
Files changed (3) hide show
  1. package/README.md +79 -0
  2. package/dist/index.js +418 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # LeadsLokal MCP Server
2
+
3
+ MCP server for LeadsLokal — search, score, and pitch local business leads.
4
+
5
+ ## Tools
6
+
7
+ | Tool | Description |
8
+ |------|-------------|
9
+ | `search_leads` | Search businesses by category, state, and filters (no_website, no_socials, no_booking, has_phone) |
10
+ | `get_lead_detail` | Fetch full profile by business_id or name + location |
11
+ | `score_opportunity` | Score a lead 0–100 based on gaps and strengths, returns pitch angle |
12
+ | `get_pitch_context` | Personalized pitch hook for website / booking / social / seo |
13
+
14
+ ## Getting an API key
15
+
16
+ Log in to your LeadsLokal account → **Settings → API Keys → Generate Key**.
17
+
18
+ Your key starts with `sk-ll-`. Copy it — it's shown only once.
19
+
20
+ ---
21
+
22
+ ## Option A — Remote HTTP (no install required)
23
+
24
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "leadslokal": {
30
+ "type": "http",
31
+ "url": "https://leadslokal.com/api/mcp",
32
+ "headers": {
33
+ "Authorization": "Bearer YOUR_API_KEY_HERE"
34
+ }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Option B — Local npm (run via npx)
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "leadslokal": {
48
+ "command": "npx",
49
+ "args": ["-y", "leadslokal-mcp"],
50
+ "env": {
51
+ "LEADSLOKAL_API_KEY": "YOUR_API_KEY_HERE"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ### Local setup
59
+
60
+ ```bash
61
+ npm install -g leadslokal-mcp
62
+ # or run directly: npx leadslokal-mcp
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Example agent workflow
68
+
69
+ ```
70
+ 1. search_leads(category="plumbing", state="CA", filters=["no_website"], min_rating=4.0)
71
+ → returns 127 leads
72
+
73
+ 2. score_opportunity(lead) for each
74
+ → top 20 high-intent leads
75
+
76
+ 3. get_pitch_context(lead, service_type="website")
77
+ → "Pacific Flow Services — 4.7★ · 89 reviews, no website = high-value prospect (75/100)"
78
+ → "Customers are searching but there's no website to land on..."
79
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ // ─── Config ──────────────────────────────────────────────────────────────────
6
+ const BASE_URL = (process.env.LEADSLOKAL_BASE_URL ?? "https://leadslokal.com").replace(/\/$/, "");
7
+ const API_KEY = process.env.LEADSLOKAL_API_KEY ?? "";
8
+ // ─── API helper ──────────────────────────────────────────────────────────────
9
+ async function fetchLeads(path) {
10
+ const headers = {
11
+ "Content-Type": "application/json",
12
+ Accept: "application/json",
13
+ };
14
+ if (API_KEY)
15
+ headers["Authorization"] = `Bearer ${API_KEY}`;
16
+ const res = await fetch(`${BASE_URL}${path}`, { headers });
17
+ if (!res.ok) {
18
+ const text = await res.text().catch(() => "");
19
+ throw new Error(`API error ${res.status}: ${text.slice(0, 200)}`);
20
+ }
21
+ return res.json();
22
+ }
23
+ // ─── Scoring ─────────────────────────────────────────────────────────────────
24
+ const BOOKING_CATEGORIES = [
25
+ "restaurant", "salon", "spa", "nail", "barbershop", "barber",
26
+ "gym", "yoga", "fitness", "dental", "dentist", "doctor", "medical",
27
+ "veterinary", "vet", "beauty", "massage", "chiropractor", "therapist",
28
+ ];
29
+ function needsBooking(category) {
30
+ const lower = category.toLowerCase();
31
+ return BOOKING_CATEGORIES.some((c) => lower.includes(c));
32
+ }
33
+ function isLocked(value) {
34
+ if (typeof value === "string")
35
+ return value === "__locked__";
36
+ if (Array.isArray(value))
37
+ return value.length > 0 && value[0] === "__locked__";
38
+ return false;
39
+ }
40
+ function scoreOpportunity(lead) {
41
+ let score = 0;
42
+ const tags = [];
43
+ const gaps = [];
44
+ const hasWebsite = Boolean(lead.website) && !isLocked(lead.website);
45
+ const hasSocials = lead.socials.length > 0 &&
46
+ !lead.socials.some((s) => s.platform === "__locked__");
47
+ const hasBooking = lead.bookingApps.length > 0 && !isLocked(lead.bookingApps);
48
+ const rating = lead.reviewRating ?? 0;
49
+ const reviews = lead.reviewCount ?? 0;
50
+ const hasPhone = lead.phones.length > 0 && !isLocked(lead.phones);
51
+ // Website gap — biggest revenue opportunity
52
+ if (!hasWebsite) {
53
+ score += 35;
54
+ tags.push("no_website");
55
+ gaps.push("no website");
56
+ }
57
+ // Social gap
58
+ if (!hasSocials) {
59
+ score += 20;
60
+ tags.push("no_socials");
61
+ gaps.push("no social media");
62
+ }
63
+ // Booking gap — only relevant for appointment-based businesses
64
+ if (!hasBooking && needsBooking(lead.category)) {
65
+ score += 20;
66
+ tags.push("no_booking");
67
+ gaps.push("no online booking");
68
+ }
69
+ // Rating quality — proof of good service makes pitching easier
70
+ if (rating >= 4.5) {
71
+ score += 15;
72
+ tags.push("excellent_rating");
73
+ }
74
+ else if (rating >= 4.0) {
75
+ score += 10;
76
+ tags.push("good_rating");
77
+ }
78
+ else if (rating >= 3.5) {
79
+ score += 4;
80
+ }
81
+ // Review volume — indicates active customer base
82
+ if (reviews >= 200) {
83
+ score += 10;
84
+ tags.push("high_volume");
85
+ }
86
+ else if (reviews >= 100) {
87
+ score += 7;
88
+ }
89
+ else if (reviews >= 50) {
90
+ score += 4;
91
+ }
92
+ // Reachable by phone
93
+ if (hasPhone) {
94
+ score += 5;
95
+ tags.push("has_phone");
96
+ }
97
+ score = Math.min(score, 100);
98
+ const label = score >= 65 ? "high" : score >= 35 ? "medium" : "low";
99
+ // Build a human-readable pitch angle
100
+ const metricParts = [];
101
+ if (rating > 0 && reviews > 0) {
102
+ metricParts.push(`${rating}★ · ${reviews} reviews`);
103
+ }
104
+ metricParts.push(...gaps);
105
+ const pitchAngle = metricParts.length > 0
106
+ ? `${lead.name} — ${metricParts.join(", ")} = ${label}-value prospect (${score}/100)`
107
+ : `${lead.name} — score ${score}/100`;
108
+ return { score, label, pitchAngle, opportunityTags: tags, gaps };
109
+ }
110
+ const SERVICE_LABELS = {
111
+ website: "website design & development",
112
+ booking: "online booking & scheduling",
113
+ social: "social media management",
114
+ seo: "local SEO & Google presence",
115
+ };
116
+ function getPitchContext(lead, service) {
117
+ const rating = lead.reviewRating ?? 0;
118
+ const reviews = lead.reviewCount ?? 0;
119
+ const category = lead.category;
120
+ const location = lead.location;
121
+ const { gaps } = scoreOpportunity(lead);
122
+ const lines = [];
123
+ lines.push(`### Pitch hook for ${lead.name}`);
124
+ lines.push(`**Service:** ${SERVICE_LABELS[service]}`);
125
+ lines.push(`**Category:** ${category} — ${location}`);
126
+ if (rating > 0) {
127
+ lines.push(`**Social proof:** ${rating}★ average across ${reviews} reviews`);
128
+ }
129
+ lines.push("");
130
+ lines.push("**Opening angle:**");
131
+ switch (service) {
132
+ case "website": {
133
+ if (!lead.website || isLocked(lead.website)) {
134
+ if (reviews >= 100) {
135
+ lines.push(`"${lead.name} has ${reviews} Google reviews and a ${rating}★ rating — customers are clearly searching for you online, but there's no website to send them to. Every competitor with a site is capturing those clicks."`);
136
+ }
137
+ else {
138
+ lines.push(`"${lead.name} has strong reviews but no website — customers looking you up after a recommendation have nowhere to land. A simple site would immediately convert that search traffic."`);
139
+ }
140
+ lines.push("");
141
+ lines.push("**Why they'll listen:** Their Google Business profile is doing the heavy lifting. A website funnels those searches into actual leads.");
142
+ }
143
+ else {
144
+ lines.push(`"${lead.name} already has a web presence — pitch a redesign, SEO audit, or conversion optimization. Lead with their Google rating as proof the demand is there."`);
145
+ }
146
+ break;
147
+ }
148
+ case "booking": {
149
+ if (!lead.bookingApps.length || isLocked(lead.bookingApps)) {
150
+ lines.push(`"${lead.name} is a ${category} with ${reviews} reviews but no way to book online. Customers calling to schedule is friction that costs them business — especially evenings and weekends when the phone is unattended."`);
151
+ lines.push("");
152
+ lines.push("**Platform angle:** Suggest a tool that matches their category:");
153
+ if (category.toLowerCase().includes("restaurant")) {
154
+ lines.push("- OpenTable or Resy for reservations");
155
+ lines.push("- Toast or Square for online ordering");
156
+ }
157
+ else if (["salon", "spa", "nail", "barber", "beauty", "massage"].some((c) => category.toLowerCase().includes(c))) {
158
+ lines.push("- Booksy, Vagaro, or Fresha — industry-standard booking");
159
+ }
160
+ else if (["gym", "yoga", "fitness"].some((c) => category.toLowerCase().includes(c))) {
161
+ lines.push("- Mindbody or Glofox — class scheduling + memberships");
162
+ }
163
+ else {
164
+ lines.push("- Acuity Scheduling or Calendly — works for any appointment business");
165
+ }
166
+ }
167
+ else {
168
+ lines.push(`"${lead.name} already uses ${lead.bookingApps.join(", ")}. Pitch additional integrations: email reminders, rebooking campaigns, or a loyalty program."`);
169
+ }
170
+ break;
171
+ }
172
+ case "social": {
173
+ if (!lead.socials.length || lead.socials.some((s) => s.platform === "__locked__")) {
174
+ lines.push(`"${lead.name} has ${reviews} happy customers but zero social presence. Their reviews mention real experiences that could become content — before/afters, testimonials, daily specials. They're leaving audience growth on the table."`);
175
+ lines.push("");
176
+ lines.push("**Content angle:** Pull from their review text to show what content would resonate:");
177
+ lines.push(`- Turn their top reviews into Instagram / Facebook posts`);
178
+ lines.push(`- ${category} lends itself to visual content — photos and short-form video`);
179
+ }
180
+ else {
181
+ const platforms = lead.socials.map((s) => s.platform).filter((p) => p !== "__locked__");
182
+ lines.push(`"${lead.name} is on ${platforms.join(", ")}. Pitch a growth retainer: content calendar, paid social ads, or engagement management."`);
183
+ }
184
+ break;
185
+ }
186
+ case "seo": {
187
+ lines.push(`"${lead.name} has ${reviews} reviews and a ${rating}★ rating — that's strong social proof Google rewards. With targeted local SEO, you can push them to the top of '${category} near me' searches in ${location}."`);
188
+ lines.push("");
189
+ lines.push("**Keyword opportunity:** target searches like:");
190
+ lines.push(`- "${category} in ${location}"`);
191
+ lines.push(`- "best ${category} near me"`);
192
+ lines.push(`- "${category} open now ${location}"`);
193
+ break;
194
+ }
195
+ }
196
+ lines.push("");
197
+ lines.push("**Identified gaps:** " + (gaps.length > 0 ? gaps.join(", ") : "none detected"));
198
+ if (lead.phones.length > 0 && !isLocked(lead.phones)) {
199
+ lines.push(`**Phone:** ${lead.phones[0]}`);
200
+ }
201
+ if (lead.mapsLink) {
202
+ lines.push(`**Google Maps:** ${lead.mapsLink}`);
203
+ }
204
+ return lines.join("\n");
205
+ }
206
+ // ─── MCP Server ──────────────────────────────────────────────────────────────
207
+ const server = new McpServer({
208
+ name: "leadslokal",
209
+ version: "1.0.0",
210
+ });
211
+ // Tool 1: search_leads
212
+ server.tool("search_leads", "Search for enriched local business leads with filters. Returns a list of businesses with contact info, social presence, booking platforms, tech stack, and review metrics.", {
213
+ category: z.string().describe("Business category to search (e.g. 'plumbing', 'restaurant', 'salon')"),
214
+ state: z
215
+ .string()
216
+ .describe("US state abbreviation or name (e.g. 'CA', 'Texas'). Required."),
217
+ city: z
218
+ .string()
219
+ .optional()
220
+ .describe("City to narrow the search (applied as a keyword filter)"),
221
+ filters: z
222
+ .array(z.enum(["no_website", "no_socials", "no_booking", "has_phone"]))
223
+ .optional()
224
+ .describe("Opportunity filters: no_website, no_socials, no_booking, has_phone"),
225
+ min_rating: z
226
+ .number()
227
+ .min(0)
228
+ .max(5)
229
+ .optional()
230
+ .describe("Minimum Google review rating (0-5)"),
231
+ page: z.number().int().min(1).optional().default(1).describe("Page number"),
232
+ per_page: z
233
+ .number()
234
+ .int()
235
+ .min(1)
236
+ .max(100)
237
+ .optional()
238
+ .default(20)
239
+ .describe("Results per page (max 100)"),
240
+ }, async ({ category, state, city, filters, min_rating, page, per_page }) => {
241
+ const params = new URLSearchParams();
242
+ if (category)
243
+ params.append("category", category);
244
+ if (state)
245
+ params.append("state", state);
246
+ if (city)
247
+ params.append("q", city);
248
+ // Map filters to API params
249
+ const filterSet = new Set(filters ?? []);
250
+ if (filterSet.has("no_website"))
251
+ params.set("website", "without");
252
+ if (filterSet.has("no_socials"))
253
+ params.set("socials", "without");
254
+ if (filterSet.has("no_booking"))
255
+ params.set("booking", "without");
256
+ params.set("page", String(page ?? 1));
257
+ params.set("perPage", String(per_page ?? 20));
258
+ const data = await fetchLeads(`/api/businesses?${params.toString()}`);
259
+ let leads = data.rows;
260
+ // Client-side min_rating filter (API doesn't support this natively)
261
+ if (min_rating != null) {
262
+ leads = leads.filter((r) => (r.reviewRating ?? 0) >= min_rating);
263
+ }
264
+ // If has_phone filter requested, filter client-side
265
+ if (filterSet.has("has_phone")) {
266
+ leads = leads.filter((r) => r.phones.length > 0 && !isLocked(r.phones));
267
+ }
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text",
272
+ text: JSON.stringify({
273
+ leads,
274
+ total: data.pagination.total,
275
+ returned: leads.length,
276
+ page: data.pagination.page,
277
+ totalPages: data.pagination.totalPages,
278
+ analytics: data.analytics,
279
+ }, null, 2),
280
+ },
281
+ ],
282
+ };
283
+ });
284
+ // Tool 2: get_lead_detail
285
+ server.tool("get_lead_detail", "Get the full profile for a specific business lead, including all contact info, social links, booking platforms, tech stack, and review metrics.", {
286
+ business_id: z
287
+ .string()
288
+ .optional()
289
+ .describe("Business ID from a previous search_leads result"),
290
+ name: z
291
+ .string()
292
+ .optional()
293
+ .describe("Business name (used if business_id is not available)"),
294
+ location: z
295
+ .string()
296
+ .optional()
297
+ .describe("City, state, or address to narrow search by name"),
298
+ }, async ({ business_id, name, location }) => {
299
+ if (!business_id && !name) {
300
+ return {
301
+ content: [
302
+ {
303
+ type: "text",
304
+ text: "Error: provide either business_id or name.",
305
+ },
306
+ ],
307
+ isError: true,
308
+ };
309
+ }
310
+ const params = new URLSearchParams();
311
+ params.set("perPage", "5");
312
+ if (name)
313
+ params.set("q", name);
314
+ if (location)
315
+ params.append("q", location);
316
+ const data = await fetchLeads(`/api/businesses?${params.toString()}`);
317
+ let match;
318
+ if (business_id) {
319
+ match = data.rows.find((r) => r.id === business_id);
320
+ }
321
+ if (!match && name) {
322
+ const nameLower = name.toLowerCase();
323
+ match =
324
+ data.rows.find((r) => r.name.toLowerCase() === nameLower) ??
325
+ data.rows.find((r) => r.name.toLowerCase().includes(nameLower)) ??
326
+ data.rows[0];
327
+ }
328
+ if (!match) {
329
+ return {
330
+ content: [
331
+ {
332
+ type: "text",
333
+ text: `No business found matching "${name ?? business_id}".`,
334
+ },
335
+ ],
336
+ isError: true,
337
+ };
338
+ }
339
+ const scored = scoreOpportunity(match);
340
+ return {
341
+ content: [
342
+ {
343
+ type: "text",
344
+ text: JSON.stringify({
345
+ lead: match,
346
+ opportunityScore: scored.score,
347
+ opportunityLabel: scored.label,
348
+ opportunityTags: scored.opportunityTags,
349
+ gaps: scored.gaps,
350
+ }, null, 2),
351
+ },
352
+ ],
353
+ };
354
+ });
355
+ // Tool 3: score_opportunity
356
+ server.tool("score_opportunity", "Score a lead's opportunity from 0–100 based on their gaps (no website, no socials, no booking) and strengths (rating, review count, phone). Returns score, label (high/medium/low), pitch angle, and opportunity tags.", {
357
+ lead: z
358
+ .object({
359
+ id: z.string(),
360
+ name: z.string(),
361
+ category: z.string(),
362
+ location: z.string(),
363
+ phones: z.array(z.string()),
364
+ website: z.string().nullable(),
365
+ mapsLink: z.string().nullable(),
366
+ bookingApps: z.array(z.string()),
367
+ techStack: z.array(z.string()),
368
+ socials: z.array(z.object({ platform: z.string(), url: z.string() })),
369
+ reviewRating: z.number().nullable(),
370
+ reviewCount: z.number(),
371
+ })
372
+ .describe("A lead object from search_leads or get_lead_detail"),
373
+ }, async ({ lead }) => {
374
+ const result = scoreOpportunity(lead);
375
+ return {
376
+ content: [
377
+ {
378
+ type: "text",
379
+ text: JSON.stringify(result, null, 2),
380
+ },
381
+ ],
382
+ };
383
+ });
384
+ // Tool 4: get_pitch_context
385
+ server.tool("get_pitch_context", "Generate a personalized pitch hook for a specific service based on the lead's gaps, category, rating, and reviews. Returns an opening angle, platform recommendations, and reachability info.", {
386
+ lead: z
387
+ .object({
388
+ id: z.string(),
389
+ name: z.string(),
390
+ category: z.string(),
391
+ location: z.string(),
392
+ phones: z.array(z.string()),
393
+ website: z.string().nullable(),
394
+ mapsLink: z.string().nullable(),
395
+ bookingApps: z.array(z.string()),
396
+ techStack: z.array(z.string()),
397
+ socials: z.array(z.object({ platform: z.string(), url: z.string() })),
398
+ reviewRating: z.number().nullable(),
399
+ reviewCount: z.number(),
400
+ })
401
+ .describe("A lead object from search_leads or get_lead_detail"),
402
+ service_type: z
403
+ .enum(["website", "booking", "social", "seo"])
404
+ .describe("The service you are pitching: website (design/dev), booking (online scheduling), social (social media management), seo (local SEO)"),
405
+ }, async ({ lead, service_type }) => {
406
+ const pitchContext = getPitchContext(lead, service_type);
407
+ return {
408
+ content: [
409
+ {
410
+ type: "text",
411
+ text: pitchContext,
412
+ },
413
+ ],
414
+ };
415
+ });
416
+ // ─── Start ────────────────────────────────────────────────────────────────────
417
+ const transport = new StdioServerTransport();
418
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "leadslokal-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for LeadsLokal — search, score, and pitch local business leads",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "leadslokal-mcp": "dist/index.js"
9
+ },
10
+ "files": ["dist", "README.md"],
11
+ "engines": { "node": ">=20" },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "start": "node dist/index.js",
15
+ "dev": "tsx src/index.ts"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.12.0",
19
+ "zod": "^3.24.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20",
23
+ "tsx": "^4",
24
+ "typescript": "^5"
25
+ }
26
+ }