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.
- package/.env.example +52 -0
- package/Dockerfile +15 -0
- package/LICENSE +21 -0
- package/README.md +426 -0
- package/SECURITY.md +252 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +117 -0
- package/dist/config.js.map +1 -0
- package/dist/fallbacks/index.d.ts +6 -0
- package/dist/fallbacks/index.d.ts.map +1 -0
- package/dist/fallbacks/index.js +14 -0
- package/dist/fallbacks/index.js.map +1 -0
- package/dist/fallbacks/rdap.d.ts +18 -0
- package/dist/fallbacks/rdap.d.ts.map +1 -0
- package/dist/fallbacks/rdap.js +339 -0
- package/dist/fallbacks/rdap.js.map +1 -0
- package/dist/fallbacks/whois.d.ts +27 -0
- package/dist/fallbacks/whois.d.ts.map +1 -0
- package/dist/fallbacks/whois.js +219 -0
- package/dist/fallbacks/whois.js.map +1 -0
- package/dist/registrars/base.d.ts +89 -0
- package/dist/registrars/base.d.ts.map +1 -0
- package/dist/registrars/base.js +203 -0
- package/dist/registrars/base.js.map +1 -0
- package/dist/registrars/index.d.ts +7 -0
- package/dist/registrars/index.d.ts.map +1 -0
- package/dist/registrars/index.js +15 -0
- package/dist/registrars/index.js.map +1 -0
- package/dist/registrars/namecheap.d.ts +69 -0
- package/dist/registrars/namecheap.d.ts.map +1 -0
- package/dist/registrars/namecheap.js +307 -0
- package/dist/registrars/namecheap.js.map +1 -0
- package/dist/registrars/porkbun.d.ts +63 -0
- package/dist/registrars/porkbun.d.ts.map +1 -0
- package/dist/registrars/porkbun.js +299 -0
- package/dist/registrars/porkbun.js.map +1 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +209 -0
- package/dist/server.js.map +1 -0
- package/dist/services/domain-search.d.ts +40 -0
- package/dist/services/domain-search.d.ts.map +1 -0
- package/dist/services/domain-search.js +438 -0
- package/dist/services/domain-search.js.map +1 -0
- package/dist/services/index.d.ts +5 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +11 -0
- package/dist/services/index.js.map +1 -0
- package/dist/tools/bulk_search.d.ts +72 -0
- package/dist/tools/bulk_search.d.ts.map +1 -0
- package/dist/tools/bulk_search.js +108 -0
- package/dist/tools/bulk_search.js.map +1 -0
- package/dist/tools/check_socials.d.ts +71 -0
- package/dist/tools/check_socials.d.ts.map +1 -0
- package/dist/tools/check_socials.js +357 -0
- package/dist/tools/check_socials.js.map +1 -0
- package/dist/tools/compare_registrars.d.ts +80 -0
- package/dist/tools/compare_registrars.d.ts.map +1 -0
- package/dist/tools/compare_registrars.js +116 -0
- package/dist/tools/compare_registrars.js.map +1 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +31 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/search_domain.d.ts +61 -0
- package/dist/tools/search_domain.d.ts.map +1 -0
- package/dist/tools/search_domain.js +81 -0
- package/dist/tools/search_domain.js.map +1 -0
- package/dist/tools/suggest_domains.d.ts +82 -0
- package/dist/tools/suggest_domains.d.ts.map +1 -0
- package/dist/tools/suggest_domains.js +227 -0
- package/dist/tools/suggest_domains.js.map +1 -0
- package/dist/tools/tld_info.d.ts +56 -0
- package/dist/tools/tld_info.d.ts.map +1 -0
- package/dist/tools/tld_info.js +273 -0
- package/dist/tools/tld_info.js.map +1 -0
- package/dist/types.d.ts +193 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/cache.d.ts +81 -0
- package/dist/utils/cache.d.ts.map +1 -0
- package/dist/utils/cache.js +192 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/errors.d.ts +87 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +191 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +24 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +27 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +132 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/premium-analyzer.d.ts +33 -0
- package/dist/utils/premium-analyzer.d.ts.map +1 -0
- package/dist/utils/premium-analyzer.js +273 -0
- package/dist/utils/premium-analyzer.js.map +1 -0
- package/dist/utils/validators.d.ts +53 -0
- package/dist/utils/validators.d.ts.map +1 -0
- package/dist/utils/validators.js +159 -0
- package/dist/utils/validators.js.map +1 -0
- package/docs/marketing/devto-post.md +135 -0
- package/docs/marketing/hackernews.md +42 -0
- package/docs/marketing/producthunt.md +109 -0
- package/docs/marketing/reddit-post.md +59 -0
- package/docs/marketing/twitter-thread.md +105 -0
- package/examples/bulk-search-50-domains.ts +131 -0
- package/examples/cli-interactive.ts +280 -0
- package/examples/compare-registrars.ts +78 -0
- package/examples/search-single-domain.ts +54 -0
- package/examples/suggest-names.ts +110 -0
- package/glama.json +6 -0
- package/jest.config.js +35 -0
- package/package.json +62 -0
- package/smithery.yaml +36 -0
- package/src/config.ts +121 -0
- package/src/fallbacks/index.ts +6 -0
- package/src/fallbacks/rdap.ts +407 -0
- package/src/fallbacks/whois.ts +250 -0
- package/src/registrars/base.ts +264 -0
- package/src/registrars/index.ts +7 -0
- package/src/registrars/namecheap.ts +378 -0
- package/src/registrars/porkbun.ts +380 -0
- package/src/server.ts +276 -0
- package/src/services/domain-search.ts +567 -0
- package/src/services/index.ts +9 -0
- package/src/tools/bulk_search.ts +142 -0
- package/src/tools/check_socials.ts +467 -0
- package/src/tools/compare_registrars.ts +162 -0
- package/src/tools/index.ts +45 -0
- package/src/tools/search_domain.ts +93 -0
- package/src/tools/suggest_domains.ts +284 -0
- package/src/tools/tld_info.ts +294 -0
- package/src/types.ts +289 -0
- package/src/utils/cache.ts +238 -0
- package/src/utils/errors.ts +262 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.ts +162 -0
- package/src/utils/premium-analyzer.ts +303 -0
- package/src/utils/validators.ts +193 -0
- package/tests/premium-analyzer.test.ts +310 -0
- package/tests/unit/cache.test.ts +123 -0
- package/tests/unit/errors.test.ts +190 -0
- package/tests/unit/tld-info.test.ts +62 -0
- package/tests/unit/tools.test.ts +200 -0
- package/tests/unit/validators.test.ts +146 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Porkbun Registrar Adapter.
|
|
3
|
+
*
|
|
4
|
+
* Porkbun offers competitive pricing and a JSON API.
|
|
5
|
+
* API Docs: https://porkbun.com/api/json/v3/documentation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import axios, { type AxiosInstance, type AxiosError } from 'axios';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { RegistrarAdapter } from './base.js';
|
|
11
|
+
import type { DomainResult, TLDInfo } from '../types.js';
|
|
12
|
+
import { config } from '../config.js';
|
|
13
|
+
import { logger } from '../utils/logger.js';
|
|
14
|
+
import {
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
RegistrarApiError,
|
|
18
|
+
} from '../utils/errors.js';
|
|
19
|
+
|
|
20
|
+
const PORKBUN_API_BASE = 'https://api.porkbun.com/api/json/v3';
|
|
21
|
+
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
// Zod Schemas for API Response Validation
|
|
24
|
+
// SECURITY: Validate all external API responses to prevent unexpected data
|
|
25
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Base response schema - all Porkbun responses have this structure.
|
|
29
|
+
*/
|
|
30
|
+
const PorkbunBaseResponseSchema = z.object({
|
|
31
|
+
status: z.enum(['SUCCESS', 'ERROR']),
|
|
32
|
+
message: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Domain availability check response schema.
|
|
37
|
+
*/
|
|
38
|
+
const PorkbunCheckResponseSchema = PorkbunBaseResponseSchema.extend({
|
|
39
|
+
avail: z.number().optional(), // 1 = available, 0 = taken
|
|
40
|
+
premium: z.number().optional(), // 1 = premium
|
|
41
|
+
yourPrice: z.string().optional(),
|
|
42
|
+
retailPrice: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pricing response schema for a single TLD.
|
|
47
|
+
*/
|
|
48
|
+
const PorkbunTldPricingSchema = z.object({
|
|
49
|
+
registration: z.string(),
|
|
50
|
+
renewal: z.string(),
|
|
51
|
+
transfer: z.string(),
|
|
52
|
+
coupons: z.object({
|
|
53
|
+
registration: z.object({
|
|
54
|
+
code: z.string(),
|
|
55
|
+
max_per_user: z.number(),
|
|
56
|
+
first_year_only: z.string(),
|
|
57
|
+
type: z.string(),
|
|
58
|
+
amount: z.number(),
|
|
59
|
+
}).optional(),
|
|
60
|
+
}).optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Full pricing response schema.
|
|
65
|
+
*/
|
|
66
|
+
const PorkbunPricingResponseSchema = PorkbunBaseResponseSchema.extend({
|
|
67
|
+
pricing: z.record(z.string(), PorkbunTldPricingSchema).optional(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Type inference from schemas
|
|
71
|
+
type PorkbunBaseResponse = z.infer<typeof PorkbunBaseResponseSchema>;
|
|
72
|
+
type PorkbunCheckResponse = z.infer<typeof PorkbunCheckResponseSchema>;
|
|
73
|
+
type PorkbunPricingResponse = z.infer<typeof PorkbunPricingResponseSchema>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Porkbun adapter implementation.
|
|
77
|
+
*/
|
|
78
|
+
export class PorkbunAdapter extends RegistrarAdapter {
|
|
79
|
+
readonly name = 'Porkbun';
|
|
80
|
+
readonly id = 'porkbun';
|
|
81
|
+
|
|
82
|
+
private readonly client: AxiosInstance;
|
|
83
|
+
private readonly apiKey?: string;
|
|
84
|
+
private readonly apiSecret?: string;
|
|
85
|
+
private pricingCache: Record<string, { registration: number; renewal: number; transfer: number }> = {};
|
|
86
|
+
|
|
87
|
+
constructor() {
|
|
88
|
+
// Porkbun has generous rate limits, ~60/min is safe
|
|
89
|
+
super(60);
|
|
90
|
+
|
|
91
|
+
this.apiKey = config.porkbun.apiKey;
|
|
92
|
+
this.apiSecret = config.porkbun.apiSecret;
|
|
93
|
+
|
|
94
|
+
this.client = axios.create({
|
|
95
|
+
baseURL: PORKBUN_API_BASE,
|
|
96
|
+
timeout: this.timeoutMs,
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if Porkbun API is enabled.
|
|
105
|
+
*/
|
|
106
|
+
isEnabled(): boolean {
|
|
107
|
+
return config.porkbun.enabled;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Search for domain availability.
|
|
112
|
+
*/
|
|
113
|
+
async search(domain: string, tld: string): Promise<DomainResult> {
|
|
114
|
+
if (!this.isEnabled()) {
|
|
115
|
+
throw new AuthenticationError(
|
|
116
|
+
'porkbun',
|
|
117
|
+
'API credentials not configured',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fullDomain = `${domain}.${tld}`;
|
|
122
|
+
logger.debug('Porkbun search', { domain: fullDomain });
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// First, try to get pricing (this is cached)
|
|
126
|
+
const pricing = await this.getPricing(tld);
|
|
127
|
+
|
|
128
|
+
// Then check availability
|
|
129
|
+
const availability = await this.checkAvailability(domain, tld);
|
|
130
|
+
|
|
131
|
+
return this.createResult(domain, tld, {
|
|
132
|
+
available: availability.available,
|
|
133
|
+
premium: availability.premium,
|
|
134
|
+
price_first_year: availability.price ?? pricing?.registration ?? null,
|
|
135
|
+
price_renewal: pricing?.renewal ?? null,
|
|
136
|
+
transfer_price: pricing?.transfer ?? null,
|
|
137
|
+
privacy_included: true, // Porkbun includes WHOIS privacy
|
|
138
|
+
source: 'porkbun_api',
|
|
139
|
+
premium_reason: availability.premium ? 'Premium domain' : undefined,
|
|
140
|
+
});
|
|
141
|
+
} catch (error) {
|
|
142
|
+
this.handleApiError(error, fullDomain);
|
|
143
|
+
throw error; // Re-throw if not handled
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check domain availability.
|
|
149
|
+
* SECURITY: Validates API response with Zod schema.
|
|
150
|
+
*/
|
|
151
|
+
private async checkAvailability(
|
|
152
|
+
domain: string,
|
|
153
|
+
tld: string,
|
|
154
|
+
): Promise<{ available: boolean; premium: boolean; price?: number }> {
|
|
155
|
+
const result = await this.retryWithBackoff(async () => {
|
|
156
|
+
const response = await this.client.post(
|
|
157
|
+
'/domain/check',
|
|
158
|
+
{
|
|
159
|
+
apikey: this.apiKey,
|
|
160
|
+
secretapikey: this.apiSecret,
|
|
161
|
+
domain: `${domain}.${tld}`,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Validate response with Zod schema
|
|
166
|
+
const parseResult = PorkbunCheckResponseSchema.safeParse(response.data);
|
|
167
|
+
if (!parseResult.success) {
|
|
168
|
+
logger.warn('Porkbun API response validation failed', {
|
|
169
|
+
domain: `${domain}.${tld}`,
|
|
170
|
+
errors: parseResult.error.errors,
|
|
171
|
+
});
|
|
172
|
+
throw new RegistrarApiError(
|
|
173
|
+
this.name,
|
|
174
|
+
'Invalid API response format',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const validated = parseResult.data;
|
|
179
|
+
|
|
180
|
+
if (validated.status !== 'SUCCESS') {
|
|
181
|
+
throw new RegistrarApiError(
|
|
182
|
+
this.name,
|
|
183
|
+
validated.message || 'Unknown error',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return validated;
|
|
188
|
+
}, `check ${domain}.${tld}`);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
available: result.avail === 1,
|
|
192
|
+
premium: result.premium === 1,
|
|
193
|
+
price: result.yourPrice ? parseFloat(result.yourPrice) : undefined,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get pricing for a TLD.
|
|
199
|
+
* SECURITY: Validates API response with Zod schema.
|
|
200
|
+
*/
|
|
201
|
+
private async getPricing(
|
|
202
|
+
tld: string,
|
|
203
|
+
): Promise<{ registration: number; renewal: number; transfer: number } | null> {
|
|
204
|
+
// Check cache first
|
|
205
|
+
if (this.pricingCache[tld]) {
|
|
206
|
+
return this.pricingCache[tld];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const result = await this.retryWithBackoff(async () => {
|
|
211
|
+
const response = await this.client.post(
|
|
212
|
+
'/pricing/get',
|
|
213
|
+
{
|
|
214
|
+
apikey: this.apiKey,
|
|
215
|
+
secretapikey: this.apiSecret,
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Validate response with Zod schema
|
|
220
|
+
const parseResult = PorkbunPricingResponseSchema.safeParse(response.data);
|
|
221
|
+
if (!parseResult.success) {
|
|
222
|
+
logger.warn('Porkbun pricing API response validation failed', {
|
|
223
|
+
errors: parseResult.error.errors,
|
|
224
|
+
});
|
|
225
|
+
throw new RegistrarApiError(
|
|
226
|
+
this.name,
|
|
227
|
+
'Invalid pricing API response format',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const validated = parseResult.data;
|
|
232
|
+
|
|
233
|
+
if (validated.status !== 'SUCCESS') {
|
|
234
|
+
throw new RegistrarApiError(
|
|
235
|
+
this.name,
|
|
236
|
+
validated.message || 'Failed to get pricing',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return validated.pricing;
|
|
241
|
+
}, 'get pricing');
|
|
242
|
+
|
|
243
|
+
if (result) {
|
|
244
|
+
// Cache all TLD pricing
|
|
245
|
+
for (const [tldKey, prices] of Object.entries(result)) {
|
|
246
|
+
this.pricingCache[tldKey] = {
|
|
247
|
+
registration: parseFloat(prices.registration),
|
|
248
|
+
renewal: parseFloat(prices.renewal),
|
|
249
|
+
transfer: parseFloat(prices.transfer),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return this.pricingCache[tld] || null;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
logger.warn('Failed to get Porkbun pricing', {
|
|
257
|
+
tld,
|
|
258
|
+
error: error instanceof Error ? error.message : String(error),
|
|
259
|
+
});
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get TLD information.
|
|
266
|
+
*/
|
|
267
|
+
async getTldInfo(tld: string): Promise<TLDInfo | null> {
|
|
268
|
+
const pricing = await this.getPricing(tld);
|
|
269
|
+
if (!pricing) return null;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
tld,
|
|
273
|
+
description: `${tld.toUpperCase()} domain`,
|
|
274
|
+
typical_use: this.getTldUseCase(tld),
|
|
275
|
+
price_range: {
|
|
276
|
+
min: pricing.registration,
|
|
277
|
+
max: pricing.registration,
|
|
278
|
+
currency: 'USD',
|
|
279
|
+
},
|
|
280
|
+
renewal_price_typical: pricing.renewal,
|
|
281
|
+
restrictions: [],
|
|
282
|
+
popularity: this.getTldPopularity(tld),
|
|
283
|
+
category: this.getTldCategory(tld),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Handle API errors with user-friendly messages.
|
|
289
|
+
*/
|
|
290
|
+
private handleApiError(error: unknown, domain: string): never {
|
|
291
|
+
if (axios.isAxiosError(error)) {
|
|
292
|
+
const axiosError = error as AxiosError<PorkbunBaseResponse>;
|
|
293
|
+
|
|
294
|
+
if (axiosError.response) {
|
|
295
|
+
const status = axiosError.response.status;
|
|
296
|
+
const message =
|
|
297
|
+
axiosError.response.data?.message || axiosError.message;
|
|
298
|
+
|
|
299
|
+
if (status === 401 || status === 403) {
|
|
300
|
+
throw new AuthenticationError('porkbun', message);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (status === 429) {
|
|
304
|
+
const retryAfter = axiosError.response.headers['retry-after'];
|
|
305
|
+
throw new RateLimitError(
|
|
306
|
+
'porkbun',
|
|
307
|
+
retryAfter ? parseInt(retryAfter, 10) : undefined,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
throw new RegistrarApiError(this.name, message, status, error);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (axiosError.code === 'ECONNABORTED') {
|
|
315
|
+
throw new RegistrarApiError(
|
|
316
|
+
this.name,
|
|
317
|
+
`Request timed out for ${domain}`,
|
|
318
|
+
undefined,
|
|
319
|
+
error,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
throw new RegistrarApiError(
|
|
325
|
+
this.name,
|
|
326
|
+
error instanceof Error ? error.message : 'Unknown error',
|
|
327
|
+
undefined,
|
|
328
|
+
error instanceof Error ? error : undefined,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get typical use case for a TLD.
|
|
334
|
+
*/
|
|
335
|
+
private getTldUseCase(tld: string): string {
|
|
336
|
+
const useCases: Record<string, string> = {
|
|
337
|
+
com: 'General commercial websites',
|
|
338
|
+
io: 'Tech startups and SaaS products',
|
|
339
|
+
dev: 'Developer tools and portfolios',
|
|
340
|
+
app: 'Mobile and web applications',
|
|
341
|
+
co: 'Companies and startups',
|
|
342
|
+
net: 'Network services and utilities',
|
|
343
|
+
org: 'Non-profit organizations',
|
|
344
|
+
ai: 'AI and machine learning projects',
|
|
345
|
+
xyz: 'Creative and unconventional projects',
|
|
346
|
+
};
|
|
347
|
+
return useCases[tld] || 'General purpose';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Get TLD popularity rating.
|
|
352
|
+
*/
|
|
353
|
+
private getTldPopularity(tld: string): 'high' | 'medium' | 'low' {
|
|
354
|
+
const highPopularity = ['com', 'net', 'org', 'io', 'co'];
|
|
355
|
+
const mediumPopularity = ['dev', 'app', 'ai', 'me'];
|
|
356
|
+
|
|
357
|
+
if (highPopularity.includes(tld)) return 'high';
|
|
358
|
+
if (mediumPopularity.includes(tld)) return 'medium';
|
|
359
|
+
return 'low';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get TLD category.
|
|
364
|
+
*/
|
|
365
|
+
private getTldCategory(tld: string): TLDInfo['category'] {
|
|
366
|
+
const countryTlds = ['uk', 'de', 'fr', 'jp', 'cn', 'au', 'ca', 'us'];
|
|
367
|
+
const sponsoredTlds = ['edu', 'gov', 'mil'];
|
|
368
|
+
const newTlds = ['io', 'dev', 'app', 'ai', 'xyz', 'tech', 'cloud'];
|
|
369
|
+
|
|
370
|
+
if (countryTlds.includes(tld)) return 'country';
|
|
371
|
+
if (sponsoredTlds.includes(tld)) return 'sponsored';
|
|
372
|
+
if (newTlds.includes(tld)) return 'new';
|
|
373
|
+
return 'generic';
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Singleton instance.
|
|
379
|
+
*/
|
|
380
|
+
export const porkbunAdapter = new PorkbunAdapter();
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Domain Search MCP Server.
|
|
4
|
+
*
|
|
5
|
+
* Model Context Protocol server for domain availability search.
|
|
6
|
+
* Supports Porkbun, Namecheap, RDAP, and WHOIS as data sources.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - search_domain: Check availability across multiple TLDs
|
|
10
|
+
* - bulk_search: Check many domains at once
|
|
11
|
+
* - compare_registrars: Compare pricing across registrars
|
|
12
|
+
* - suggest_domains: Generate available name variations
|
|
13
|
+
* - tld_info: Get TLD information and recommendations
|
|
14
|
+
* - check_socials: Check social handle availability
|
|
15
|
+
*
|
|
16
|
+
* @see https://github.com/yourusername/domain-search-mcp
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
20
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
|
+
import {
|
|
22
|
+
CallToolRequestSchema,
|
|
23
|
+
ListToolsRequestSchema,
|
|
24
|
+
type Tool,
|
|
25
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
26
|
+
|
|
27
|
+
import { config, getAvailableSources, hasRegistrarApi } from './config.js';
|
|
28
|
+
import { logger, generateRequestId, setRequestId, clearRequestId } from './utils/logger.js';
|
|
29
|
+
import { wrapError, DomainSearchError } from './utils/errors.js';
|
|
30
|
+
import {
|
|
31
|
+
searchDomainTool,
|
|
32
|
+
executeSearchDomain,
|
|
33
|
+
bulkSearchTool,
|
|
34
|
+
executeBulkSearch,
|
|
35
|
+
compareRegistrarsTool,
|
|
36
|
+
executeCompareRegistrars,
|
|
37
|
+
suggestDomainsTool,
|
|
38
|
+
executeSuggestDomains,
|
|
39
|
+
tldInfoTool,
|
|
40
|
+
executeTldInfo,
|
|
41
|
+
checkSocialsTool,
|
|
42
|
+
executeCheckSocials,
|
|
43
|
+
} from './tools/index.js';
|
|
44
|
+
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
46
|
+
// Server Configuration
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
|
|
49
|
+
const SERVER_NAME = 'domain-search-mcp';
|
|
50
|
+
const SERVER_VERSION = '1.0.0';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* All available tools.
|
|
54
|
+
*/
|
|
55
|
+
const TOOLS: Tool[] = [
|
|
56
|
+
searchDomainTool as Tool,
|
|
57
|
+
bulkSearchTool as Tool,
|
|
58
|
+
compareRegistrarsTool as Tool,
|
|
59
|
+
suggestDomainsTool as Tool,
|
|
60
|
+
tldInfoTool as Tool,
|
|
61
|
+
checkSocialsTool as Tool,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
65
|
+
// Server Implementation
|
|
66
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create and configure the MCP server.
|
|
70
|
+
*/
|
|
71
|
+
function createServer(): Server {
|
|
72
|
+
const server = new Server(
|
|
73
|
+
{
|
|
74
|
+
name: SERVER_NAME,
|
|
75
|
+
version: SERVER_VERSION,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
capabilities: {
|
|
79
|
+
tools: {},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Register tool listing handler
|
|
85
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
86
|
+
return { tools: TOOLS };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Register tool call handler
|
|
90
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
91
|
+
const { name, arguments: args } = request.params;
|
|
92
|
+
const requestId = generateRequestId();
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
setRequestId(requestId);
|
|
96
|
+
logger.info('Tool call started', { tool: name, request_id: requestId });
|
|
97
|
+
|
|
98
|
+
const result = await executeToolCall(name, args || {});
|
|
99
|
+
|
|
100
|
+
logger.info('Tool call completed', {
|
|
101
|
+
tool: name,
|
|
102
|
+
request_id: requestId,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: JSON.stringify(result, null, 2),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const wrapped = wrapError(error);
|
|
115
|
+
|
|
116
|
+
logger.error('Tool call failed', {
|
|
117
|
+
tool: name,
|
|
118
|
+
request_id: requestId,
|
|
119
|
+
error: wrapped.message,
|
|
120
|
+
code: wrapped.code,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Return error as content (MCP pattern)
|
|
124
|
+
return {
|
|
125
|
+
content: [
|
|
126
|
+
{
|
|
127
|
+
type: 'text',
|
|
128
|
+
text: JSON.stringify(
|
|
129
|
+
{
|
|
130
|
+
error: true,
|
|
131
|
+
code: wrapped.code,
|
|
132
|
+
message: wrapped.userMessage,
|
|
133
|
+
retryable: wrapped.retryable,
|
|
134
|
+
suggestedAction: wrapped.suggestedAction,
|
|
135
|
+
},
|
|
136
|
+
null,
|
|
137
|
+
2,
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
isError: true,
|
|
142
|
+
};
|
|
143
|
+
} finally {
|
|
144
|
+
clearRequestId();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return server;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Execute a tool call by name.
|
|
153
|
+
*/
|
|
154
|
+
async function executeToolCall(
|
|
155
|
+
name: string,
|
|
156
|
+
args: Record<string, unknown>,
|
|
157
|
+
): Promise<unknown> {
|
|
158
|
+
switch (name) {
|
|
159
|
+
case 'search_domain':
|
|
160
|
+
return executeSearchDomain({
|
|
161
|
+
domain_name: args.domain_name as string,
|
|
162
|
+
tlds: args.tlds as string[] | undefined,
|
|
163
|
+
registrars: args.registrars as string[] | undefined,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
case 'bulk_search':
|
|
167
|
+
return executeBulkSearch({
|
|
168
|
+
domains: args.domains as string[],
|
|
169
|
+
tld: (args.tld as string) || 'com',
|
|
170
|
+
registrar: args.registrar as string | undefined,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
case 'compare_registrars':
|
|
174
|
+
return executeCompareRegistrars({
|
|
175
|
+
domain: args.domain as string,
|
|
176
|
+
tld: args.tld as string,
|
|
177
|
+
registrars: args.registrars as string[] | undefined,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
case 'suggest_domains':
|
|
181
|
+
return executeSuggestDomains({
|
|
182
|
+
base_name: args.base_name as string,
|
|
183
|
+
tld: (args.tld as string) || 'com',
|
|
184
|
+
variants: args.variants as
|
|
185
|
+
| Array<'hyphen' | 'numbers' | 'abbreviations' | 'synonyms' | 'prefixes' | 'suffixes'>
|
|
186
|
+
| undefined,
|
|
187
|
+
max_suggestions: (args.max_suggestions as number) || 10,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
case 'tld_info':
|
|
191
|
+
return executeTldInfo({
|
|
192
|
+
tld: args.tld as string,
|
|
193
|
+
detailed: (args.detailed as boolean) || false,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
case 'check_socials':
|
|
197
|
+
return executeCheckSocials({
|
|
198
|
+
name: args.name as string,
|
|
199
|
+
platforms: args.platforms as
|
|
200
|
+
| Array<'github' | 'twitter' | 'instagram' | 'linkedin' | 'tiktok'>
|
|
201
|
+
| undefined,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
default:
|
|
205
|
+
throw new DomainSearchError(
|
|
206
|
+
'UNKNOWN_TOOL',
|
|
207
|
+
`Unknown tool: ${name}`,
|
|
208
|
+
`The tool "${name}" is not available.`,
|
|
209
|
+
{
|
|
210
|
+
retryable: false,
|
|
211
|
+
suggestedAction: `Available tools: ${TOOLS.map((t) => t.name).join(', ')}`,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
218
|
+
// Startup
|
|
219
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Main entry point.
|
|
223
|
+
*/
|
|
224
|
+
async function main(): Promise<void> {
|
|
225
|
+
// Log startup info
|
|
226
|
+
logger.info('Domain Search MCP starting', {
|
|
227
|
+
version: SERVER_VERSION,
|
|
228
|
+
node_version: process.version,
|
|
229
|
+
sources: getAvailableSources(),
|
|
230
|
+
has_registrar_api: hasRegistrarApi(),
|
|
231
|
+
dry_run: config.dryRun,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Warn if no API keys configured
|
|
235
|
+
if (!hasRegistrarApi()) {
|
|
236
|
+
logger.warn(
|
|
237
|
+
'No registrar API keys configured. Falling back to RDAP/WHOIS only.',
|
|
238
|
+
);
|
|
239
|
+
logger.warn(
|
|
240
|
+
'For pricing info, add PORKBUN_API_KEY and PORKBUN_API_SECRET to your .env file.',
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Create and start server
|
|
245
|
+
const server = createServer();
|
|
246
|
+
const transport = new StdioServerTransport();
|
|
247
|
+
|
|
248
|
+
await server.connect(transport);
|
|
249
|
+
|
|
250
|
+
logger.info('Domain Search MCP ready', {
|
|
251
|
+
tools: TOOLS.length,
|
|
252
|
+
transport: 'stdio',
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Handle graceful shutdown
|
|
256
|
+
process.on('SIGINT', async () => {
|
|
257
|
+
logger.info('Shutting down...');
|
|
258
|
+
await server.close();
|
|
259
|
+
process.exit(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
process.on('SIGTERM', async () => {
|
|
263
|
+
logger.info('Shutting down...');
|
|
264
|
+
await server.close();
|
|
265
|
+
process.exit(0);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Run the server
|
|
270
|
+
main().catch((error) => {
|
|
271
|
+
logger.error('Failed to start server', {
|
|
272
|
+
error: error instanceof Error ? error.message : String(error),
|
|
273
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
274
|
+
});
|
|
275
|
+
process.exit(1);
|
|
276
|
+
});
|