@chrischall/tripadvisor-mcp 0.0.0 → 0.2.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/client.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { dirname, join } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { loadDotenvSafely, readEnvVar, formatApiError, McpToolError } from '@chrischall/mcp-utils';
3
+ import { loadDotenvSafely, readEnvVar, formatApiError, truncateErrorMessage, McpToolError } from '@chrischall/mcp-utils';
4
4
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. the
5
5
  // .mcpb bundle). loadDotenvSafely never lets .env override a host-provided value.
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
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';
8
+ const BASE_URL = 'https://terra.tripadvisor.com/api';
9
+ const SERVICE = 'TripAdvisor Terra API';
10
10
  const REQUEST_TIMEOUT_MS = 30_000;
11
- // The Content API free tier is 5,000 calls/month, so identical GETs in quick
11
+ // The Terra Discover tier is 10,000 calls/day, so identical GETs in quick
12
12
  // succession are wasteful. Search results get a 5-minute TTL by default;
13
13
  // override with TRIPADVISOR_CACHE_TTL (seconds; 0 = off).
14
14
  const DEFAULT_CACHE_TTL_MS = 300_000;
@@ -32,7 +32,6 @@ function readCacheTtlMs(envVar, defaultMs) {
32
32
  export class TripAdvisorClient {
33
33
  apiKey;
34
34
  configError;
35
- referer;
36
35
  fetchImpl;
37
36
  sleep;
38
37
  cacheTtlMs;
@@ -51,12 +50,11 @@ export class TripAdvisorClient {
51
50
  this.staticCacheTtlMs =
52
51
  opts.staticCacheTtlMs ?? readCacheTtlMs('TRIPADVISOR_STATIC_CACHE_TTL', DEFAULT_STATIC_CACHE_TTL_MS);
53
52
  this.fetchImpl = opts.fetchImpl ?? fetch;
54
- this.referer = readEnvVar('TRIPADVISOR_REFERER');
55
53
  const key = readEnvVar('TRIPADVISOR_API_KEY');
56
54
  if (!key) {
57
55
  this.apiKey = null;
58
56
  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).',
57
+ hint: 'Create a Terra API key at https://www.tripadvisor.com/developers and set TRIPADVISOR_API_KEY in your MCP host env or .env (free Discover tier: 10,000 calls/day).',
60
58
  });
61
59
  }
62
60
  else {
@@ -106,13 +104,10 @@ export class TripAdvisorClient {
106
104
  }
107
105
  async request(path, isRetry = false) {
108
106
  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) });
107
+ // Terra authenticates with the X-API-Key header (not a query param), so the
108
+ // key never touches the URL — cache keys and error messages are key-free.
109
+ const headers = { 'X-API-Key': key, Accept: 'application/json' };
110
+ const res = await this.fetchImpl(`${BASE_URL}${path}`, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
116
111
  if (res.ok)
117
112
  return (await res.json());
118
113
  const text = await res.text();
@@ -122,20 +117,18 @@ export class TripAdvisorClient {
122
117
  await this.sleep(delayMs);
123
118
  return this.request(path, true);
124
119
  }
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
- });
120
+ if (res.status === 401 || res.status === 403) {
121
+ throw new McpToolError(`${SERVICE} returned ${res.status} TRIPADVISOR_API_KEY is missing, invalid, or not authorized for Terra. A legacy Content API key does NOT work here (and a Terra key does not work on the legacy endpoint).`, { hint: 'Confirm the key on the Terra dashboard at https://www.tripadvisor.com/developers and that its plan is active.' });
130
122
  }
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.',
123
+ if (res.status === 429) {
124
+ throw new McpToolError(`${SERVICE} rate limit or daily quota exceeded (429).`, {
125
+ hint: 'The Discover tier allows 10 QPS and 10,000 calls/day check usage at https://www.tripadvisor.com/developers. Cached reads (TRIPADVISOR_CACHE_TTL) stretch the quota.',
134
126
  });
135
127
  }
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.',
128
+ if (res.status === 400) {
129
+ // Terra 400s carry a structured validation body; surface it (it names the bad field).
130
+ throw new McpToolError(`${SERVICE} rejected the request (400): ${truncateErrorMessage(text)}`, {
131
+ hint: 'Check the parameters against docs/TRIPADVISOR-API.md (e.g. category must be RESTAURANT/ATTRACTION/HOTEL; nearby needs lat+lon+radius or a bounding box).',
139
132
  });
140
133
  }
141
134
  throw new McpToolError(formatApiError(res.status, 'GET', path, text, { service: SERVICE }));
@@ -0,0 +1,78 @@
1
+ // Opt-in compact projection for the verbose Terra list responses. Pure and
2
+ // unit-tested against captured shapes (docs/TRIPADVISOR-API.md). Keyed only off
3
+ // fields observed live; on shape drift it warns and returns the RAW payload so
4
+ // an undocumented API change degrades instead of silently emitting empties.
5
+ const str = (v) => (typeof v === 'string' ? v : undefined);
6
+ const numOf = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
7
+ /** Primary-language value from a Terra `names`/`descriptions` array (or the first entry). */
8
+ function primaryValue(arr) {
9
+ if (!Array.isArray(arr) || arr.length === 0)
10
+ return undefined;
11
+ const primary = arr.find((e) => e && typeof e === 'object' && e.primary === true);
12
+ const pick = (primary ?? arr[0]);
13
+ return str(pick?.value);
14
+ }
15
+ /** ATTRACTION | HOTEL | RESTAURANT from the `/{Type}_Review-…` listing URL. */
16
+ function categoryFromUrl(url) {
17
+ if (!url)
18
+ return undefined;
19
+ const m = /\/(Attraction|Hotel|Restaurant)_Review/.exec(url);
20
+ return m ? m[1].toUpperCase() : undefined;
21
+ }
22
+ function assign(o, k, v) {
23
+ if (v !== undefined)
24
+ o[k] = v;
25
+ }
26
+ /** Project one Terra location object to a {@link CompactLocation}. */
27
+ export function compactLocation(loc) {
28
+ const addr = (Array.isArray(loc.addresses) ? loc.addresses[0] : undefined);
29
+ const url = str(loc.urls?.tripadvisor && loc.urls.tripadvisor.main);
30
+ const overall = (loc.traveler_ratings?.overall ?? {});
31
+ const out = {};
32
+ assign(out, 'id', numOf(loc.id));
33
+ assign(out, 'name', primaryValue(loc.names));
34
+ assign(out, 'category', categoryFromUrl(url));
35
+ assign(out, 'geo', str(loc.geo));
36
+ assign(out, 'city', str(addr?.city));
37
+ assign(out, 'state', str(addr?.state));
38
+ assign(out, 'rating', numOf(overall.rating));
39
+ assign(out, 'review_count', numOf(overall.count));
40
+ assign(out, 'url', url);
41
+ return out;
42
+ }
43
+ /**
44
+ * Project a Terra list envelope (`{data:[{location,…}], pagination}`) to compact
45
+ * items, preserving `pagination` and any per-item `distance_*` (nearby). If the
46
+ * shape isn't the expected `data:[{location}]`, warn to stderr and return the raw
47
+ * payload unchanged — undocumented APIs drift, so degrade rather than break.
48
+ */
49
+ export function compactList(raw) {
50
+ const env = raw;
51
+ const rows = env?.data;
52
+ if (!Array.isArray(rows) || !rows.every((r) => r && typeof r === 'object' && 'location' in r)) {
53
+ console.error('[tripadvisor] compact: unexpected response shape, returning raw payload');
54
+ return raw;
55
+ }
56
+ const data = rows.map((r) => {
57
+ const row = r;
58
+ const item = compactLocation(row.location);
59
+ assign(item, 'distance_miles', numOf(row.distance_miles));
60
+ assign(item, 'distance_kilometers', numOf(row.distance_kilometers));
61
+ return item;
62
+ });
63
+ return env.pagination !== undefined ? { data, pagination: env.pagination } : { data };
64
+ }
65
+ /**
66
+ * Compact projection for the multi-get envelope (`{data:[<Location>]}` — the
67
+ * items ARE locations, not `{location}` wrappers). Same drift-fallback contract
68
+ * as {@link compactList}.
69
+ */
70
+ export function compactLocationList(raw) {
71
+ const env = raw;
72
+ const rows = env?.data;
73
+ if (!Array.isArray(rows) || !rows.every((r) => r && typeof r === 'object' && 'id' in r)) {
74
+ console.error('[tripadvisor] compact: unexpected response shape, returning raw payload');
75
+ return raw;
76
+ }
77
+ return { data: rows.map((r) => compactLocation(r)) };
78
+ }
@@ -1,53 +1,59 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult } from '@chrischall/mcp-utils';
3
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');
4
+ import { LocationId, LocaleList, pageParams, qs } from './shared.js';
5
+ import { compactLocationList } from '../projection.js';
9
6
  export function registerLocationTools(server) {
7
+ server.registerTool('ta_get_locations', {
8
+ description: 'Get details for MULTIPLE locations in one call (batch). Pass an array of location ids — cheaper than repeated ta_get_location_details. Unknown or unlicensed ids are silently omitted. Pass compact:true for slim summaries.',
9
+ annotations: { readOnlyHint: true, openWorldHint: true },
10
+ inputSchema: {
11
+ ids: z.array(LocationId).min(1).max(50).describe('Location IDs to fetch (1–50)'),
12
+ locale: LocaleList,
13
+ compact: z
14
+ .boolean()
15
+ .optional()
16
+ .describe('Return a slim summary per location instead of full records'),
17
+ },
18
+ }, async ({ ids, locale, compact }) => {
19
+ const data = await client.get(`/locations${qs({ id: ids, locale })}`, { cache: 'static' });
20
+ return textResult(compact ? compactLocationList(data) : data);
21
+ });
10
22
  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.',
23
+ description: 'Get full details for a TripAdvisor location: names, descriptions, address, coordinates, traveler ratings, phone, category, and listing URLs.',
12
24
  annotations: { readOnlyHint: true, openWorldHint: true },
13
25
  inputSchema: {
14
26
  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)'),
27
+ locale: LocaleList,
17
28
  },
18
- }, async ({ locationId, language, currency }) => {
19
- const data = await client.get(`/location/${locationId}/details${qs({ language, currency })}`, {
20
- cache: 'static',
21
- });
29
+ }, async ({ locationId, locale }) => {
30
+ const data = await client.get(`/locations/${locationId}${qs({ locale })}`, { cache: 'static' });
22
31
  return textResult(data);
23
32
  });
24
33
  server.registerTool('ta_get_location_photos', {
25
- description: 'Get photos for a TripAdvisor location (multi-size image URLs, captions, sources).',
34
+ description: 'Get photos for a TripAdvisor location (multi-size image URLs, source, dimensions), with pagination.',
26
35
  annotations: { readOnlyHint: true, openWorldHint: true },
27
36
  inputSchema: {
28
37
  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)'),
38
+ locale: LocaleList,
39
+ ...pageParams,
33
40
  },
34
- }, async ({ locationId, language, limit, offset, source }) => {
35
- const data = await client.get(`/location/${locationId}/photos${qs({ language, limit, offset, source })}`, {
41
+ }, async ({ locationId, locale, page, size }) => {
42
+ const data = await client.get(`/locations/${locationId}/photos${qs({ locale, page, size })}`, {
36
43
  cache: 'static',
37
44
  });
38
45
  return textResult(data);
39
46
  });
40
47
  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).',
48
+ description: 'Get traveler reviews for a TripAdvisor location, with pagination.',
42
49
  annotations: { readOnlyHint: true, openWorldHint: true },
43
50
  inputSchema: {
44
51
  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'),
52
+ locale: LocaleList,
53
+ ...pageParams,
48
54
  },
49
- }, async ({ locationId, language, limit, offset }) => {
50
- const data = await client.get(`/location/${locationId}/reviews${qs({ language, limit, offset })}`, {
55
+ }, async ({ locationId, locale, page, size }) => {
56
+ const data = await client.get(`/locations/${locationId}/reviews${qs({ locale, page, size })}`, {
51
57
  cache: 'static',
52
58
  });
53
59
  return textResult(data);
@@ -1,29 +1,86 @@
1
1
  import { z } from 'zod';
2
- import { textResult } from '@chrischall/mcp-utils';
2
+ import { textResult, McpToolError } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
- import { LatLong, searchFilterParams, qs } from './shared.js';
4
+ import { Category, LocaleList, pageParams, qs } from './shared.js';
5
+ import { compactList } from '../projection.js';
6
+ /** `compact` arg shared by the list-returning search tools. */
7
+ const compactParam = {
8
+ compact: z
9
+ .boolean()
10
+ .optional()
11
+ .describe('Return a slim summary per result (id, name, category, city, rating, review_count, url) instead of full records'),
12
+ };
5
13
  export function registerSearchTools(server) {
6
14
  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.',
15
+ description: 'Search TripAdvisor locations (restaurants, attractions, hotels) by name. Returns matches with a location id for the detail tools, plus pagination. Pass compact:true for slim summaries.',
8
16
  annotations: { readOnlyHint: true, openWorldHint: true },
9
17
  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,
18
+ query: z.string().min(1).max(500).describe('Text to search location names for'),
19
+ category: Category.optional().describe('Restrict to one category'),
20
+ country_code: z.string().length(2).optional().describe('Alpha-2 country code (e.g. "US")'),
21
+ geo_name: z.string().optional().describe('City, town, or country name to scope the search'),
22
+ postal_code: z.string().optional().describe('Postal/ZIP code (takes precedence over geo_name)'),
23
+ locale: LocaleList,
24
+ ...pageParams,
25
+ ...compactParam,
13
26
  },
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);
27
+ }, async ({ query, category, country_code, geo_name, postal_code, locale, page, size, compact }) => {
28
+ const data = await client.get(`/locations/search${qs({ query, category, country_code, geo_name, postal_code, locale, page, size })}`, { cache: 'dynamic' });
29
+ return textResult(compact ? compactList(data) : data);
17
30
  });
18
31
  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.',
32
+ description: 'Find TripAdvisor locations near a point within a radius, or inside a bounding box. Center by lat+lon+radius, by a reference location_id+radius, or by a sw/ne bounding box. Returns matches with distance and a location id. Pass compact:true for slim summaries.',
20
33
  annotations: { readOnlyHint: true, openWorldHint: true },
21
34
  inputSchema: {
22
- latLong: LatLong.describe('Center point, e.g. "42.3455,-71.10767"'),
23
- ...searchFilterParams,
35
+ // Center — supply exactly one of: lat+lon, location_id, or the sw/ne box.
36
+ lat: z.number().min(-90).max(90).optional().describe('Center latitude (with lon+radius)'),
37
+ lon: z.number().min(-180).max(180).optional().describe('Center longitude (with lat+radius)'),
38
+ location_id: z.number().int().positive().optional().describe('Reference location as center (with radius)'),
39
+ radius: z.number().positive().optional().describe('Search radius (required with lat/lon or location_id; must be > 0)'),
40
+ unit: z.enum(['MI', 'KM']).optional().describe('Radius unit (default MI)'),
41
+ sw_lat: z.number().min(-90).max(90).optional().describe('Bounding box SW latitude'),
42
+ sw_lon: z.number().min(-180).max(180).optional().describe('Bounding box SW longitude'),
43
+ ne_lat: z.number().min(-90).max(90).optional().describe('Bounding box NE latitude'),
44
+ ne_lon: z.number().min(-180).max(180).optional().describe('Bounding box NE longitude'),
45
+ category: Category.optional().describe('Restrict to one category'),
46
+ min_rating: z.number().min(1).max(5).optional().describe('Minimum traveler rating (1.0–5.0)'),
47
+ include_photo: z.boolean().optional().describe('Include a photo per result'),
48
+ sort: z.enum(['distance', 'rating']).optional().describe('Sort order (default distance)'),
49
+ locale: LocaleList,
50
+ ...pageParams,
51
+ ...compactParam,
24
52
  },
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);
53
+ }, async (args) => {
54
+ const { lat, lon, location_id, radius, sw_lat, sw_lon, ne_lat, ne_lon, compact, ...rest } = args;
55
+ const boxParts = [sw_lat, sw_lon, ne_lat, ne_lon];
56
+ const boxGiven = boxParts.filter((v) => v !== undefined).length;
57
+ // A partial box (1–3 of 4) is never valid: it can't form a center on its
58
+ // own, and if it rode alongside a lat/lon or location_id center it would
59
+ // bleed into the Terra URL and 400. Reject it before the center count.
60
+ if (boxGiven > 0 && boxGiven < 4) {
61
+ throw new McpToolError('ta_search_nearby bounding box needs all four of sw_lat, sw_lon, ne_lat, ne_lon.', {
62
+ hint: 'Provide all four box corners, or use lat+lon+radius or location_id+radius instead.',
63
+ });
64
+ }
65
+ // Same defect class: a lone lat or lon can't form a center and would bleed
66
+ // into the URL alongside another center — require them together or not at all.
67
+ if ((lat === undefined) !== (lon === undefined)) {
68
+ throw new McpToolError('ta_search_nearby needs both lat and lon together (or neither).', {
69
+ hint: 'Provide lat AND lon with a radius, or use location_id+radius or a sw/ne box.',
70
+ });
71
+ }
72
+ const hasLatLon = lat !== undefined && lon !== undefined;
73
+ const hasBox = boxGiven === 4;
74
+ const centers = [hasLatLon, location_id !== undefined, hasBox].filter(Boolean).length;
75
+ if (centers !== 1) {
76
+ throw new McpToolError('ta_search_nearby needs exactly one center: lat+lon (+radius), location_id (+radius), or the sw/ne bounding box.', { hint: 'Provide lat AND lon, OR location_id, OR all four of sw_lat/sw_lon/ne_lat/ne_lon — not more than one.' });
77
+ }
78
+ if ((hasLatLon || location_id !== undefined) && radius === undefined) {
79
+ throw new McpToolError('lat/lon and location_id center modes require radius.', {
80
+ hint: 'Add radius (with optional unit MI/KM), or switch to a sw/ne bounding box.',
81
+ });
82
+ }
83
+ const data = await client.get(`/locations/nearby${qs({ lat, lon, location_id, radius, sw_lat, sw_lon, ne_lat, ne_lon, ...rest })}`, { cache: 'dynamic' });
84
+ return textResult(compact ? compactList(data) : data);
28
85
  });
29
86
  }
@@ -2,23 +2,21 @@ import { z } from 'zod';
2
2
  import { buildQueryString } from '@chrischall/mcp-utils';
3
3
  /** TripAdvisor location id — a positive integer (interpolated into the path). */
4
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)'),
5
+ /** Terra category filter — the three UPPERCASE values the API accepts. */
6
+ export const Category = z.enum(['RESTAURANT', 'ATTRACTION', 'HOTEL']);
7
+ /** Locale list → Terra's repeated `locale` query param. */
8
+ export const LocaleList = z
9
+ .array(z.string())
10
+ .optional()
11
+ .describe('Preferred locales for localized fields, in priority order (e.g. ["en","es"])');
12
+ /** Paging shared by Terra list endpoints (size is capped at 20 by the API). */
13
+ export const pageParams = {
14
+ page: z.number().int().min(1).optional().describe('Page index (1-based)'),
15
+ size: z.number().int().min(1).max(20).optional().describe('Results per page (max 20)'),
19
16
  };
20
17
  /**
21
- * Build a `?a=b&c=d` query string, dropping undefined values. Thin wrapper over
18
+ * Build a `?a=b&c=d` query string, dropping undefined values and expanding
19
+ * arrays into repeated params (Terra takes `locale` repeated). Thin wrapper over
22
20
  * the shared helper so every tool serializes params identically.
23
21
  */
24
22
  export function qs(params) {
package/dist/tools/web.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { registerBridgeHealthcheckTool } from '@chrischall/mcp-utils/fetchproxy';
2
+ import { McpToolError, textResult } from '@chrischall/mcp-utils';
2
3
  import { webClient } from '../web/client.js';
4
+ import { parseLocationDetail } from '../web/parse.js';
5
+ import { LocationId } from './shared.js';
3
6
  /**
4
7
  * A small, real page GET the probe round-trips — the homepage exercises the
5
8
  * exact bridge + bot-wall guards every web tool uses.
@@ -25,4 +28,20 @@ export function registerWebTools(server) {
25
28
  },
26
29
  probeFn: (path) => webClient.getHtml(path),
27
30
  });
31
+ server.registerTool('ta_web_get_location', {
32
+ description: "Get a TripAdvisor location's core details (name, rating, review count, address, coordinates, phone, photo, listing URL) by location ID, read from the public page via the browser bridge. Works without an API key — use this when ta_get_location_details is unavailable or its key is blocked. Covers attractions, hotels, and restaurants. Does not return individual review text.",
33
+ annotations: { readOnlyHint: true, openWorldHint: true },
34
+ inputSchema: {
35
+ locationId: LocationId,
36
+ },
37
+ }, async ({ locationId }) => {
38
+ const html = await webClient.getLocationHtml(locationId);
39
+ const detail = parseLocationDetail(html);
40
+ if (!detail) {
41
+ throw new McpToolError(`Could not parse location ${locationId} from its TripAdvisor page.`, {
42
+ hint: 'The page may be a bot-challenge shell or the id may be wrong — run ta_web_healthcheck and confirm a signed-in www.tripadvisor.com tab is open, then retry.',
43
+ });
44
+ }
45
+ return textResult({ location_id: locationId, ...detail });
46
+ });
28
47
  }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '0.0.0'; // x-release-please-version
2
+ export const VERSION = '0.2.0'; // x-release-please-version
@@ -12,6 +12,7 @@ import { McpToolError } from '@chrischall/mcp-utils';
12
12
  import { bridgeErrorInfo, classifyBotWall } from '@chrischall/mcp-utils/fetchproxy';
13
13
  import { debugLogEnabled } from './config.js';
14
14
  import { createTripAdvisorTransport } from './transport.js';
15
+ import { locationDetailPath } from './parse.js';
15
16
  export class TripAdvisorWebClient {
16
17
  injected;
17
18
  transport;
@@ -101,6 +102,10 @@ export class TripAdvisorWebClient {
101
102
  });
102
103
  }
103
104
  }
105
+ /** Fetch a location detail page's HTML by numeric d-id (canonicalized by TripAdvisor). */
106
+ async getLocationHtml(locationId) {
107
+ return this.getHtml(locationDetailPath(locationId));
108
+ }
104
109
  assertNotWalled(status, body, path) {
105
110
  const wall = classifyBotWall(body, status);
106
111
  if (wall.blocked) {
@@ -0,0 +1,70 @@
1
+ // Pure parsing for the web tier — no bridge, no I/O, so it's unit-testable
2
+ // against captured bytes. Shapes pinned in docs/TRIPADVISOR-WEB-API.md.
3
+ /**
4
+ * Build the location detail path from a numeric `d`-id. TripAdvisor canonicalizes
5
+ * on the `d<id>` segment and same-origin-redirects the `g<geo>` + type prefix to
6
+ * the correct page (verified across attraction/hotel/restaurant), so one fixed
7
+ * form works for every category — the in-tab fetch follows the redirect.
8
+ */
9
+ export function locationDetailPath(locationId) {
10
+ return `/Attraction_Review-g1-d${locationId}-Reviews-a-a.html`;
11
+ }
12
+ /** Extract every `application/ld+json` block's parsed JSON (skipping malformed ones). */
13
+ function ldJsonBlocks(html) {
14
+ const out = [];
15
+ for (const m of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
16
+ try {
17
+ out.push(JSON.parse(m[1].trim()));
18
+ }
19
+ catch {
20
+ // Undocumented markup can carry a malformed block; skip it and keep scanning.
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+ /** Coerce a schema.org string|number to a finite number, or undefined. */
26
+ function num(v) {
27
+ if (v === undefined || v === null)
28
+ return undefined;
29
+ const n = typeof v === 'number' ? v : Number(v);
30
+ return Number.isFinite(n) ? n : undefined;
31
+ }
32
+ /**
33
+ * Parse a location detail page into a {@link LocationDetail}. The business node
34
+ * is the ld+json block carrying both `name` and `aggregateRating` (its `@type`
35
+ * varies by category but the shape is identical). Returns null when no such node
36
+ * is present — a hydrated shell or a bot-challenge page — so the caller can throw
37
+ * an actionable error instead of emitting an empty projection.
38
+ */
39
+ export function parseLocationDetail(html) {
40
+ const node = ldJsonBlocks(html).find((b) => typeof b === 'object' && b !== null && typeof b.name === 'string' && 'aggregateRating' in b);
41
+ if (!node)
42
+ return null;
43
+ const rating = (node.aggregateRating ?? {});
44
+ const geo = (node.geo ?? {});
45
+ const detail = { name: node.name };
46
+ const assign = (key, value) => {
47
+ if (value !== undefined)
48
+ detail[key] = value;
49
+ };
50
+ assign('type', typeof node['@type'] === 'string' ? node['@type'] : undefined);
51
+ assign('url', typeof node.url === 'string' ? node.url : undefined);
52
+ assign('rating', num(rating.ratingValue));
53
+ assign('review_count', num(rating.reviewCount));
54
+ assign('best_rating', num(rating.bestRating));
55
+ assign('telephone', typeof node.telephone === 'string' ? node.telephone : undefined);
56
+ assign('image', typeof node.image === 'string' ? node.image : undefined);
57
+ assign('latitude', num(geo.latitude));
58
+ assign('longitude', num(geo.longitude));
59
+ assign('same_as', typeof node.sameAs === 'string' ? node.sameAs : undefined);
60
+ if (node.address && typeof node.address === 'object') {
61
+ const addr = {};
62
+ for (const [k, v] of Object.entries(node.address)) {
63
+ if (k !== '@type' && typeof v === 'string')
64
+ addr[k] = v;
65
+ }
66
+ if (Object.keys(addr).length)
67
+ detail.address = addr;
68
+ }
69
+ return detail;
70
+ }
@@ -7,7 +7,7 @@
7
7
  // regardless of cookie freshness. So web-tier requests run through the
8
8
  // fetchproxy bridge: each one is a same-origin fetch executed in the user's
9
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
10
+ // Terra API tools (src/client.ts) are unaffected — they never touch the
11
11
  // bridge.
12
12
  //
13
13
  // This is the fleet's fetchproxy archetype (alltrails/redfin/zillow): a thin
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@chrischall/tripadvisor-mcp",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
4
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).",
5
+ "description": "TripAdvisor Terra API MCP server for Claude search locations, details, photos, and reviews. Developed and maintained by AI (Claude Code).",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
7
7
  "repository": {
8
8
  "type": "git",
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "scripts": {
39
39
  "build": "tsc && npm run bundle",
40
- "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
40
+ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
41
41
  "dev": "node dist/index.js",
42
42
  "test": "vitest run",
43
43
  "test:watch": "vitest",
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@chrischall/mcp-utils": "^0.10.0",
48
+ "@fetchproxy/server": "^1.3.4",
48
49
  "@modelcontextprotocol/sdk": "^1.29.0",
49
50
  "dotenv": "^17.4.0",
50
51
  "zod": "^4.4.2"
package/server.json CHANGED
@@ -1,24 +1,24 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.chrischall/tripadvisor-mcp",
4
- "description": "TripAdvisor location search, details, photos, and reviews via the official Content API",
4
+ "description": "TripAdvisor location search, details, photos, and reviews via the Terra API",
5
5
  "repository": {
6
6
  "url": "https://github.com/chrischall/tripadvisor-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.0.0",
9
+ "version": "0.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/tripadvisor-mcp",
14
- "version": "0.0.0",
14
+ "version": "0.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
18
18
  "environmentVariables": [
19
19
  {
20
20
  "name": "TRIPADVISOR_API_KEY",
21
- "description": "Your TripAdvisor Content API key (tripadvisor.com/developers)",
21
+ "description": "Your TripAdvisor Terra API key (tripadvisor.com/developers)",
22
22
  "isRequired": true,
23
23
  "format": "string",
24
24
  "isSecret": true