@chrischall/tripadvisor-mcp 0.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/.claude-plugin/marketplace.json +37 -0
- package/.claude-plugin/plugin.json +24 -0
- package/.mcp.json +14 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/SKILL.md +70 -0
- package/dist/bundle.js +38637 -0
- package/dist/client.js +149 -0
- package/dist/index.js +16 -0
- package/dist/tools/location.js +55 -0
- package/dist/tools/search.js +29 -0
- package/dist/tools/shared.js +26 -0
- package/dist/tools/web.js +28 -0
- package/dist/version.js +2 -0
- package/dist/web/client.js +114 -0
- package/dist/web/config.js +22 -0
- package/dist/web/transport.js +46 -0
- package/package.json +62 -0
- package/server.json +29 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { loadDotenvSafely, readEnvVar, formatApiError, McpToolError } from '@chrischall/mcp-utils';
|
|
4
|
+
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. the
|
|
5
|
+
// .mcpb bundle). loadDotenvSafely never lets .env override a host-provided value.
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
|
|
8
|
+
const BASE_URL = 'https://api.content.tripadvisor.com/api/v1';
|
|
9
|
+
const SERVICE = 'TripAdvisor Content API';
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
11
|
+
// The Content API free tier is 5,000 calls/month, so identical GETs in quick
|
|
12
|
+
// succession are wasteful. Search results get a 5-minute TTL by default;
|
|
13
|
+
// override with TRIPADVISOR_CACHE_TTL (seconds; 0 = off).
|
|
14
|
+
const DEFAULT_CACHE_TTL_MS = 300_000;
|
|
15
|
+
// Location details/photos/reviews change slowly — 1 hour by default.
|
|
16
|
+
// Override with TRIPADVISOR_STATIC_CACHE_TTL (seconds; 0 = off).
|
|
17
|
+
const DEFAULT_STATIC_CACHE_TTL_MS = 3_600_000;
|
|
18
|
+
// Bound the cache so a long-lived server doesn't grow unbounded across many
|
|
19
|
+
// distinct paths; oldest entries are evicted first.
|
|
20
|
+
const CACHE_MAX_ENTRIES = 256;
|
|
21
|
+
// Cap a server-supplied Retry-After so one bad header can't stall a tool call.
|
|
22
|
+
const MAX_RETRY_AFTER_MS = 10_000;
|
|
23
|
+
/** Resolve a cache TTL (ms) from an env var holding seconds. A blank or
|
|
24
|
+
* non-numeric value falls back to `defaultMs`; a valid `0` disables caching. */
|
|
25
|
+
function readCacheTtlMs(envVar, defaultMs) {
|
|
26
|
+
const raw = readEnvVar(envVar);
|
|
27
|
+
if (raw === undefined)
|
|
28
|
+
return defaultMs;
|
|
29
|
+
const secs = Number(raw);
|
|
30
|
+
return Number.isFinite(secs) && secs >= 0 ? secs * 1000 : defaultMs;
|
|
31
|
+
}
|
|
32
|
+
export class TripAdvisorClient {
|
|
33
|
+
apiKey;
|
|
34
|
+
configError;
|
|
35
|
+
referer;
|
|
36
|
+
fetchImpl;
|
|
37
|
+
sleep;
|
|
38
|
+
cacheTtlMs;
|
|
39
|
+
staticCacheTtlMs;
|
|
40
|
+
now;
|
|
41
|
+
cache = new Map();
|
|
42
|
+
/**
|
|
43
|
+
* Defer the config error so the server still boots (and answers the host's
|
|
44
|
+
* install-time tools/list probe) when TRIPADVISOR_API_KEY isn't set yet. The
|
|
45
|
+
* error is re-raised at request time via requireKey().
|
|
46
|
+
*/
|
|
47
|
+
constructor(opts = {}) {
|
|
48
|
+
this.now = opts.now ?? Date.now;
|
|
49
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
50
|
+
this.cacheTtlMs = opts.cacheTtlMs ?? readCacheTtlMs('TRIPADVISOR_CACHE_TTL', DEFAULT_CACHE_TTL_MS);
|
|
51
|
+
this.staticCacheTtlMs =
|
|
52
|
+
opts.staticCacheTtlMs ?? readCacheTtlMs('TRIPADVISOR_STATIC_CACHE_TTL', DEFAULT_STATIC_CACHE_TTL_MS);
|
|
53
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
54
|
+
this.referer = readEnvVar('TRIPADVISOR_REFERER');
|
|
55
|
+
const key = readEnvVar('TRIPADVISOR_API_KEY');
|
|
56
|
+
if (!key) {
|
|
57
|
+
this.apiKey = null;
|
|
58
|
+
this.configError = new McpToolError('TRIPADVISOR_API_KEY environment variable is required', {
|
|
59
|
+
hint: 'Create a Content API key at https://www.tripadvisor.com/developers and set TRIPADVISOR_API_KEY in your MCP host env or .env (free tier: 5,000 calls/month).',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
this.apiKey = key;
|
|
64
|
+
this.configError = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
requireKey() {
|
|
68
|
+
if (this.configError)
|
|
69
|
+
throw this.configError;
|
|
70
|
+
return this.apiKey;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* GET a JSON resource. `path` must already include any query string but NOT
|
|
74
|
+
* the API key — the key is appended here at fetch time so it never appears in
|
|
75
|
+
* cache keys or error messages. Responses are cached by path; `cache:
|
|
76
|
+
* 'static'` selects the longer details/photos/reviews TTL
|
|
77
|
+
* (TRIPADVISOR_STATIC_CACHE_TTL), the default 'dynamic' tier
|
|
78
|
+
* (TRIPADVISOR_CACHE_TTL) is for searches.
|
|
79
|
+
*/
|
|
80
|
+
async get(path, opts = {}) {
|
|
81
|
+
const ttl = opts.cache === 'static' ? this.staticCacheTtlMs : this.cacheTtlMs;
|
|
82
|
+
if (ttl > 0) {
|
|
83
|
+
const hit = this.cache.get(path);
|
|
84
|
+
if (hit && hit.expiresAt > this.now())
|
|
85
|
+
return hit.value;
|
|
86
|
+
}
|
|
87
|
+
const value = await this.request(path);
|
|
88
|
+
if (ttl > 0) {
|
|
89
|
+
if (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
90
|
+
// Evict expired entries first; if still full, drop the oldest (Map
|
|
91
|
+
// preserves insertion order, so the first key is the oldest).
|
|
92
|
+
const t = this.now();
|
|
93
|
+
for (const [k, v] of this.cache)
|
|
94
|
+
if (v.expiresAt <= t)
|
|
95
|
+
this.cache.delete(k);
|
|
96
|
+
while (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
97
|
+
const oldest = this.cache.keys().next().value;
|
|
98
|
+
if (oldest === undefined)
|
|
99
|
+
break;
|
|
100
|
+
this.cache.delete(oldest);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.cache.set(path, { expiresAt: this.now() + ttl, value });
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
async request(path, isRetry = false) {
|
|
108
|
+
const key = this.requireKey();
|
|
109
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
110
|
+
const url = `${BASE_URL}${path}${sep}key=${encodeURIComponent(key)}`;
|
|
111
|
+
const headers = { Accept: 'application/json' };
|
|
112
|
+
// A domain-restricted key requires a matching Referer on every request.
|
|
113
|
+
if (this.referer)
|
|
114
|
+
headers.Referer = this.referer;
|
|
115
|
+
const res = await this.fetchImpl(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
116
|
+
if (res.ok)
|
|
117
|
+
return (await res.json());
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
if (res.status === 429 && !isRetry) {
|
|
120
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
121
|
+
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS) : 1_000;
|
|
122
|
+
await this.sleep(delayMs);
|
|
123
|
+
return this.request(path, true);
|
|
124
|
+
}
|
|
125
|
+
// `path` deliberately excludes the key, so these messages can't leak it.
|
|
126
|
+
if (res.status === 401) {
|
|
127
|
+
throw new McpToolError(`${SERVICE} returned 401 Unauthorized — TRIPADVISOR_API_KEY is missing or invalid.`, {
|
|
128
|
+
hint: 'Check the key in your MCP host env or .env; create one at https://www.tripadvisor.com/developers',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (res.status === 403) {
|
|
132
|
+
throw new McpToolError(`${SERVICE} returned 403 Forbidden — the key is valid but blocked, usually by its domain/IP restriction (set at key creation) or an unapproved application.`, {
|
|
133
|
+
hint: 'If the key is domain-restricted, set TRIPADVISOR_REFERER to a matching https://domain; if IP-restricted, call from a listed address.',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (res.status === 429) {
|
|
137
|
+
throw new McpToolError(`${SERVICE} rate limit or monthly quota exceeded (429).`, {
|
|
138
|
+
hint: 'The free tier is 5,000 calls/month — check usage at https://www.tripadvisor.com/developers. Cached reads (TRIPADVISOR_CACHE_TTL) stretch the quota.',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
throw new McpToolError(formatApiError(res.status, 'GET', path, text, { service: SERVICE }));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Module-level singleton shared by every tool module. Constructed here (not in
|
|
146
|
+
* index.ts) so the deferred-config-error pattern holds: the server boots and
|
|
147
|
+
* lists tools even without a key — the error surfaces on the first tool call.
|
|
148
|
+
*/
|
|
149
|
+
export const client = new TripAdvisorClient();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runMcp } from '@chrischall/mcp-utils';
|
|
3
|
+
import { VERSION } from './version.js';
|
|
4
|
+
import { registerSearchTools } from './tools/search.js';
|
|
5
|
+
import { registerLocationTools } from './tools/location.js';
|
|
6
|
+
import { registerWebTools } from './tools/web.js';
|
|
7
|
+
// The TripAdvisorClient is a module-level singleton (imported by each tool
|
|
8
|
+
// module) that defers its config error to the first request — so the server
|
|
9
|
+
// boots and answers the host's install-time tools/list probe even without
|
|
10
|
+
// TRIPADVISOR_API_KEY.
|
|
11
|
+
await runMcp({
|
|
12
|
+
name: 'tripadvisor-mcp',
|
|
13
|
+
version: VERSION,
|
|
14
|
+
banner: '[tripadvisor-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
|
|
15
|
+
tools: [registerSearchTools, registerLocationTools, registerWebTools],
|
|
16
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
import { LocationId, qs } from './shared.js';
|
|
5
|
+
/** Photo source filter: comma-separated list of the three allowed origins. */
|
|
6
|
+
const PhotoSource = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(/^(Expert|Management|Traveler)(,(Expert|Management|Traveler))*$/, 'must be a comma-separated list of: Expert, Management, Traveler');
|
|
9
|
+
export function registerLocationTools(server) {
|
|
10
|
+
server.registerTool('ta_get_location_details', {
|
|
11
|
+
description: 'Get full details for a TripAdvisor location: name, address, coordinates, rating, ranking, subratings, awards, review count, amenities, hours, and listing URLs.',
|
|
12
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
13
|
+
inputSchema: {
|
|
14
|
+
locationId: LocationId,
|
|
15
|
+
language: z.string().optional().describe('Result language code (default: en)'),
|
|
16
|
+
currency: z.string().optional().describe('ISO 4217 currency code for prices (default: USD)'),
|
|
17
|
+
},
|
|
18
|
+
}, async ({ locationId, language, currency }) => {
|
|
19
|
+
const data = await client.get(`/location/${locationId}/details${qs({ language, currency })}`, {
|
|
20
|
+
cache: 'static',
|
|
21
|
+
});
|
|
22
|
+
return textResult(data);
|
|
23
|
+
});
|
|
24
|
+
server.registerTool('ta_get_location_photos', {
|
|
25
|
+
description: 'Get photos for a TripAdvisor location (multi-size image URLs, captions, sources).',
|
|
26
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
27
|
+
inputSchema: {
|
|
28
|
+
locationId: LocationId,
|
|
29
|
+
language: z.string().optional().describe('Caption language code (default: en)'),
|
|
30
|
+
limit: z.number().int().positive().optional().describe('Number of photos to return'),
|
|
31
|
+
offset: z.number().int().min(0).optional().describe('Index of the first photo'),
|
|
32
|
+
source: PhotoSource.optional().describe('Comma-separated photo sources to allow: Expert, Management, Traveler (default: all)'),
|
|
33
|
+
},
|
|
34
|
+
}, async ({ locationId, language, limit, offset, source }) => {
|
|
35
|
+
const data = await client.get(`/location/${locationId}/photos${qs({ language, limit, offset, source })}`, {
|
|
36
|
+
cache: 'static',
|
|
37
|
+
});
|
|
38
|
+
return textResult(data);
|
|
39
|
+
});
|
|
40
|
+
server.registerTool('ta_get_location_reviews', {
|
|
41
|
+
description: 'Get the most recent reviews for a TripAdvisor location (up to 5 per call; page with offset).',
|
|
42
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
43
|
+
inputSchema: {
|
|
44
|
+
locationId: LocationId,
|
|
45
|
+
language: z.string().optional().describe('Review language code (default: en)'),
|
|
46
|
+
limit: z.number().int().positive().optional().describe('Number of reviews to return'),
|
|
47
|
+
offset: z.number().int().min(0).optional().describe('Index of the first review'),
|
|
48
|
+
},
|
|
49
|
+
}, async ({ locationId, language, limit, offset }) => {
|
|
50
|
+
const data = await client.get(`/location/${locationId}/reviews${qs({ language, limit, offset })}`, {
|
|
51
|
+
cache: 'static',
|
|
52
|
+
});
|
|
53
|
+
return textResult(data);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
import { LatLong, searchFilterParams, qs } from './shared.js';
|
|
5
|
+
export function registerSearchTools(server) {
|
|
6
|
+
server.registerTool('ta_search_locations', {
|
|
7
|
+
description: 'Search TripAdvisor locations (hotels, restaurants, attractions, geos) by name. Returns up to 10 matches with location_id for the detail tools.',
|
|
8
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
9
|
+
inputSchema: {
|
|
10
|
+
searchQuery: z.string().min(1).describe('Text to search location names for'),
|
|
11
|
+
latLong: LatLong.optional().describe('Center point to scope the search, e.g. "42.3455,-71.10767"'),
|
|
12
|
+
...searchFilterParams,
|
|
13
|
+
},
|
|
14
|
+
}, async ({ searchQuery, latLong, category, phone, address, radius, radiusUnit, language }) => {
|
|
15
|
+
const data = await client.get(`/location/search${qs({ searchQuery, category, phone, address, latLong, radius, radiusUnit, language })}`, { cache: 'dynamic' });
|
|
16
|
+
return textResult(data);
|
|
17
|
+
});
|
|
18
|
+
server.registerTool('ta_search_nearby', {
|
|
19
|
+
description: 'Find TripAdvisor locations near a latitude/longitude. Returns up to 10 locations with location_id for the detail tools.',
|
|
20
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
21
|
+
inputSchema: {
|
|
22
|
+
latLong: LatLong.describe('Center point, e.g. "42.3455,-71.10767"'),
|
|
23
|
+
...searchFilterParams,
|
|
24
|
+
},
|
|
25
|
+
}, async ({ latLong, category, phone, address, radius, radiusUnit, language }) => {
|
|
26
|
+
const data = await client.get(`/location/nearby_search${qs({ latLong, category, phone, address, radius, radiusUnit, language })}`, { cache: 'dynamic' });
|
|
27
|
+
return textResult(data);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildQueryString } from '@chrischall/mcp-utils';
|
|
3
|
+
/** TripAdvisor location id — a positive integer (interpolated into the path). */
|
|
4
|
+
export const LocationId = z.number().int().positive().describe('TripAdvisor location ID (from a search tool)');
|
|
5
|
+
/** `"lat,long"` pair, e.g. `"42.3455,-71.10767"`. */
|
|
6
|
+
export const LatLong = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(/^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/, 'must be a "lat,long" pair like "42.3455,-71.10767"');
|
|
9
|
+
/** Search category filter — the four values the Content API accepts. */
|
|
10
|
+
export const Category = z.enum(['hotels', 'attractions', 'restaurants', 'geos']);
|
|
11
|
+
/** Filters shared by the two search endpoints. */
|
|
12
|
+
export const searchFilterParams = {
|
|
13
|
+
category: Category.optional().describe('Restrict results to one property type'),
|
|
14
|
+
phone: z.string().optional().describe('Phone number filter (spaces/dashes ok, no leading "+")'),
|
|
15
|
+
address: z.string().optional().describe('Address filter'),
|
|
16
|
+
radius: z.number().positive().optional().describe('Search radius around latLong (must be > 0)'),
|
|
17
|
+
radiusUnit: z.enum(['km', 'mi', 'm']).optional().describe('Unit for radius'),
|
|
18
|
+
language: z.string().optional().describe('Result language code (default: en)'),
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Build a `?a=b&c=d` query string, dropping undefined values. Thin wrapper over
|
|
22
|
+
* the shared helper so every tool serializes params identically.
|
|
23
|
+
*/
|
|
24
|
+
export function qs(params) {
|
|
25
|
+
return buildQueryString(params);
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { registerBridgeHealthcheckTool } from '@chrischall/mcp-utils/fetchproxy';
|
|
2
|
+
import { webClient } from '../web/client.js';
|
|
3
|
+
/**
|
|
4
|
+
* A small, real page GET the probe round-trips — the homepage exercises the
|
|
5
|
+
* exact bridge + bot-wall guards every web tool uses.
|
|
6
|
+
*/
|
|
7
|
+
export const HEALTHCHECK_PROBE_PATH = '/';
|
|
8
|
+
/**
|
|
9
|
+
* Register `ta_web_healthcheck` — round-trips the probe path through the
|
|
10
|
+
* fetchproxy bridge and reports role/port/timing plus an actionable hint
|
|
11
|
+
* ladder. The transport is reached lazily through `webClient.bridge()` so
|
|
12
|
+
* registration never constructs it at server startup.
|
|
13
|
+
*/
|
|
14
|
+
export function registerWebTools(server) {
|
|
15
|
+
registerBridgeHealthcheckTool({
|
|
16
|
+
server,
|
|
17
|
+
prefix: 'ta_web',
|
|
18
|
+
probePath: HEALTHCHECK_PROBE_PATH,
|
|
19
|
+
hostLabel: 'www.tripadvisor.com',
|
|
20
|
+
transport: {
|
|
21
|
+
// runProbe must go through the STARTED transport (start() loads the
|
|
22
|
+
// identity and must precede any verb), hence the async delegate.
|
|
23
|
+
runProbe: async (fetchFn, probePath) => (await webClient.bridgeReady()).runProbe(fetchFn, probePath),
|
|
24
|
+
status: () => webClient.bridge().status(),
|
|
25
|
+
},
|
|
26
|
+
probeFn: (path) => webClient.getHtml(path),
|
|
27
|
+
});
|
|
28
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// TripAdvisor web client — every tripadvisor.com request rides the bridge
|
|
3
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The consumer site is DataDome-fronted, so web-tier requests run as
|
|
6
|
+
// same-origin fetches inside the user's open tripadvisor.com tab via the
|
|
7
|
+
// fetchproxy bridge (src/web/transport.ts). The browser carries its own
|
|
8
|
+
// cookies; nothing is captured or persisted here. This client is deliberately
|
|
9
|
+
// generic ({method, path} → {status, body}) — endpoint knowledge lives in the
|
|
10
|
+
// tools, pinned by docs/TRIPADVISOR-WEB-API.md captures.
|
|
11
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
12
|
+
import { bridgeErrorInfo, classifyBotWall } from '@chrischall/mcp-utils/fetchproxy';
|
|
13
|
+
import { debugLogEnabled } from './config.js';
|
|
14
|
+
import { createTripAdvisorTransport } from './transport.js';
|
|
15
|
+
export class TripAdvisorWebClient {
|
|
16
|
+
injected;
|
|
17
|
+
transport;
|
|
18
|
+
startPromise;
|
|
19
|
+
constructor(injected = {}) {
|
|
20
|
+
this.injected = injected;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The bridge transport, created lazily (construction is cheap — the port
|
|
24
|
+
* only binds on the first verb call). Public so the healthcheck tool can
|
|
25
|
+
* probe/report bridge state without re-creating it.
|
|
26
|
+
*/
|
|
27
|
+
bridge() {
|
|
28
|
+
if (!this.transport) {
|
|
29
|
+
this.transport = this.injected.transport ?? createTripAdvisorTransport();
|
|
30
|
+
}
|
|
31
|
+
return this.transport;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The bridge transport, started. Runs single-flight (concurrent callers
|
|
35
|
+
* share one start) and clears on rejection so a transient failure is retried
|
|
36
|
+
* on the next request instead of sticking forever.
|
|
37
|
+
*/
|
|
38
|
+
async bridgeReady() {
|
|
39
|
+
const transport = this.bridge();
|
|
40
|
+
if (!this.startPromise) {
|
|
41
|
+
this.startPromise = transport.start().catch((e) => {
|
|
42
|
+
this.startPromise = undefined;
|
|
43
|
+
throw e;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
await this.startPromise;
|
|
47
|
+
return transport;
|
|
48
|
+
}
|
|
49
|
+
/** Round-trip one request through the signed-in tab; bridge failures become actionable errors. */
|
|
50
|
+
async fetchRaw(method, path, opts = {}) {
|
|
51
|
+
if (debugLogEnabled())
|
|
52
|
+
console.error(`[tripadvisor-debug] → ${method} ${path}`);
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
const transport = await this.bridgeReady();
|
|
56
|
+
result = await transport.fetch({
|
|
57
|
+
method,
|
|
58
|
+
path,
|
|
59
|
+
headers: opts.headers ?? {},
|
|
60
|
+
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
const info = bridgeErrorInfo(e);
|
|
65
|
+
throw new McpToolError(`TripAdvisor bridge: ${info.message}`, {
|
|
66
|
+
...(info.hint ? { hint: info.hint } : {}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (debugLogEnabled())
|
|
70
|
+
console.error(`[tripadvisor-debug] ← ${result.status}`);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
/** GET an HTML page. Throws on non-2xx or a bot-wall interstitial. */
|
|
74
|
+
async getHtml(path) {
|
|
75
|
+
const { status, body } = await this.fetchRaw('GET', path);
|
|
76
|
+
this.assertNotWalled(status, body, path);
|
|
77
|
+
if (status < 200 || status >= 300) {
|
|
78
|
+
throw new McpToolError(`tripadvisor.com answered ${status} for ${path}`, {
|
|
79
|
+
hint: 'If this persists, run ta_web_healthcheck and make sure a tripadvisor.com tab is open.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return body;
|
|
83
|
+
}
|
|
84
|
+
/** GET a JSON endpoint. A non-JSON 2xx is almost always a bot-challenge interstitial. */
|
|
85
|
+
async getJson(path, headers = {}) {
|
|
86
|
+
const { status, body } = await this.fetchRaw('GET', path, {
|
|
87
|
+
headers: { Accept: 'application/json', ...headers },
|
|
88
|
+
});
|
|
89
|
+
this.assertNotWalled(status, body, path);
|
|
90
|
+
if (status < 200 || status >= 300) {
|
|
91
|
+
throw new McpToolError(`tripadvisor.com answered ${status} for ${path}`, {
|
|
92
|
+
hint: 'If this persists, run ta_web_healthcheck and make sure a tripadvisor.com tab is open.',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(body);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new McpToolError(`tripadvisor.com answered 2xx but non-JSON for ${path} — likely a bot-challenge interstitial.`, {
|
|
100
|
+
hint: 'Open (or refresh) a www.tripadvisor.com tab in the paired browser so the challenge clears, then retry.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
assertNotWalled(status, body, path) {
|
|
105
|
+
const wall = classifyBotWall(body, status);
|
|
106
|
+
if (wall.blocked) {
|
|
107
|
+
throw new McpToolError(`tripadvisor.com bot wall (${wall.vendor}) blocked ${path}.`, {
|
|
108
|
+
hint: 'Open a www.tripadvisor.com tab in the paired browser, complete any challenge, then retry.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Module-level singleton shared by the web tool modules (lazy — nothing binds at import). */
|
|
114
|
+
export const webClient = new TripAdvisorWebClient();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { parseBoolEnv, readEnvVar, readPortEnv } from '@chrischall/mcp-utils';
|
|
2
|
+
// The whole fetchproxy fleet shares ONE concentrator port — the Transporter
|
|
3
|
+
// extension dials it, and servers host/peer-elect on it. Never default to a
|
|
4
|
+
// "unique" port; override only for test isolation.
|
|
5
|
+
const DEFAULT_WS_PORT = 37_149;
|
|
6
|
+
/** Bridge concentrator port. Override with TRIPADVISOR_WS_PORT (tests only). */
|
|
7
|
+
export function getWsPort() {
|
|
8
|
+
return readPortEnv('TRIPADVISOR_WS_PORT', DEFAULT_WS_PORT);
|
|
9
|
+
}
|
|
10
|
+
// Comfortably above tripadvisor.com's typical latency but low enough that a
|
|
11
|
+
// stuck upstream (or a DataDome challenge that never resolves) fails fast.
|
|
12
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
13
|
+
/** Per-request bridge timeout. Override with TRIPADVISOR_REQUEST_TIMEOUT_MS. */
|
|
14
|
+
export function getRequestTimeoutMs() {
|
|
15
|
+
const raw = readEnvVar('TRIPADVISOR_REQUEST_TIMEOUT_MS');
|
|
16
|
+
const ms = Number(raw);
|
|
17
|
+
return Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
18
|
+
}
|
|
19
|
+
/** Per-request bridge debug logging (stderr). Set TRIPADVISOR_DEBUG_LOG=1. */
|
|
20
|
+
export function debugLogEnabled() {
|
|
21
|
+
return parseBoolEnv('TRIPADVISOR_DEBUG_LOG');
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Fetchproxy bridge transport — the hot path for every tripadvisor.com request
|
|
3
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// tripadvisor.com fronts its consumer site with DataDome, which fingerprints
|
|
6
|
+
// the HTTP client itself (TLS/JA3) — Node-originated requests are rejected
|
|
7
|
+
// regardless of cookie freshness. So web-tier requests run through the
|
|
8
|
+
// fetchproxy bridge: each one is a same-origin fetch executed in the user's
|
|
9
|
+
// open tripadvisor.com tab, and the bot wall never sees Node. The official
|
|
10
|
+
// Content API tools (src/client.ts) are unaffected — they never touch the
|
|
11
|
+
// bridge.
|
|
12
|
+
//
|
|
13
|
+
// This is the fleet's fetchproxy archetype (alltrails/redfin/zillow): a thin
|
|
14
|
+
// factory over @chrischall/mcp-utils' createFetchproxyTransport, which owns
|
|
15
|
+
// the FetchproxyServer construction, the start/close/status lifecycle, and
|
|
16
|
+
// the fetch/requestJson/runProbe verb adapters.
|
|
17
|
+
import { createFetchproxyTransport, } from '@chrischall/mcp-utils/fetchproxy';
|
|
18
|
+
import { getRequestTimeoutMs, getWsPort } from './config.js';
|
|
19
|
+
import { VERSION } from '../version.js';
|
|
20
|
+
/**
|
|
21
|
+
* Build the TripAdvisor bridge transport. Construction is cheap — the port
|
|
22
|
+
* only binds (and the extension only pairs) on the first verb call, so callers
|
|
23
|
+
* can create this eagerly without touching the bridge.
|
|
24
|
+
*
|
|
25
|
+
* @param createServer Test seam forwarded to `createFetchproxyTransport`: a
|
|
26
|
+
* factory that builds the underlying `FetchproxyServer`. Tests pass a
|
|
27
|
+
* capturing mock; production omits it.
|
|
28
|
+
*/
|
|
29
|
+
export function createTripAdvisorTransport(createServer) {
|
|
30
|
+
return createFetchproxyTransport({
|
|
31
|
+
port: getWsPort(),
|
|
32
|
+
serverName: 'tripadvisor-mcp',
|
|
33
|
+
version: VERSION,
|
|
34
|
+
// 'tripadvisor.com' matches www.tripadvisor.com (the extension treats each
|
|
35
|
+
// domain as "exact host or any subdomain of it").
|
|
36
|
+
domains: ['tripadvisor.com'],
|
|
37
|
+
defaultSubdomain: 'www',
|
|
38
|
+
capabilities: ['fetch'],
|
|
39
|
+
// Canonical fleet startup banner on start() — stderr only (stdout is the
|
|
40
|
+
// JSON-RPC channel).
|
|
41
|
+
logListening: true,
|
|
42
|
+
debugEnvVar: 'TRIPADVISOR_DEBUG_LOG',
|
|
43
|
+
fetchTimeoutMs: getRequestTimeoutMs(),
|
|
44
|
+
...(createServer ? { createServer } : {}),
|
|
45
|
+
});
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chrischall/tripadvisor-mcp",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"mcpName": "io.github.chrischall/tripadvisor-mcp",
|
|
5
|
+
"description": "TripAdvisor Content API MCP server for Claude \u2014 search locations, details, photos, and reviews. Developed and maintained by AI (Claude Code).",
|
|
6
|
+
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chrischall/tripadvisor-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"model-context-protocol",
|
|
15
|
+
"claude",
|
|
16
|
+
"ai",
|
|
17
|
+
"tripadvisor",
|
|
18
|
+
"travel",
|
|
19
|
+
"hotels",
|
|
20
|
+
"restaurants",
|
|
21
|
+
"attractions",
|
|
22
|
+
"reviews"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"tripadvisor-mcp": "dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
".claude-plugin",
|
|
34
|
+
"SKILL.md",
|
|
35
|
+
".mcp.json",
|
|
36
|
+
"server.json"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc && npm run bundle",
|
|
40
|
+
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
|
|
41
|
+
"dev": "node dist/index.js",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"test:coverage": "vitest run --coverage"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@chrischall/mcp-utils": "^0.10.0",
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
|
+
"dotenv": "^17.4.0",
|
|
50
|
+
"zod": "^4.4.2"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^26.0.0",
|
|
54
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
55
|
+
"esbuild": "^0.28.0",
|
|
56
|
+
"typescript": "^6.0.2",
|
|
57
|
+
"vitest": "^4.1.2"
|
|
58
|
+
},
|
|
59
|
+
"allowScripts": {
|
|
60
|
+
"esbuild@0.28.1": true
|
|
61
|
+
}
|
|
62
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.chrischall/tripadvisor-mcp",
|
|
4
|
+
"description": "TripAdvisor location search, details, photos, and reviews via the official Content API",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/chrischall/tripadvisor-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.0.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "@chrischall/tripadvisor-mcp",
|
|
14
|
+
"version": "0.0.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
},
|
|
18
|
+
"environmentVariables": [
|
|
19
|
+
{
|
|
20
|
+
"name": "TRIPADVISOR_API_KEY",
|
|
21
|
+
"description": "Your TripAdvisor Content API key (tripadvisor.com/developers)",
|
|
22
|
+
"isRequired": true,
|
|
23
|
+
"format": "string",
|
|
24
|
+
"isSecret": true
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|