@probeops/mcp-server 1.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/dist/index.js ADDED
@@ -0,0 +1,662 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
5
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
+ const zod_1 = require("zod");
7
+ const api_client_js_1 = require("./api-client.js");
8
+ const types_js_1 = require("./types.js");
9
+ const formatters_js_1 = require("./formatters.js");
10
+ // ── Configuration ───────────────────────────────────────────
11
+ const API_KEY = process.env.PROBEOPS_API_KEY;
12
+ const BASE_URL = process.env.PROBEOPS_BASE_URL || 'https://probeops.com';
13
+ if (!API_KEY) {
14
+ console.error('Error: PROBEOPS_API_KEY environment variable is required.');
15
+ console.error('Get your free API key at https://probeops.com/dashboard/api-keys');
16
+ process.exit(1);
17
+ }
18
+ const client = new api_client_js_1.ProbeOpsClient({ apiKey: API_KEY, baseUrl: BASE_URL });
19
+ let cachedProxyToken = null;
20
+ /**
21
+ * Build a user-facing extension notice for quota awareness.
22
+ */
23
+ function buildExtensionNotice(data) {
24
+ const { consumed, quota, resets_at } = data.daily_usage;
25
+ return `Proxy session extended (+1 hour). ${consumed} of ${quota} daily hours used. Resets at ${resets_at} | Upgrade: https://probeops.com/pricing`;
26
+ }
27
+ /**
28
+ * Get a valid proxy token with 3-tier logic:
29
+ * 1. > 5 min remaining → reuse cached (no quota cost)
30
+ * 2. 0-5 min remaining → extend existing token (+1 quota unit)
31
+ * 3. Expired/no cache → generate new token (1 quota unit)
32
+ *
33
+ * A single token works across ALL regions (allowed_regions: ["*"]).
34
+ */
35
+ async function getOrCreateProxyToken(region) {
36
+ const now = Date.now();
37
+ if (cachedProxyToken) {
38
+ const remaining = cachedProxyToken.expiresAt - now;
39
+ // Tier 1: > 5 minutes remaining — reuse as-is (no quota cost)
40
+ if (remaining > 5 * 60 * 1000) {
41
+ const remainMin = Math.round(remaining / 60000);
42
+ process.stderr.write(`[probeops] Reusing cached proxy token ${cachedProxyToken.data.token_id} (${remainMin} min remaining, no quota consumed)\n`);
43
+ return cachedProxyToken.data;
44
+ }
45
+ // Tier 2: 0-5 minutes remaining — try to extend
46
+ if (remaining > 0) {
47
+ try {
48
+ process.stderr.write(`[probeops] Token ${cachedProxyToken.data.token_id} nearing expiry (${Math.round(remaining / 60000)} min), extending (+1 quota)\n`);
49
+ const data = await client.extendProxyToken(cachedProxyToken.data.token_id);
50
+ cachedProxyToken = {
51
+ data,
52
+ expiresAt: new Date(data.expires_at).getTime(),
53
+ extensionNotice: buildExtensionNotice(data),
54
+ };
55
+ // Update quota cache with fresh daily_usage from extend response
56
+ quotaCache.proxy = data.daily_usage;
57
+ quotaCache.fetchedAt = Date.now();
58
+ process.stderr.write(`[probeops] Token ${data.token_id} extended, expires ${data.expires_at}, quota ${data.daily_usage.consumed}/${data.daily_usage.quota}\n`);
59
+ return data;
60
+ }
61
+ catch (err) {
62
+ // Extend failed (expired between check and call, quota exhausted, etc.)
63
+ // Fall through to generate
64
+ const msg = err instanceof Error ? err.message : String(err);
65
+ process.stderr.write(`[probeops] Extend failed (${msg}), falling back to generate\n`);
66
+ }
67
+ }
68
+ }
69
+ // Tier 3: No cache, expired, or extend failed — generate new token
70
+ process.stderr.write(`[probeops] Generating new proxy token (1 daily quota consumed)\n`);
71
+ const data = await client.getGeoProxy({ region });
72
+ cachedProxyToken = {
73
+ data,
74
+ expiresAt: new Date(data.expires_at).getTime(),
75
+ };
76
+ // Update quota cache
77
+ quotaCache.proxy = data.daily_usage;
78
+ quotaCache.fetchedAt = Date.now();
79
+ process.stderr.write(`[probeops] Token ${data.token_id} created, expires ${data.expires_at}, quota ${data.daily_usage.consumed}/${data.daily_usage.quota}\n`);
80
+ return data;
81
+ }
82
+ /**
83
+ * Get the proxy server URL for a region.
84
+ * Uses proxy_nodes map from API if available, falls back to proxy_url.
85
+ */
86
+ function getProxyServer(data, region) {
87
+ // Try region-specific URL from proxy_nodes map (returned by API)
88
+ if (data.proxy_nodes && data.proxy_nodes[region]) {
89
+ return data.proxy_nodes[region];
90
+ }
91
+ // Fall back to the primary proxy_url (assigned node)
92
+ if (data.proxy_url) {
93
+ return data.proxy_url;
94
+ }
95
+ // Last resort: derive from region name (should rarely happen)
96
+ process.stderr.write(`[probeops] Warning: no proxy_nodes or proxy_url in API response, using fallback FQDN for ${region}\n`);
97
+ return `https://node-1-${region}.probeops.com:443`;
98
+ }
99
+ // ── Quota Cache (passive awareness across all tools) ────────
100
+ const QUOTA_CACHE_TTL_MS = 60_000; // 60 seconds
101
+ let quotaCache = {
102
+ diagnostic: null,
103
+ proxy: null,
104
+ fetchedAt: 0,
105
+ };
106
+ async function refreshQuotaCache() {
107
+ if (Date.now() - quotaCache.fetchedAt < QUOTA_CACHE_TTL_MS) {
108
+ return quotaCache;
109
+ }
110
+ const [diagResult, proxyResult] = await Promise.allSettled([
111
+ client.getQuota(),
112
+ client.getProxyDailyUsage(),
113
+ ]);
114
+ quotaCache = {
115
+ diagnostic: diagResult.status === 'fulfilled' ? diagResult.value : quotaCache.diagnostic,
116
+ proxy: proxyResult.status === 'fulfilled' ? proxyResult.value : quotaCache.proxy,
117
+ fetchedAt: Date.now(),
118
+ };
119
+ return quotaCache;
120
+ }
121
+ function buildQuotaFooter(category) {
122
+ const q = quotaCache;
123
+ const parts = [];
124
+ if (category === 'diagnostic' && q.diagnostic) {
125
+ const d = q.diagnostic;
126
+ parts.push(`Diagnostics: ${d.remaining.day} of ${d.limits.day} remaining today (${d.tier})`);
127
+ }
128
+ if (category === 'proxy') {
129
+ // Show one-time extension notice (cleared after first display)
130
+ if (cachedProxyToken?.extensionNotice) {
131
+ parts.push(cachedProxyToken.extensionNotice);
132
+ cachedProxyToken.extensionNotice = undefined;
133
+ }
134
+ if (q.proxy) {
135
+ const remaining = q.proxy.quota - q.proxy.consumed;
136
+ parts.push(`Proxy hours: ${remaining} of ${q.proxy.quota} remaining today`);
137
+ }
138
+ if (cachedProxyToken && cachedProxyToken.expiresAt > Date.now()) {
139
+ const minsLeft = Math.round((cachedProxyToken.expiresAt - Date.now()) / 60000);
140
+ parts.push(`Active token: ${minsLeft} min remaining`);
141
+ }
142
+ }
143
+ if (parts.length === 0)
144
+ return '';
145
+ return '\n---\n' + parts.join(' | ');
146
+ }
147
+ // ── V1 Quota Update Helper ───────────────────────────────────
148
+ function updateQuotaFromV1(data) {
149
+ if (data.quota) {
150
+ quotaCache.diagnostic = {
151
+ can_execute: true,
152
+ tier: data.quota.tier,
153
+ limits: data.quota.limits,
154
+ usage: data.quota.usage,
155
+ remaining: data.quota.available,
156
+ };
157
+ quotaCache.fetchedAt = Date.now();
158
+ }
159
+ }
160
+ // ── Helper ──────────────────────────────────────────────────
161
+ function errorText(err) {
162
+ if (err instanceof types_js_1.ProbeOpsError) {
163
+ const lines = [];
164
+ if (err.statusCode === 429) {
165
+ lines.push('Rate limit exceeded.');
166
+ if (err.retryAfter)
167
+ lines.push(`Retry after: ${err.retryAfter} seconds.`);
168
+ if (err.rateLimitInfo) {
169
+ lines.push(`Limit: ${err.rateLimitInfo.limit} requests, Remaining: ${err.rateLimitInfo.remaining}.`);
170
+ }
171
+ lines.push('Use the probeops://usage resource to check your current quota.');
172
+ return lines.join(' ');
173
+ }
174
+ if (err.statusCode === 401) {
175
+ return 'Authentication failed. Check your PROBEOPS_API_KEY. Get a key at https://probeops.com/dashboard/api-keys';
176
+ }
177
+ if (err.statusCode === 403) {
178
+ return 'Access denied. This feature may require a paid plan. See https://probeops.com/pricing';
179
+ }
180
+ return `ProbeOps API Error (${err.statusCode}): ${err.detail || err.message}`;
181
+ }
182
+ if (err instanceof Error)
183
+ return err.message;
184
+ return String(err);
185
+ }
186
+ // ── MCP Server Setup ────────────────────────────────────────
187
+ const server = new mcp_js_1.McpServer({
188
+ name: 'probeops',
189
+ version: '1.0.0',
190
+ });
191
+ // ── Tools ───────────────────────────────────────────────────
192
+ server.tool('ssl_check', 'Check SSL/TLS certificate for a domain from multiple global regions. Returns certificate details (validity, expiry, issuer, TLS version, SANs) and checks consistency across regions.', { domain: zod_1.z.string().describe('Domain name to check (e.g., "example.com")') }, async ({ domain }) => {
193
+ try {
194
+ const data = await client.sslCheck({ domain });
195
+ updateQuotaFromV1(data);
196
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatSslCheck)(data) + buildQuotaFooter('diagnostic') }] };
197
+ }
198
+ catch (err) {
199
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
200
+ }
201
+ });
202
+ server.tool('dns_lookup', 'Look up DNS records for a domain from multiple global regions. Supports A, AAAA, CNAME, MX, TXT, NS, SOA, CAA, and PTR record types. Useful for checking DNS propagation across regions.', {
203
+ domain: zod_1.z.string().describe('Domain name to look up (e.g., "example.com")'),
204
+ record_type: zod_1.z.enum(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA', 'PTR']).optional().describe('DNS record type (default: A)'),
205
+ }, async ({ domain, record_type }) => {
206
+ try {
207
+ const data = await client.dnsLookup({ domain, record_type });
208
+ updateQuotaFromV1(data);
209
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatDnsLookup)(data) + buildQuotaFooter('diagnostic') }] };
210
+ }
211
+ catch (err) {
212
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
213
+ }
214
+ });
215
+ server.tool('mx_lookup', 'Look up MX (Mail Exchange) records for a domain. Shows mail servers and priorities. Useful for verifying email configuration and troubleshooting email delivery.', { domain: zod_1.z.string().describe('Domain name to look up (e.g., "example.com")') }, async ({ domain }) => {
216
+ try {
217
+ const data = await client.dnsLookup({ domain, record_type: 'MX' });
218
+ updateQuotaFromV1(data);
219
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatDnsLookup)(data) + buildQuotaFooter('diagnostic') }] };
220
+ }
221
+ catch (err) {
222
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
223
+ }
224
+ });
225
+ server.tool('txt_lookup', 'Look up TXT records for a domain. Shows SPF, DKIM, DMARC, domain verification, and other TXT records. Essential for email authentication and domain ownership verification.', { domain: zod_1.z.string().describe('Domain name to look up (e.g., "example.com")') }, async ({ domain }) => {
226
+ try {
227
+ const data = await client.dnsLookup({ domain, record_type: 'TXT' });
228
+ updateQuotaFromV1(data);
229
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatDnsLookup)(data) + buildQuotaFooter('diagnostic') }] };
230
+ }
231
+ catch (err) {
232
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
233
+ }
234
+ });
235
+ server.tool('ns_lookup', 'Look up NS (Nameserver) records for a domain. Shows authoritative DNS servers. Useful for verifying DNS delegation and nameserver configuration.', { domain: zod_1.z.string().describe('Domain name to look up (e.g., "example.com")') }, async ({ domain }) => {
236
+ try {
237
+ const data = await client.dnsLookup({ domain, record_type: 'NS' });
238
+ updateQuotaFromV1(data);
239
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatDnsLookup)(data) + buildQuotaFooter('diagnostic') }] };
240
+ }
241
+ catch (err) {
242
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
243
+ }
244
+ });
245
+ server.tool('cname_lookup', 'Look up CNAME (Canonical Name) records for a domain. Shows DNS aliases. Useful for verifying CDN configuration and subdomain routing.', { domain: zod_1.z.string().describe('Domain or subdomain to look up (e.g., "www.example.com")') }, async ({ domain }) => {
246
+ try {
247
+ const data = await client.dnsLookup({ domain, record_type: 'CNAME' });
248
+ updateQuotaFromV1(data);
249
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatDnsLookup)(data) + buildQuotaFooter('diagnostic') }] };
250
+ }
251
+ catch (err) {
252
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
253
+ }
254
+ });
255
+ server.tool('caa_lookup', 'Look up CAA (Certificate Authority Authorization) DNS records for a domain. Shows which certificate authorities are authorized to issue SSL/TLS certificates.', { domain: zod_1.z.string().describe('Domain name to look up (e.g., "example.com")') }, async ({ domain }) => {
256
+ try {
257
+ const data = await client.dnsLookup({ domain, record_type: 'CAA' });
258
+ updateQuotaFromV1(data);
259
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatDnsLookup)(data) + buildQuotaFooter('diagnostic') }] };
260
+ }
261
+ catch (err) {
262
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
263
+ }
264
+ });
265
+ server.tool('reverse_dns_lookup', 'Perform reverse DNS (PTR) lookup for an IP address. Finds the hostname associated with an IP. Essential for email deliverability verification and server identification.', { ip: zod_1.z.string().describe('IP address to look up (e.g., "8.8.8.8")') }, async ({ ip }) => {
266
+ try {
267
+ const data = await client.dnsLookup({ domain: ip, record_type: 'PTR' });
268
+ updateQuotaFromV1(data);
269
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatDnsLookup)(data) + buildQuotaFooter('diagnostic') }] };
270
+ }
271
+ catch (err) {
272
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
273
+ }
274
+ });
275
+ server.tool('is_it_down', 'Check if a website is up, down, or partially available from multiple global regions. Returns HTTP status and response time per region.', { url: zod_1.z.string().describe('Full URL to check (e.g., "https://example.com")') }, async ({ url }) => {
276
+ try {
277
+ const data = await client.isItDown({ url });
278
+ updateQuotaFromV1(data);
279
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatIsItDown)(data) + buildQuotaFooter('diagnostic') }] };
280
+ }
281
+ catch (err) {
282
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
283
+ }
284
+ });
285
+ server.tool('latency_test', 'Measure network latency (ping) to a target from multiple global regions. Returns per-region latency plus average, min, and max.', { target: zod_1.z.string().describe('Hostname or IP to test (e.g., "example.com" or "8.8.8.8")') }, async ({ target }) => {
286
+ try {
287
+ const data = await client.latencyTest({ target });
288
+ updateQuotaFromV1(data);
289
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatLatencyTest)(data) + buildQuotaFooter('diagnostic') }] };
290
+ }
291
+ catch (err) {
292
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
293
+ }
294
+ });
295
+ server.tool('traceroute', 'Trace the network path to a target from one or more global regions. Shows each hop with latency. Supports TCP, UDP, and ICMP protocols.', {
296
+ target: zod_1.z.string().describe('Hostname or IP to trace (e.g., "example.com")'),
297
+ protocol: zod_1.z.enum(['tcp', 'udp', 'icmp']).optional().describe('Protocol to use (default: tcp)'),
298
+ }, async ({ target, protocol }) => {
299
+ try {
300
+ const data = await client.traceroute({ target, protocol });
301
+ updateQuotaFromV1(data);
302
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatTraceroute)(data) + buildQuotaFooter('diagnostic') }] };
303
+ }
304
+ catch (err) {
305
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
306
+ }
307
+ });
308
+ server.tool('port_check', 'Check if a specific port is open, closed, or filtered on a target from multiple global regions. Useful for verifying firewall rules and service availability.', {
309
+ target: zod_1.z.string().describe('Hostname or IP to check (e.g., "example.com")'),
310
+ port: zod_1.z.number().int().min(1).max(65535).describe('Port number to check (1-65535)'),
311
+ }, async ({ target, port }) => {
312
+ try {
313
+ const data = await client.portCheck({ target, port });
314
+ updateQuotaFromV1(data);
315
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatPortCheck)(data) + buildQuotaFooter('diagnostic') }] };
316
+ }
317
+ catch (err) {
318
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
319
+ }
320
+ });
321
+ // ── New Tools (via v1/run) ───────────────────────────────────
322
+ server.tool('ping', 'ICMP ping a target from multiple global regions. Returns packet loss and round-trip times. Useful for basic reachability and latency testing.', { target: zod_1.z.string().describe('Hostname or IP to ping (e.g., "example.com" or "8.8.8.8")') }, async ({ target }) => {
323
+ try {
324
+ const data = await client.run('ping', target);
325
+ updateQuotaFromV1(data);
326
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGenericResult)(data) + buildQuotaFooter('diagnostic') }] };
327
+ }
328
+ catch (err) {
329
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
330
+ }
331
+ });
332
+ server.tool('whois', 'Look up WHOIS registration information for a domain. Shows registrar, creation/expiry dates, nameservers, and registrant info.', { domain: zod_1.z.string().describe('Domain name to look up (e.g., "example.com")') }, async ({ domain }) => {
333
+ try {
334
+ const data = await client.run('whois', domain);
335
+ updateQuotaFromV1(data);
336
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGenericResult)(data) + buildQuotaFooter('diagnostic') }] };
337
+ }
338
+ catch (err) {
339
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
340
+ }
341
+ });
342
+ server.tool('nmap_port_check', 'Check if multiple ports are open or closed on a target from multiple global regions using nmap. Checks specified ports (not a full scan).', {
343
+ target: zod_1.z.string().describe('Hostname or IP to check (e.g., "example.com")'),
344
+ ports: zod_1.z.string().optional().describe('Ports to check (e.g., "80,443" or "22,80,443,8080"). Default: common ports 1-1024'),
345
+ }, async ({ target, ports }) => {
346
+ try {
347
+ const params = {};
348
+ if (ports)
349
+ params.ports = ports;
350
+ const data = await client.run('nmap', target, params);
351
+ updateQuotaFromV1(data);
352
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGenericResult)(data) + buildQuotaFooter('diagnostic') }] };
353
+ }
354
+ catch (err) {
355
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
356
+ }
357
+ });
358
+ server.tool('tcp_ping', 'Measure TCP-level latency to a specific port on a target from multiple global regions. More reliable than ICMP ping for hosts that block ICMP.', {
359
+ target: zod_1.z.string().describe('Hostname or IP to test (e.g., "example.com")'),
360
+ port: zod_1.z.number().int().min(1).max(65535).describe('Port number to TCP ping (e.g., 443)'),
361
+ }, async ({ target, port }) => {
362
+ try {
363
+ const data = await client.run('tcping', target, { port });
364
+ updateQuotaFromV1(data);
365
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGenericResult)(data) + buildQuotaFooter('diagnostic') }] };
366
+ }
367
+ catch (err) {
368
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
369
+ }
370
+ });
371
+ server.tool('keyword_check', 'Check if a keyword or phrase exists on a web page from multiple global regions. Useful for verifying content delivery and geo-specific content.', {
372
+ url: zod_1.z.string().describe('URL to check (e.g., "https://example.com")'),
373
+ keyword: zod_1.z.string().describe('Keyword or phrase to search for on the page'),
374
+ }, async ({ url, keyword }) => {
375
+ try {
376
+ const data = await client.run('keyword_check', url, { keyword });
377
+ updateQuotaFromV1(data);
378
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGenericResult)(data) + buildQuotaFooter('diagnostic') }] };
379
+ }
380
+ catch (err) {
381
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
382
+ }
383
+ });
384
+ server.tool('websocket_check', 'Check WebSocket endpoint health and connectivity from multiple global regions. Verifies that a WebSocket server is accepting connections.', { url: zod_1.z.string().describe('WebSocket URL to check (e.g., "wss://example.com/ws")') }, async ({ url }) => {
385
+ try {
386
+ const data = await client.run('websocket_check', url);
387
+ updateQuotaFromV1(data);
388
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGenericResult)(data) + buildQuotaFooter('diagnostic') }] };
389
+ }
390
+ catch (err) {
391
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
392
+ }
393
+ });
394
+ server.tool('banner_grab', 'Grab the service banner from a specific port on a target from multiple global regions. Identifies service type and version.', {
395
+ target: zod_1.z.string().describe('Hostname or IP to check (e.g., "example.com")'),
396
+ port: zod_1.z.number().int().min(1).max(65535).describe('Port number to grab banner from (e.g., 22, 80, 443)'),
397
+ }, async ({ target, port }) => {
398
+ try {
399
+ const data = await client.run('banner_grab', target, { port });
400
+ updateQuotaFromV1(data);
401
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGenericResult)(data) + buildQuotaFooter('diagnostic') }] };
402
+ }
403
+ catch (err) {
404
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
405
+ }
406
+ });
407
+ server.tool('api_health', 'Check API endpoint health from multiple global regions. Sends an HTTP request and reports status code, response time, and availability.', { url: zod_1.z.string().describe('API URL to check (e.g., "https://api.example.com/health")') }, async ({ url }) => {
408
+ try {
409
+ const data = await client.run('api_health', url);
410
+ updateQuotaFromV1(data);
411
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGenericResult)(data) + buildQuotaFooter('diagnostic') }] };
412
+ }
413
+ catch (err) {
414
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
415
+ }
416
+ });
417
+ // ── Proxy Tools ─────────────────────────────────────────────
418
+ server.tool('get_geo_proxy', 'Get geo-proxy credentials for a specific region. Returns a proxy JWT token with tier-based quota info. The token can be used with Playwright or any HTTPS proxy client to browse the web from that geographic region. A single token works across all regions.', {
419
+ region: zod_1.z.enum(['eu-central', 'us-east', 'ap-south', 'us-west', 'ca-central', 'ap-southeast']).describe('Region to proxy through'),
420
+ }, async ({ region }) => {
421
+ try {
422
+ refreshQuotaCache().catch(() => { });
423
+ const data = await getOrCreateProxyToken(region);
424
+ const proxyServer = getProxyServer(data, region);
425
+ const fqdn = proxyServer.replace(/^https?:\/\//, '').replace(/:.*$/, '');
426
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatGeoProxy)(data, fqdn) + buildQuotaFooter('proxy') }] };
427
+ }
428
+ catch (err) {
429
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
430
+ }
431
+ });
432
+ server.tool('geo_browse', 'Browse a URL from a specific geographic region using ProbeOps geo-proxy. Launches a real browser through a geo-located proxy and returns the page content and a screenshot. One-step tool — no manual Playwright setup needed.', {
433
+ url: zod_1.z.string().describe('URL to browse (e.g., "https://example.com/pricing")'),
434
+ region: zod_1.z.enum(['eu-central', 'us-east', 'ap-south', 'us-west', 'ca-central', 'ap-southeast']).describe('Region to browse from'),
435
+ action: zod_1.z.enum(['screenshot', 'content', 'both']).optional().describe('What to capture: screenshot, page content text, or both (default: both)'),
436
+ }, async ({ url, region, action }) => {
437
+ refreshQuotaCache().catch(() => { });
438
+ const captureAction = action || 'both';
439
+ // Step 1: Get proxy credentials (reuses cached token if valid)
440
+ let proxyData;
441
+ try {
442
+ proxyData = await getOrCreateProxyToken(region);
443
+ }
444
+ catch (err) {
445
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
446
+ }
447
+ // Step 2: Get proxy server URL from API response (not hardcoded)
448
+ const proxyServer = getProxyServer(proxyData, region);
449
+ // Step 3: Try Playwright (full browser rendering)
450
+ try {
451
+ const { chromium } = await import('playwright-core');
452
+ const browser = await chromium.launch({ headless: true });
453
+ try {
454
+ const context = await browser.newContext({
455
+ proxy: {
456
+ server: proxyServer,
457
+ username: proxyData.jwt_token,
458
+ password: '',
459
+ },
460
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
461
+ viewport: { width: 1280, height: 720 },
462
+ });
463
+ const page = await context.newPage();
464
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
465
+ const title = await page.title();
466
+ const finalUrl = page.url();
467
+ const content = [];
468
+ // Capture text content
469
+ if (captureAction === 'content' || captureAction === 'both') {
470
+ const text = await page.evaluate('document.body.innerText');
471
+ const truncated = text.length > 5000 ? text.slice(0, 5000) + '\n\n... [truncated, full page is ' + text.length + ' chars]' : text;
472
+ content.push({
473
+ type: 'text',
474
+ text: [
475
+ `Geo-Browse: ${url} from ${region}`,
476
+ `Proxy: ${proxyServer}`,
477
+ `Final URL: ${finalUrl}`,
478
+ `Title: ${title}`,
479
+ `Quota: ${proxyData.daily_usage.consumed}/${proxyData.daily_usage.quota} tokens used today`,
480
+ '',
481
+ 'Page Content:',
482
+ truncated,
483
+ ].join('\n'),
484
+ });
485
+ }
486
+ // Capture screenshot
487
+ if (captureAction === 'screenshot' || captureAction === 'both') {
488
+ const screenshot = await page.screenshot({ type: 'png', fullPage: false });
489
+ if (captureAction === 'screenshot') {
490
+ content.push({
491
+ type: 'text',
492
+ text: [
493
+ `Geo-Browse: ${url} from ${region}`,
494
+ `Proxy: ${proxyServer}`,
495
+ `Final URL: ${finalUrl}`,
496
+ `Title: ${title}`,
497
+ `Quota: ${proxyData.daily_usage.consumed}/${proxyData.daily_usage.quota} tokens used today`,
498
+ ].join('\n'),
499
+ });
500
+ }
501
+ content.push({
502
+ type: 'image',
503
+ data: screenshot.toString('base64'),
504
+ mimeType: 'image/png',
505
+ });
506
+ }
507
+ await context.close();
508
+ // Append proxy quota footer to the first text content block
509
+ const footer = buildQuotaFooter('proxy');
510
+ if (footer) {
511
+ const firstText = content.find((c) => c.type === 'text');
512
+ if (firstText)
513
+ firstText.text += footer;
514
+ }
515
+ return { content };
516
+ }
517
+ finally {
518
+ await browser.close();
519
+ }
520
+ }
521
+ catch (playwrightError) {
522
+ // Step 4: Fallback — HTTP fetch through proxy (no browser needed)
523
+ try {
524
+ const { HttpsProxyAgent } = await import('https-proxy-agent');
525
+ // Embed JWT as username in proxy URL for Basic auth (matches Rust proxy expectations)
526
+ const proxyUrl = new URL(proxyServer);
527
+ proxyUrl.username = proxyData.jwt_token;
528
+ proxyUrl.password = '';
529
+ const agent = new HttpsProxyAgent(proxyUrl.toString());
530
+ const response = await fetch(url, {
531
+ headers: {
532
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
533
+ },
534
+ // @ts-expect-error Node.js fetch supports agent via dispatcher
535
+ dispatcher: agent,
536
+ signal: AbortSignal.timeout(30000),
537
+ });
538
+ const html = await response.text();
539
+ const truncatedHtml = html.length > 5000 ? html.slice(0, 5000) + '\n\n... [truncated]' : html;
540
+ return {
541
+ content: [{
542
+ type: 'text',
543
+ text: [
544
+ `Geo-Browse (HTTP fallback): ${url} from ${region}`,
545
+ `Status: ${response.status} ${response.statusText}`,
546
+ `Content-Type: ${response.headers.get('content-type') || 'unknown'}`,
547
+ `Quota: ${proxyData.daily_usage.consumed}/${proxyData.daily_usage.quota} tokens used today`,
548
+ '',
549
+ 'Note: Full browser rendering requires Chromium. Install with: npx playwright install chromium',
550
+ '',
551
+ 'Raw HTML:',
552
+ truncatedHtml,
553
+ ].join('\n') + buildQuotaFooter('proxy'),
554
+ }],
555
+ };
556
+ }
557
+ catch (fetchError) {
558
+ // Both Playwright and HTTP fetch failed — return helpful error
559
+ const pwErr = playwrightError instanceof Error ? playwrightError.message : String(playwrightError);
560
+ return {
561
+ content: [{
562
+ type: 'text',
563
+ text: [
564
+ `Geo-Browse failed for ${url} from ${region}`,
565
+ '',
566
+ `Playwright error: ${pwErr}`,
567
+ '',
568
+ 'To use full browser rendering, install Chromium:',
569
+ ' npx playwright install chromium',
570
+ '',
571
+ 'Proxy credentials were obtained successfully:',
572
+ ` Token: ${proxyData.token_id}`,
573
+ ` Region: ${region}`,
574
+ ` Proxy: ${proxyServer}`,
575
+ ` Expires: ${proxyData.expires_at}`,
576
+ ` Quota: ${proxyData.daily_usage.consumed}/${proxyData.daily_usage.quota} tokens used today`,
577
+ ].join('\n'),
578
+ }],
579
+ isError: true,
580
+ };
581
+ }
582
+ }
583
+ });
584
+ server.tool('account_status', 'Show your ProbeOps account status: subscription tier, diagnostic quota (minute/hour/day/month), proxy token quota, and active proxy token details. Use this to check remaining quota before running multiple tools.', {}, async () => {
585
+ try {
586
+ // Force-refresh cache (awaited)
587
+ quotaCache.fetchedAt = 0;
588
+ const q = await refreshQuotaCache();
589
+ const activeToken = cachedProxyToken && cachedProxyToken.expiresAt > Date.now()
590
+ ? {
591
+ token_id: cachedProxyToken.data.token_id,
592
+ expires_at: cachedProxyToken.data.expires_at,
593
+ allowed_regions: cachedProxyToken.data.allowed_regions || [cachedProxyToken.data.region],
594
+ }
595
+ : null;
596
+ return { content: [{ type: 'text', text: (0, formatters_js_1.formatAccountStatus)(q, activeToken) }] };
597
+ }
598
+ catch (err) {
599
+ return { content: [{ type: 'text', text: errorText(err) }], isError: true };
600
+ }
601
+ });
602
+ // ── Resources ───────────────────────────────────────────────
603
+ server.resource('regions', 'probeops://regions', { description: 'List of available probe regions with location and status' }, async () => {
604
+ try {
605
+ const data = await client.getRegions();
606
+ return { contents: [{ uri: 'probeops://regions', text: (0, formatters_js_1.formatRegions)(data), mimeType: 'text/plain' }] };
607
+ }
608
+ catch (err) {
609
+ return { contents: [{ uri: 'probeops://regions', text: errorText(err), mimeType: 'text/plain' }] };
610
+ }
611
+ });
612
+ server.resource('proxy-regions', 'probeops://proxy-regions', { description: 'List of available geo-proxy regions with proxy URLs for Playwright/browser proxy usage' }, async () => {
613
+ try {
614
+ // Fetch a token to get live proxy_nodes map from API
615
+ const data = await getOrCreateProxyToken('us-east');
616
+ if (data.proxy_nodes && Object.keys(data.proxy_nodes).length > 0) {
617
+ const regions = Object.entries(data.proxy_nodes).map(([region, url]) => {
618
+ const fqdn = url.replace(/^https?:\/\//, '').replace(/:.*$/, '');
619
+ return { region, fqdn, location: region, port: 443 };
620
+ });
621
+ return { contents: [{ uri: 'probeops://proxy-regions', text: (0, formatters_js_1.formatProxyRegions)(regions), mimeType: 'text/plain' }] };
622
+ }
623
+ }
624
+ catch { /* fall through to static list */ }
625
+ // Fallback: static list (only if API unavailable)
626
+ const fallback = [
627
+ { region: 'eu-central', fqdn: 'node-1-eu-central.probeops.com', location: 'Helsinki, Finland', port: 443 },
628
+ { region: 'us-east', fqdn: 'node-1-us-east.probeops.com', location: 'Ashburn, USA', port: 443 },
629
+ { region: 'ap-south', fqdn: 'node-1-ap-south.probeops.com', location: 'Mumbai, India', port: 443 },
630
+ { region: 'us-west', fqdn: 'node-1-us-west.probeops.com', location: 'Oregon, USA', port: 443 },
631
+ { region: 'ca-central', fqdn: 'node-1-ca-central.probeops.com', location: 'Canada', port: 443 },
632
+ { region: 'ap-southeast', fqdn: 'node-1-ap-southeast.probeops.com', location: 'Sydney, Australia', port: 443 },
633
+ ];
634
+ return { contents: [{ uri: 'probeops://proxy-regions', text: (0, formatters_js_1.formatProxyRegions)(fallback), mimeType: 'text/plain' }] };
635
+ });
636
+ server.resource('usage', 'probeops://usage', { description: 'Current API usage and remaining quota for your ProbeOps account (diagnostic + proxy)' }, async () => {
637
+ try {
638
+ quotaCache.fetchedAt = 0;
639
+ const q = await refreshQuotaCache();
640
+ const activeToken = cachedProxyToken && cachedProxyToken.expiresAt > Date.now()
641
+ ? {
642
+ token_id: cachedProxyToken.data.token_id,
643
+ expires_at: cachedProxyToken.data.expires_at,
644
+ allowed_regions: cachedProxyToken.data.allowed_regions || [cachedProxyToken.data.region],
645
+ }
646
+ : null;
647
+ return { contents: [{ uri: 'probeops://usage', text: (0, formatters_js_1.formatAccountStatus)(q, activeToken), mimeType: 'text/plain' }] };
648
+ }
649
+ catch (err) {
650
+ return { contents: [{ uri: 'probeops://usage', text: errorText(err), mimeType: 'text/plain' }] };
651
+ }
652
+ });
653
+ // ── Start Server ────────────────────────────────────────────
654
+ async function main() {
655
+ const transport = new stdio_js_1.StdioServerTransport();
656
+ await server.connect(transport);
657
+ }
658
+ main().catch((err) => {
659
+ console.error('Failed to start ProbeOps MCP server:', err);
660
+ process.exit(1);
661
+ });
662
+ //# sourceMappingURL=index.js.map