domain-search-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 (151) hide show
  1. package/.env.example +52 -0
  2. package/Dockerfile +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +426 -0
  5. package/SECURITY.md +252 -0
  6. package/dist/config.d.ts +25 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +117 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/fallbacks/index.d.ts +6 -0
  11. package/dist/fallbacks/index.d.ts.map +1 -0
  12. package/dist/fallbacks/index.js +14 -0
  13. package/dist/fallbacks/index.js.map +1 -0
  14. package/dist/fallbacks/rdap.d.ts +18 -0
  15. package/dist/fallbacks/rdap.d.ts.map +1 -0
  16. package/dist/fallbacks/rdap.js +339 -0
  17. package/dist/fallbacks/rdap.js.map +1 -0
  18. package/dist/fallbacks/whois.d.ts +27 -0
  19. package/dist/fallbacks/whois.d.ts.map +1 -0
  20. package/dist/fallbacks/whois.js +219 -0
  21. package/dist/fallbacks/whois.js.map +1 -0
  22. package/dist/registrars/base.d.ts +89 -0
  23. package/dist/registrars/base.d.ts.map +1 -0
  24. package/dist/registrars/base.js +203 -0
  25. package/dist/registrars/base.js.map +1 -0
  26. package/dist/registrars/index.d.ts +7 -0
  27. package/dist/registrars/index.d.ts.map +1 -0
  28. package/dist/registrars/index.js +15 -0
  29. package/dist/registrars/index.js.map +1 -0
  30. package/dist/registrars/namecheap.d.ts +69 -0
  31. package/dist/registrars/namecheap.d.ts.map +1 -0
  32. package/dist/registrars/namecheap.js +307 -0
  33. package/dist/registrars/namecheap.js.map +1 -0
  34. package/dist/registrars/porkbun.d.ts +63 -0
  35. package/dist/registrars/porkbun.d.ts.map +1 -0
  36. package/dist/registrars/porkbun.js +299 -0
  37. package/dist/registrars/porkbun.js.map +1 -0
  38. package/dist/server.d.ts +19 -0
  39. package/dist/server.d.ts.map +1 -0
  40. package/dist/server.js +209 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/services/domain-search.d.ts +40 -0
  43. package/dist/services/domain-search.d.ts.map +1 -0
  44. package/dist/services/domain-search.js +438 -0
  45. package/dist/services/domain-search.js.map +1 -0
  46. package/dist/services/index.d.ts +5 -0
  47. package/dist/services/index.d.ts.map +1 -0
  48. package/dist/services/index.js +11 -0
  49. package/dist/services/index.js.map +1 -0
  50. package/dist/tools/bulk_search.d.ts +72 -0
  51. package/dist/tools/bulk_search.d.ts.map +1 -0
  52. package/dist/tools/bulk_search.js +108 -0
  53. package/dist/tools/bulk_search.js.map +1 -0
  54. package/dist/tools/check_socials.d.ts +71 -0
  55. package/dist/tools/check_socials.d.ts.map +1 -0
  56. package/dist/tools/check_socials.js +357 -0
  57. package/dist/tools/check_socials.js.map +1 -0
  58. package/dist/tools/compare_registrars.d.ts +80 -0
  59. package/dist/tools/compare_registrars.d.ts.map +1 -0
  60. package/dist/tools/compare_registrars.js +116 -0
  61. package/dist/tools/compare_registrars.js.map +1 -0
  62. package/dist/tools/index.d.ts +10 -0
  63. package/dist/tools/index.d.ts.map +1 -0
  64. package/dist/tools/index.js +31 -0
  65. package/dist/tools/index.js.map +1 -0
  66. package/dist/tools/search_domain.d.ts +61 -0
  67. package/dist/tools/search_domain.d.ts.map +1 -0
  68. package/dist/tools/search_domain.js +81 -0
  69. package/dist/tools/search_domain.js.map +1 -0
  70. package/dist/tools/suggest_domains.d.ts +82 -0
  71. package/dist/tools/suggest_domains.d.ts.map +1 -0
  72. package/dist/tools/suggest_domains.js +227 -0
  73. package/dist/tools/suggest_domains.js.map +1 -0
  74. package/dist/tools/tld_info.d.ts +56 -0
  75. package/dist/tools/tld_info.d.ts.map +1 -0
  76. package/dist/tools/tld_info.js +273 -0
  77. package/dist/tools/tld_info.js.map +1 -0
  78. package/dist/types.d.ts +193 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +9 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/utils/cache.d.ts +81 -0
  83. package/dist/utils/cache.d.ts.map +1 -0
  84. package/dist/utils/cache.js +192 -0
  85. package/dist/utils/cache.js.map +1 -0
  86. package/dist/utils/errors.d.ts +87 -0
  87. package/dist/utils/errors.d.ts.map +1 -0
  88. package/dist/utils/errors.js +191 -0
  89. package/dist/utils/errors.js.map +1 -0
  90. package/dist/utils/index.d.ts +8 -0
  91. package/dist/utils/index.d.ts.map +1 -0
  92. package/dist/utils/index.js +24 -0
  93. package/dist/utils/index.js.map +1 -0
  94. package/dist/utils/logger.d.ts +27 -0
  95. package/dist/utils/logger.d.ts.map +1 -0
  96. package/dist/utils/logger.js +132 -0
  97. package/dist/utils/logger.js.map +1 -0
  98. package/dist/utils/premium-analyzer.d.ts +33 -0
  99. package/dist/utils/premium-analyzer.d.ts.map +1 -0
  100. package/dist/utils/premium-analyzer.js +273 -0
  101. package/dist/utils/premium-analyzer.js.map +1 -0
  102. package/dist/utils/validators.d.ts +53 -0
  103. package/dist/utils/validators.d.ts.map +1 -0
  104. package/dist/utils/validators.js +159 -0
  105. package/dist/utils/validators.js.map +1 -0
  106. package/docs/marketing/devto-post.md +135 -0
  107. package/docs/marketing/hackernews.md +42 -0
  108. package/docs/marketing/producthunt.md +109 -0
  109. package/docs/marketing/reddit-post.md +59 -0
  110. package/docs/marketing/twitter-thread.md +105 -0
  111. package/examples/bulk-search-50-domains.ts +131 -0
  112. package/examples/cli-interactive.ts +280 -0
  113. package/examples/compare-registrars.ts +78 -0
  114. package/examples/search-single-domain.ts +54 -0
  115. package/examples/suggest-names.ts +110 -0
  116. package/glama.json +6 -0
  117. package/jest.config.js +35 -0
  118. package/package.json +62 -0
  119. package/smithery.yaml +36 -0
  120. package/src/config.ts +121 -0
  121. package/src/fallbacks/index.ts +6 -0
  122. package/src/fallbacks/rdap.ts +407 -0
  123. package/src/fallbacks/whois.ts +250 -0
  124. package/src/registrars/base.ts +264 -0
  125. package/src/registrars/index.ts +7 -0
  126. package/src/registrars/namecheap.ts +378 -0
  127. package/src/registrars/porkbun.ts +380 -0
  128. package/src/server.ts +276 -0
  129. package/src/services/domain-search.ts +567 -0
  130. package/src/services/index.ts +9 -0
  131. package/src/tools/bulk_search.ts +142 -0
  132. package/src/tools/check_socials.ts +467 -0
  133. package/src/tools/compare_registrars.ts +162 -0
  134. package/src/tools/index.ts +45 -0
  135. package/src/tools/search_domain.ts +93 -0
  136. package/src/tools/suggest_domains.ts +284 -0
  137. package/src/tools/tld_info.ts +294 -0
  138. package/src/types.ts +289 -0
  139. package/src/utils/cache.ts +238 -0
  140. package/src/utils/errors.ts +262 -0
  141. package/src/utils/index.ts +8 -0
  142. package/src/utils/logger.ts +162 -0
  143. package/src/utils/premium-analyzer.ts +303 -0
  144. package/src/utils/validators.ts +193 -0
  145. package/tests/premium-analyzer.test.ts +310 -0
  146. package/tests/unit/cache.test.ts +123 -0
  147. package/tests/unit/errors.test.ts +190 -0
  148. package/tests/unit/tld-info.test.ts +62 -0
  149. package/tests/unit/tools.test.ts +200 -0
  150. package/tests/unit/validators.test.ts +146 -0
  151. package/tsconfig.json +25 -0
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Domain Search Service.
3
+ *
4
+ * Orchestrates domain availability checks across multiple sources:
5
+ * 1. Porkbun (primary, if configured)
6
+ * 2. Namecheap (secondary, if configured)
7
+ * 3. RDAP (fallback, always available)
8
+ * 4. WHOIS (last resort, always available)
9
+ *
10
+ * Handles:
11
+ * - Smart source selection based on availability and configuration
12
+ * - Graceful fallback on failures
13
+ * - Caching for performance
14
+ * - Insights generation for vibecoding UX
15
+ */
16
+
17
+ import type { DomainResult, SearchResponse, DataSource } from '../types.js';
18
+ import { config } from '../config.js';
19
+ import { logger } from '../utils/logger.js';
20
+ import {
21
+ NoSourceAvailableError,
22
+ wrapError,
23
+ DomainSearchError,
24
+ } from '../utils/errors.js';
25
+ import {
26
+ validateDomainName,
27
+ validateTlds,
28
+ buildDomain,
29
+ } from '../utils/validators.js';
30
+ import { domainCache, domainCacheKey, getOrCompute } from '../utils/cache.js';
31
+ import { porkbunAdapter, namecheapAdapter } from '../registrars/index.js';
32
+ import { checkRdap, isRdapAvailable } from '../fallbacks/rdap.js';
33
+ import { checkWhois, isWhoisAvailable } from '../fallbacks/whois.js';
34
+ import {
35
+ generatePremiumInsight,
36
+ generatePremiumSummary,
37
+ calculateDomainScore,
38
+ analyzePremiumReason,
39
+ suggestPremiumAlternatives,
40
+ } from '../utils/premium-analyzer.js';
41
+
42
+ /**
43
+ * Search for domain availability across multiple TLDs.
44
+ */
45
+ export async function searchDomain(
46
+ domainName: string,
47
+ tlds: string[] = ['com', 'io', 'dev'],
48
+ preferredRegistrars?: string[],
49
+ ): Promise<SearchResponse> {
50
+ const startTime = Date.now();
51
+ const normalizedDomain = validateDomainName(domainName);
52
+ const normalizedTlds = validateTlds(tlds);
53
+
54
+ logger.info('Domain search started', {
55
+ domain: normalizedDomain,
56
+ tlds: normalizedTlds,
57
+ });
58
+
59
+ // Search each TLD
60
+ const results: DomainResult[] = [];
61
+ const errors: string[] = [];
62
+ let fromCache = false;
63
+
64
+ // Run TLD checks in parallel
65
+ const promises = normalizedTlds.map(async (tld) => {
66
+ try {
67
+ const result = await searchSingleDomain(
68
+ normalizedDomain,
69
+ tld,
70
+ preferredRegistrars,
71
+ );
72
+ if (result.fromCache) fromCache = true;
73
+ return { success: true as const, tld, result: result.result };
74
+ } catch (error) {
75
+ const wrapped = wrapError(error);
76
+ return { success: false as const, tld, error: wrapped };
77
+ }
78
+ });
79
+
80
+ const outcomes = await Promise.all(promises);
81
+
82
+ for (const outcome of outcomes) {
83
+ if (outcome.success) {
84
+ results.push(outcome.result);
85
+ } else {
86
+ errors.push(`${outcome.tld}: ${outcome.error.userMessage}`);
87
+ logger.warn(`Failed to check .${outcome.tld}`, {
88
+ domain: normalizedDomain,
89
+ error: outcome.error.message,
90
+ });
91
+ }
92
+ }
93
+
94
+ // Generate insights and next steps
95
+ const insights = generateInsights(results, errors);
96
+ const nextSteps = generateNextSteps(results);
97
+
98
+ const duration = Date.now() - startTime;
99
+ logger.info('Domain search completed', {
100
+ domain: normalizedDomain,
101
+ results_count: results.length,
102
+ errors_count: errors.length,
103
+ duration_ms: duration,
104
+ from_cache: fromCache,
105
+ });
106
+
107
+ return {
108
+ results,
109
+ insights,
110
+ next_steps: nextSteps,
111
+ from_cache: fromCache,
112
+ duration_ms: duration,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Search a single domain with fallback chain.
118
+ */
119
+ async function searchSingleDomain(
120
+ domain: string,
121
+ tld: string,
122
+ preferredRegistrars?: string[],
123
+ ): Promise<{ result: DomainResult; fromCache: boolean }> {
124
+ const fullDomain = buildDomain(domain, tld);
125
+ const triedSources: string[] = [];
126
+
127
+ // Check cache first
128
+ for (const source of ['porkbun', 'namecheap', 'rdap', 'whois'] as const) {
129
+ const cacheKey = domainCacheKey(fullDomain, source);
130
+ const cached = domainCache.get(cacheKey);
131
+ if (cached) {
132
+ logger.debug('Cache hit', { domain: fullDomain, source });
133
+ return { result: cached, fromCache: true };
134
+ }
135
+ }
136
+
137
+ // Build source priority
138
+ const sources = buildSourcePriority(tld, preferredRegistrars);
139
+
140
+ // Try each source
141
+ for (const source of sources) {
142
+ triedSources.push(source);
143
+
144
+ try {
145
+ const result = await trySource(domain, tld, source);
146
+ if (result) {
147
+ // Calculate quality score
148
+ result.score = calculateDomainScore(result);
149
+
150
+ // Enhance premium_reason with analysis
151
+ if (result.premium && !result.premium_reason) {
152
+ const reasons = analyzePremiumReason(result.domain);
153
+ result.premium_reason = reasons.length > 0
154
+ ? reasons.join(', ')
155
+ : 'Premium domain';
156
+ }
157
+
158
+ // Cache the result
159
+ const cacheKey = domainCacheKey(fullDomain, source);
160
+ domainCache.set(cacheKey, result);
161
+ return { result, fromCache: false };
162
+ }
163
+ } catch (error) {
164
+ const wrapped = wrapError(error);
165
+ logger.debug(`Source ${source} failed, trying next`, {
166
+ domain: fullDomain,
167
+ error: wrapped.message,
168
+ retryable: wrapped.retryable,
169
+ });
170
+
171
+ // If it's not retryable, skip similar sources
172
+ if (!wrapped.retryable && source === 'porkbun') {
173
+ // Skip other registrar APIs, go straight to fallbacks
174
+ continue;
175
+ }
176
+ }
177
+ }
178
+
179
+ // All sources failed
180
+ throw new NoSourceAvailableError(fullDomain, triedSources);
181
+ }
182
+
183
+ /**
184
+ * Build the priority list of sources to try.
185
+ */
186
+ function buildSourcePriority(
187
+ tld: string,
188
+ preferredRegistrars?: string[],
189
+ ): string[] {
190
+ const sources: string[] = [];
191
+
192
+ // Add preferred registrars first
193
+ if (preferredRegistrars && preferredRegistrars.length > 0) {
194
+ for (const registrar of preferredRegistrars) {
195
+ if (registrar === 'porkbun' && config.porkbun.enabled) {
196
+ sources.push('porkbun');
197
+ } else if (registrar === 'namecheap' && config.namecheap.enabled) {
198
+ sources.push('namecheap');
199
+ }
200
+ }
201
+ } else {
202
+ // Default priority: Porkbun first (better API), then Namecheap
203
+ if (config.porkbun.enabled) sources.push('porkbun');
204
+ if (config.namecheap.enabled) sources.push('namecheap');
205
+ }
206
+
207
+ // Always add fallbacks
208
+ if (isRdapAvailable(tld)) sources.push('rdap');
209
+ if (isWhoisAvailable(tld)) sources.push('whois');
210
+
211
+ // If no registrar APIs, RDAP should be first
212
+ if (sources.length === 0) {
213
+ sources.push('rdap', 'whois');
214
+ }
215
+
216
+ return sources;
217
+ }
218
+
219
+ /**
220
+ * Try a specific source for domain lookup.
221
+ */
222
+ async function trySource(
223
+ domain: string,
224
+ tld: string,
225
+ source: string,
226
+ ): Promise<DomainResult | null> {
227
+ switch (source) {
228
+ case 'porkbun':
229
+ return porkbunAdapter.search(domain, tld);
230
+
231
+ case 'namecheap':
232
+ return namecheapAdapter.search(domain, tld);
233
+
234
+ case 'rdap':
235
+ return checkRdap(domain, tld);
236
+
237
+ case 'whois':
238
+ return checkWhois(domain, tld);
239
+
240
+ default:
241
+ logger.warn(`Unknown source: ${source}`);
242
+ return null;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Generate human-readable insights about the results.
248
+ */
249
+ function generateInsights(
250
+ results: DomainResult[],
251
+ errors: string[],
252
+ ): string[] {
253
+ const insights: string[] = [];
254
+
255
+ // Available domains summary
256
+ const available = results.filter((r) => r.available);
257
+ const taken = results.filter((r) => !r.available);
258
+
259
+ if (available.length > 0) {
260
+ const cheapest = available.reduce(
261
+ (min, r) =>
262
+ r.price_first_year !== null &&
263
+ (min === null || r.price_first_year < min.price_first_year!)
264
+ ? r
265
+ : min,
266
+ null as DomainResult | null,
267
+ );
268
+
269
+ if (cheapest && cheapest.price_first_year !== null) {
270
+ insights.push(
271
+ `✅ ${available.length} domain${available.length > 1 ? 's' : ''} available! Best price: ${cheapest.domain} at $${cheapest.price_first_year}/year (${cheapest.registrar})`,
272
+ );
273
+ } else {
274
+ insights.push(
275
+ `✅ ${available.length} domain${available.length > 1 ? 's' : ''} available!`,
276
+ );
277
+ }
278
+ }
279
+
280
+ if (taken.length > 0) {
281
+ insights.push(
282
+ `❌ ${taken.length} domain${taken.length > 1 ? 's' : ''} already taken`,
283
+ );
284
+ }
285
+
286
+ // TLD-specific advice
287
+ for (const result of results) {
288
+ if (result.available) {
289
+ const tld = result.domain.split('.').pop()!;
290
+ const advice = getTldAdvice(tld, result);
291
+ if (advice) {
292
+ insights.push(advice);
293
+ }
294
+ }
295
+ }
296
+
297
+ // Premium insights (enhanced with analyzer)
298
+ const premiums = results.filter((r) => r.premium && r.available);
299
+ if (premiums.length > 0) {
300
+ // Add detailed insight for each premium domain
301
+ for (const premium of premiums) {
302
+ const premiumInsight = generatePremiumInsight(premium);
303
+ if (premiumInsight) {
304
+ insights.push(premiumInsight);
305
+ }
306
+ }
307
+
308
+ // Add summary insights (alternatives, pricing context)
309
+ const summaryInsights = generatePremiumSummary(results);
310
+ insights.push(...summaryInsights);
311
+ }
312
+
313
+ // Privacy insight
314
+ const withPrivacy = results.filter(
315
+ (r) => r.available && r.privacy_included,
316
+ );
317
+ if (withPrivacy.length > 0) {
318
+ insights.push(
319
+ `🔒 ${withPrivacy.length} option${withPrivacy.length > 1 ? 's' : ''} include free WHOIS privacy`,
320
+ );
321
+ }
322
+
323
+ // Expiration insights for taken domains
324
+ const takenWithExpiration = results.filter(
325
+ (r) => !r.available && r.expires_at && r.days_until_expiration !== undefined,
326
+ );
327
+
328
+ for (const domain of takenWithExpiration) {
329
+ if (domain.days_until_expiration !== undefined) {
330
+ if (domain.days_until_expiration <= 0) {
331
+ insights.push(
332
+ `🕐 ${domain.domain} has EXPIRED — may become available soon!`,
333
+ );
334
+ } else if (domain.days_until_expiration <= 30) {
335
+ insights.push(
336
+ `🕐 ${domain.domain} expires in ${domain.days_until_expiration} days — watch for availability`,
337
+ );
338
+ } else if (domain.days_until_expiration <= 90) {
339
+ insights.push(
340
+ `📅 ${domain.domain} expires in ${Math.round(domain.days_until_expiration / 30)} months`,
341
+ );
342
+ }
343
+ }
344
+ }
345
+
346
+ // Error summary
347
+ if (errors.length > 0) {
348
+ insights.push(`⚠️ Could not check some TLDs: ${errors.join(', ')}`);
349
+ }
350
+
351
+ return insights;
352
+ }
353
+
354
+ /**
355
+ * Get TLD-specific advice.
356
+ */
357
+ function getTldAdvice(tld: string, result: DomainResult): string | null {
358
+ const advice: Record<string, string> = {
359
+ com: '💡 .com is the classic, universal choice — trusted worldwide',
360
+ io: '💡 .io is popular with tech startups and SaaS products',
361
+ dev: '💡 .dev signals developer/tech credibility (requires HTTPS)',
362
+ app: '💡 .app is perfect for mobile/web applications (requires HTTPS)',
363
+ co: '💡 .co is a popular alternative to .com for companies',
364
+ ai: '💡 .ai is trending for AI/ML projects',
365
+ sh: '💡 .sh is popular with developers (shell scripts!)',
366
+ };
367
+
368
+ return advice[tld] || null;
369
+ }
370
+
371
+ /**
372
+ * Generate suggested next steps.
373
+ */
374
+ function generateNextSteps(results: DomainResult[]): string[] {
375
+ const nextSteps: string[] = [];
376
+ const available = results.filter((r) => r.available);
377
+ const taken = results.filter((r) => !r.available);
378
+ const premiumAvailable = available.filter((r) => r.premium);
379
+ const nonPremiumAvailable = available.filter((r) => !r.premium);
380
+
381
+ if (available.length > 0) {
382
+ // Check other TLDs
383
+ const checkedTlds = new Set(results.map((r) => r.domain.split('.').pop()));
384
+ const suggestedTlds = ['com', 'io', 'dev', 'app', 'co', 'ai'].filter(
385
+ (t) => !checkedTlds.has(t),
386
+ );
387
+ if (suggestedTlds.length > 0) {
388
+ nextSteps.push(
389
+ `Check other TLDs: ${suggestedTlds.slice(0, 3).join(', ')}`,
390
+ );
391
+ }
392
+
393
+ // Premium-specific advice
394
+ if (premiumAvailable.length > 0 && nonPremiumAvailable.length === 0) {
395
+ // All available domains are premium
396
+ const firstPremium = premiumAvailable[0]!;
397
+ const alternatives = suggestPremiumAlternatives(firstPremium.domain);
398
+ if (alternatives.length > 0) {
399
+ nextSteps.push(
400
+ `Consider alternatives to avoid premium pricing: ${alternatives.join(', ')}`,
401
+ );
402
+ }
403
+ }
404
+
405
+ // Compare registrars
406
+ if (available.length === 1 && !available[0]!.price_first_year) {
407
+ nextSteps.push('Compare prices across registrars for better deals');
408
+ }
409
+
410
+ // Check social handles
411
+ nextSteps.push('Check social handle availability (GitHub, X, npm)');
412
+ }
413
+
414
+ if (taken.length > 0 && available.length === 0) {
415
+ nextSteps.push('Try name variations (add prefixes, suffixes, or hyphens)');
416
+ nextSteps.push('Check different TLDs for availability');
417
+ }
418
+
419
+ if (available.length > 0) {
420
+ // Prefer non-premium for registration suggestion
421
+ const best = nonPremiumAvailable.length > 0
422
+ ? nonPremiumAvailable.reduce((a, b) =>
423
+ (a.price_first_year || Infinity) < (b.price_first_year || Infinity) ? a : b
424
+ )
425
+ : available[0]!;
426
+
427
+ if (best.premium && best.price_first_year && best.price_first_year > 100) {
428
+ nextSteps.push(
429
+ `${best.domain} is premium ($${best.price_first_year}) — consider if it fits your budget`,
430
+ );
431
+ } else {
432
+ nextSteps.push(
433
+ `Register ${best.domain} at ${best.registrar} to secure it`,
434
+ );
435
+ }
436
+ }
437
+
438
+ return nextSteps;
439
+ }
440
+
441
+ /**
442
+ * Bulk search for multiple domains.
443
+ */
444
+ export async function bulkSearchDomains(
445
+ domains: string[],
446
+ tld: string = 'com',
447
+ registrar?: string,
448
+ maxConcurrent: number = 5,
449
+ ): Promise<DomainResult[]> {
450
+ const startTime = Date.now();
451
+ const results: DomainResult[] = [];
452
+
453
+ logger.info('Bulk search started', {
454
+ count: domains.length,
455
+ tld,
456
+ registrar,
457
+ });
458
+
459
+ // Process in batches
460
+ for (let i = 0; i < domains.length; i += maxConcurrent) {
461
+ const batch = domains.slice(i, i + maxConcurrent);
462
+ const batchPromises = batch.map(async (domain) => {
463
+ try {
464
+ const normalizedDomain = validateDomainName(domain);
465
+ const { result } = await searchSingleDomain(
466
+ normalizedDomain,
467
+ tld,
468
+ registrar ? [registrar] : undefined,
469
+ );
470
+ return result;
471
+ } catch (error) {
472
+ logger.warn(`Failed to check ${domain}.${tld}`, {
473
+ error: error instanceof Error ? error.message : String(error),
474
+ });
475
+ return null;
476
+ }
477
+ });
478
+
479
+ const batchResults = await Promise.all(batchPromises);
480
+ for (const result of batchResults) {
481
+ if (result) results.push(result);
482
+ }
483
+ }
484
+
485
+ const duration = Date.now() - startTime;
486
+ logger.info('Bulk search completed', {
487
+ checked: domains.length,
488
+ results: results.length,
489
+ duration_ms: duration,
490
+ });
491
+
492
+ return results;
493
+ }
494
+
495
+ /**
496
+ * Compare pricing across registrars.
497
+ */
498
+ export async function compareRegistrars(
499
+ domain: string,
500
+ tld: string,
501
+ registrars: string[] = ['porkbun', 'namecheap'],
502
+ ): Promise<{
503
+ comparisons: DomainResult[];
504
+ best_first_year: { registrar: string; price: number } | null;
505
+ best_renewal: { registrar: string; price: number } | null;
506
+ recommendation: string;
507
+ }> {
508
+ const normalizedDomain = validateDomainName(domain);
509
+ const comparisons: DomainResult[] = [];
510
+
511
+ // Check each registrar
512
+ for (const registrar of registrars) {
513
+ try {
514
+ const { result } = await searchSingleDomain(normalizedDomain, tld, [
515
+ registrar,
516
+ ]);
517
+ comparisons.push(result);
518
+ } catch (error) {
519
+ logger.warn(`Registrar ${registrar} comparison failed`, {
520
+ domain: `${normalizedDomain}.${tld}`,
521
+ error: error instanceof Error ? error.message : String(error),
522
+ });
523
+ }
524
+ }
525
+
526
+ // Find best prices
527
+ let bestFirstYear: { registrar: string; price: number } | null = null;
528
+ let bestRenewal: { registrar: string; price: number } | null = null;
529
+
530
+ for (const result of comparisons) {
531
+ if (result.available && result.price_first_year !== null) {
532
+ if (!bestFirstYear || result.price_first_year < bestFirstYear.price) {
533
+ bestFirstYear = {
534
+ registrar: result.registrar,
535
+ price: result.price_first_year,
536
+ };
537
+ }
538
+ }
539
+ if (result.available && result.price_renewal !== null) {
540
+ if (!bestRenewal || result.price_renewal < bestRenewal.price) {
541
+ bestRenewal = {
542
+ registrar: result.registrar,
543
+ price: result.price_renewal,
544
+ };
545
+ }
546
+ }
547
+ }
548
+
549
+ // Generate recommendation
550
+ let recommendation = 'Could not compare registrars';
551
+ if (bestFirstYear && bestRenewal) {
552
+ if (bestFirstYear.registrar === bestRenewal.registrar) {
553
+ recommendation = `${bestFirstYear.registrar} offers the best price for both first year ($${bestFirstYear.price}) and renewal ($${bestRenewal.price})`;
554
+ } else {
555
+ recommendation = `${bestFirstYear.registrar} for first year ($${bestFirstYear.price}), ${bestRenewal.registrar} for renewal ($${bestRenewal.price})`;
556
+ }
557
+ } else if (bestFirstYear) {
558
+ recommendation = `${bestFirstYear.registrar} has the best first year price: $${bestFirstYear.price}`;
559
+ }
560
+
561
+ return {
562
+ comparisons,
563
+ best_first_year: bestFirstYear,
564
+ best_renewal: bestRenewal,
565
+ recommendation,
566
+ };
567
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Service Exports.
3
+ */
4
+
5
+ export {
6
+ searchDomain,
7
+ bulkSearchDomains,
8
+ compareRegistrars,
9
+ } from './domain-search.js';
@@ -0,0 +1,142 @@
1
+ /**
2
+ * bulk_search Tool - Search Multiple Domains.
3
+ *
4
+ * Efficiently check availability for many domain names at once.
5
+ * Uses concurrent requests with rate limiting.
6
+ */
7
+
8
+ import { z } from 'zod';
9
+ import type { DomainResult } from '../types.js';
10
+ import { bulkSearchDomains } from '../services/domain-search.js';
11
+ import { wrapError } from '../utils/errors.js';
12
+
13
+ /**
14
+ * Input schema for bulk_search.
15
+ */
16
+ export const bulkSearchSchema = z.object({
17
+ domains: z
18
+ .array(z.string())
19
+ .min(1)
20
+ .max(100)
21
+ .describe(
22
+ "List of domain names to check (e.g., ['vibecoding', 'myapp', 'coolstartup']). Max 100 domains.",
23
+ ),
24
+ tld: z
25
+ .string()
26
+ .optional()
27
+ .default('com')
28
+ .describe("Single TLD to check for all domains (e.g., 'com'). Defaults to 'com'."),
29
+ registrar: z
30
+ .string()
31
+ .optional()
32
+ .describe(
33
+ "Optional: specific registrar to use (e.g., 'porkbun'). Leave empty for fastest source.",
34
+ ),
35
+ });
36
+
37
+ export type BulkSearchInput = z.infer<typeof bulkSearchSchema>;
38
+
39
+ /**
40
+ * Tool definition for MCP.
41
+ */
42
+ export const bulkSearchTool = {
43
+ name: 'bulk_search',
44
+ description: `Check availability for multiple domain names at once.
45
+
46
+ Efficiently searches up to 100 domains in parallel with rate limiting.
47
+ Use a single TLD for best performance.
48
+
49
+ Returns:
50
+ - Availability status for each domain
51
+ - Pricing where available
52
+ - Summary statistics
53
+
54
+ Example:
55
+ - bulk_search(["vibecoding", "myapp", "coolstartup"], "io")`,
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ domains: {
60
+ type: 'array',
61
+ items: { type: 'string' },
62
+ description:
63
+ "List of domain names to check. Don't include extensions. Max 100.",
64
+ },
65
+ tld: {
66
+ type: 'string',
67
+ description: "TLD to check for all domains (e.g., 'com'). Defaults to 'com'.",
68
+ },
69
+ registrar: {
70
+ type: 'string',
71
+ description: "Optional: specific registrar to use.",
72
+ },
73
+ },
74
+ required: ['domains'],
75
+ },
76
+ };
77
+
78
+ /**
79
+ * Response format for bulk search.
80
+ */
81
+ interface BulkSearchResponse {
82
+ results: DomainResult[];
83
+ summary: {
84
+ total: number;
85
+ available: number;
86
+ taken: number;
87
+ errors: number;
88
+ };
89
+ insights: string[];
90
+ }
91
+
92
+ /**
93
+ * Execute the bulk_search tool.
94
+ */
95
+ export async function executeBulkSearch(
96
+ input: BulkSearchInput,
97
+ ): Promise<BulkSearchResponse> {
98
+ try {
99
+ const { domains, tld, registrar } = bulkSearchSchema.parse(input);
100
+
101
+ const results = await bulkSearchDomains(domains, tld, registrar);
102
+
103
+ const available = results.filter((r) => r.available);
104
+ const taken = results.filter((r) => !r.available);
105
+
106
+ const insights: string[] = [];
107
+
108
+ if (available.length > 0) {
109
+ insights.push(
110
+ `✅ ${available.length} of ${domains.length} domains available`,
111
+ );
112
+
113
+ const cheapest = available
114
+ .filter((r) => r.price_first_year !== null)
115
+ .sort((a, b) => a.price_first_year! - b.price_first_year!)[0];
116
+
117
+ if (cheapest) {
118
+ insights.push(
119
+ `💰 Best price: ${cheapest.domain} at $${cheapest.price_first_year}/year`,
120
+ );
121
+ }
122
+ } else {
123
+ insights.push(`❌ All ${domains.length} domains are taken`);
124
+ insights.push(
125
+ '💡 Try different variations or alternative TLDs',
126
+ );
127
+ }
128
+
129
+ return {
130
+ results,
131
+ summary: {
132
+ total: domains.length,
133
+ available: available.length,
134
+ taken: taken.length,
135
+ errors: domains.length - results.length,
136
+ },
137
+ insights,
138
+ };
139
+ } catch (error) {
140
+ throw wrapError(error);
141
+ }
142
+ }