@chrischall/tripadvisor-mcp 0.0.0 → 0.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.
@@ -6,16 +6,16 @@
6
6
  "email": "chris.c.hall@gmail.com"
7
7
  },
8
8
  "metadata": {
9
- "description": "MCP server for the TripAdvisor Content API — location search, details, photos, and reviews",
10
- "version": "0.0.0"
9
+ "description": "MCP server for the TripAdvisor Terra API — location search, details, photos, and reviews",
10
+ "version": "0.1.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
14
14
  "name": "tripadvisor-mcp",
15
15
  "displayName": "TripAdvisor",
16
16
  "source": "./",
17
- "description": "TripAdvisor travel data via the official Content API — search hotels, restaurants, and attractions, with details, photos, and reviews",
18
- "version": "0.0.0",
17
+ "description": "TripAdvisor travel data via the Terra API — search hotels, restaurants, and attractions, with details, photos, and reviews",
18
+ "version": "0.1.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "tripadvisor-mcp",
3
3
  "displayName": "TripAdvisor",
4
- "version": "0.0.0",
5
- "description": "MCP server for the TripAdvisor Content API — location search, details, photos, and reviews",
4
+ "version": "0.1.0",
5
+ "description": "MCP server for the TripAdvisor Terra API — location search, details, photos, and reviews",
6
6
  "author": {
7
7
  "name": "Chris Hall",
8
8
  "email": "chris.c.hall@gmail.com"
package/.mcp.json CHANGED
@@ -5,9 +5,10 @@
5
5
  "args": ["${CLAUDE_PLUGIN_ROOT}/dist/index.js"],
6
6
  "env": {
7
7
  "TRIPADVISOR_API_KEY": "${TRIPADVISOR_API_KEY}",
8
- "TRIPADVISOR_REFERER": "${TRIPADVISOR_REFERER}",
9
8
  "TRIPADVISOR_CACHE_TTL": "${TRIPADVISOR_CACHE_TTL}",
10
- "TRIPADVISOR_STATIC_CACHE_TTL": "${TRIPADVISOR_STATIC_CACHE_TTL}"
9
+ "TRIPADVISOR_STATIC_CACHE_TTL": "${TRIPADVISOR_STATIC_CACHE_TTL}",
10
+ "TRIPADVISOR_REQUEST_TIMEOUT_MS": "${TRIPADVISOR_REQUEST_TIMEOUT_MS}",
11
+ "TRIPADVISOR_DEBUG_LOG": "${TRIPADVISOR_DEBUG_LOG}"
11
12
  }
12
13
  }
13
14
  }
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@chrischall/tripadvisor-mcp)](https://www.npmjs.com/package/@chrischall/tripadvisor-mcp)
4
4
 
5
- MCP server for the **TripAdvisor Content API** — travel data for Claude. Search hotels, restaurants, attractions, and destinations by name or coordinates, then pull full details, photos, and recent reviews, all over stdio.
5
+ MCP server for the **TripAdvisor Terra API** — travel data for Claude. Search hotels, restaurants, and attractions by name or coordinates, then pull full details, photos, and reviews, all over stdio. (Terra is TripAdvisor's current API; the legacy Content API is sunset on 2026-08-31.)
6
6
 
7
7
  > Developed and maintained by AI (Claude Code). Use at your own discretion.
8
8
 
@@ -14,34 +14,53 @@ MCP server for the **TripAdvisor Content API** — travel data for Claude. Searc
14
14
  "tripadvisor": {
15
15
  "command": "npx",
16
16
  "args": ["-y", "@chrischall/tripadvisor-mcp"],
17
- "env": { "TRIPADVISOR_API_KEY": "your-content-api-key-here" }
17
+ "env": { "TRIPADVISOR_API_KEY": "your-terra-api-key-here" }
18
18
  }
19
19
  }
20
20
  }
21
21
  ```
22
22
 
23
- Get a key at [tripadvisor.com/developers](https://www.tripadvisor.com/developers). The free tier is **5,000 calls/month**; responses are cached in-memory to stretch it.
23
+ Get a key at [tripadvisor.com/developers](https://www.tripadvisor.com/developers). The free **Discover** tier is pay-as-you-go (10 QPS, **10,000 calls/day**); responses are cached in-memory to stretch it. Make sure it's a **Terra** key — a legacy Content API key returns 403.
24
24
 
25
25
  ## Tools
26
26
 
27
27
  | Tool | What it does |
28
28
  | --- | --- |
29
- | `ta_search_locations` | Search locations by name (optionally scoped by category, lat/long + radius, address, phone) — up to 10 matches |
30
- | `ta_search_nearby` | Find locations near a latitude/longitude up to 10 matches |
31
- | `ta_get_location_details` | Full details: rating, ranking, subratings, awards, review count, amenities, hours, listing URLs |
32
- | `ta_get_location_photos` | Photos with multi-size image URLs, captions, and source filter |
33
- | `ta_get_location_reviews` | Most recent reviews (up to 5 per call, pageable with `offset`) |
34
-
35
- All tools are read-only the Content API has no write endpoints.
29
+ | `ta_search_locations` | Search locations by name (optionally scoped by category, country/geo/postal code) — paginated |
30
+ | `ta_search_nearby` | Find locations near a lat/lon within a radius (category, min rating, sort) — paginated |
31
+ | `ta_get_location_details` | Full details: names, descriptions, address, coordinates, traveler ratings, phone, listing URLs |
32
+ | `ta_get_location_photos` | Photos with multi-size image URLs, source, and dimensions paginated |
33
+ | `ta_get_location_reviews` | Traveler reviews paginated |
34
+ | `ta_web_healthcheck` | Diagnose the optional tripadvisor.com browser-bridge connection (see below) |
35
+ | `ta_web_get_location` | Location details (rating, address, coords, phone, photo) read from the public page via the browser bridge — **no API key needed** |
36
+
37
+ All tools are read-only — Terra has no write endpoints.
38
+
39
+ ### Browser bridge (optional)
40
+
41
+ `ta_web_healthcheck` is the first tool of an optional second tier that reaches
42
+ tripadvisor.com's consumer site (bot-walled, so unreachable server-side) by
43
+ routing same-origin fetches through your signed-in browser tab via the
44
+ [fetchproxy](https://github.com/chrischall/fetchproxy) Transporter extension.
45
+ It needs the extension installed and a one-time pairing approval; the Content
46
+ API tools above never touch the bridge.
47
+
48
+ `ta_web_get_location` uses this bridge to read a location's details straight
49
+ from its public TripAdvisor page — so it works **without an API key**,
50
+ covering attractions, hotels, and restaurants. It returns core business data
51
+ (rating, review count, address, coordinates, phone, primary photo, listing
52
+ URL) but not individual review text. Request shapes are pinned in
53
+ [docs/TRIPADVISOR-WEB-API.md](docs/TRIPADVISOR-WEB-API.md).
36
54
 
37
55
  ## Environment
38
56
 
39
57
  | Var | Required | Purpose |
40
58
  | --- | --- | --- |
41
- | `TRIPADVISOR_API_KEY` | yes | Content API key, sent as the `key` query parameter. |
42
- | `TRIPADVISOR_REFERER` | no | `Referer` header for domain-restricted keys (the API 403s without a matching referer). |
59
+ | `TRIPADVISOR_API_KEY` | yes | Terra API key, sent as the `X-API-Key` header. |
43
60
  | `TRIPADVISOR_CACHE_TTL` | no | Seconds to cache search responses (default: 300; `0` disables). |
44
61
  | `TRIPADVISOR_STATIC_CACHE_TTL` | no | Seconds to cache details/photos/reviews (default: 3600; `0` disables). |
62
+ | `TRIPADVISOR_REQUEST_TIMEOUT_MS` | no | Per-request timeout for the optional browser bridge (default: 30000). |
63
+ | `TRIPADVISOR_DEBUG_LOG` | no | Set to `1` to log browser-bridge requests to stderr. |
45
64
 
46
65
  ## Development
47
66
 
package/SKILL.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: tripadvisor-mcp
3
- description: TripAdvisor travel data via the official Content API through MCP. Use when the user asks to find hotels, restaurants, attractions, or destinations, look up a place's TripAdvisor rating/reviews/photos, compare places to stay or eat, or find what's near a location. Triggers on phrases like "find a hotel in", "best restaurants near", "TripAdvisor reviews for", "what's the rating of", "things to do in", or "attractions near me". Requires the @chrischall/tripadvisor-mcp package installed and the tripadvisor server registered (see Setup), plus a TripAdvisor Content API key.
3
+ description: TripAdvisor travel data via the Terra API through MCP. Use when the user asks to find hotels, restaurants, or attractions, look up a place's TripAdvisor rating/reviews/photos, compare places to stay or eat, or find what's near a location. Triggers on phrases like "find a hotel in", "best restaurants near", "TripAdvisor reviews for", "what's the rating of", "things to do in", or "attractions near me". Requires the @chrischall/tripadvisor-mcp package installed and the tripadvisor server registered (see Setup), plus a TripAdvisor Terra API key.
4
4
  ---
5
5
 
6
6
  # tripadvisor-mcp
7
7
 
8
- MCP server for the **TripAdvisor Content API** — location search, details, photos, and reviews for hotels, restaurants, attractions, and destinations, exposed to Claude over stdio.
8
+ MCP server for the **TripAdvisor Terra API** — location search, details, photos, and reviews for hotels, restaurants, and attractions, exposed to Claude over stdio. (Terra is TripAdvisor's current API; the legacy Content API is sunset on 2026-08-31.)
9
9
 
10
10
  - **npm:** [npmjs.com/package/@chrischall/tripadvisor-mcp](https://www.npmjs.com/package/@chrischall/tripadvisor-mcp)
11
11
  - **Source:** [github.com/chrischall/tripadvisor-mcp](https://github.com/chrischall/tripadvisor-mcp)
@@ -23,14 +23,14 @@ Add to `.mcp.json` in your project or `~/.claude/mcp.json`:
23
23
  "command": "npx",
24
24
  "args": ["-y", "@chrischall/tripadvisor-mcp"],
25
25
  "env": {
26
- "TRIPADVISOR_API_KEY": "your-content-api-key-here"
26
+ "TRIPADVISOR_API_KEY": "your-terra-api-key-here"
27
27
  }
28
28
  }
29
29
  }
30
30
  }
31
31
  ```
32
32
 
33
- Get a key at [tripadvisor.com/developers](https://www.tripadvisor.com/developers). The free tier is **5,000 calls/month**; the server caches responses in-memory to stretch it.
33
+ Get a key at [tripadvisor.com/developers](https://www.tripadvisor.com/developers). The free **Discover** tier is pay-as-you-go (10 QPS, **10,000 calls/day**); the server caches responses in-memory to stretch it. Must be a **Terra** key — a legacy key returns 403.
34
34
 
35
35
  ### Option B — from source
36
36
 
@@ -44,8 +44,7 @@ npm install && npm run build
44
44
 
45
45
  | Var | Required | Purpose |
46
46
  | --- | --- | --- |
47
- | `TRIPADVISOR_API_KEY` | yes | Content API key (sent as the `key` query parameter). |
48
- | `TRIPADVISOR_REFERER` | no | `Referer` header — required if your key was created with a domain restriction. |
47
+ | `TRIPADVISOR_API_KEY` | yes | Terra API key (sent as the `X-API-Key` header). A legacy key returns 403. |
49
48
  | `TRIPADVISOR_CACHE_TTL` | no | Seconds to cache identical search responses (default: 300; `0` disables). |
50
49
  | `TRIPADVISOR_STATIC_CACHE_TTL` | no | Longer TTL for details/photos/reviews (default: 3600; `0` disables). |
51
50
 
@@ -53,18 +52,20 @@ npm install && npm run build
53
52
 
54
53
  | Tool | Use for |
55
54
  | --- | --- |
56
- | `ta_search_locations` | Find places by name — `searchQuery` required; filter with `category` (hotels/attractions/restaurants/geos), `latLong` + `radius`/`radiusUnit`, `address`, `phone`, `language`. Returns up to 10 matches with `location_id`. |
57
- | `ta_search_nearby` | Find places near coordinates — `latLong` ("lat,long") required; same filters. |
58
- | `ta_get_location_details` | Full listing for a `location_id`: rating, ranking, subratings, awards, review count, amenities, hours, URLs. Optional `language`, `currency`. |
59
- | `ta_get_location_photos` | Photos (multi-size URLs + captions). Page with `limit`/`offset`; filter `source` to `Expert`, `Management`, `Traveler`. |
60
- | `ta_get_location_reviews` | Up to 5 most-recent reviews per call; page with `offset`. |
55
+ | `ta_search_locations` | Find places by name — `query` required; filter with `category` (`RESTAURANT`/`ATTRACTION`/`HOTEL`), `country_code`, `geo_name`, `postal_code`, `locale`; page with `page`/`size` (max 20). Returns matches with a location `id`. |
56
+ | `ta_search_nearby` | Find places near coordinates — `lat`+`lon`+`radius` required (`unit` `MI`/`KM`); filter `category`, `min_rating`, `sort` (`distance`/`rating`), `include_photo`. |
57
+ | `ta_get_location_details` | Full listing for a location `id`: names, descriptions, address, coordinates, traveler ratings, phone, URLs. Optional `locale`. |
58
+ | `ta_get_location_photos` | Photos (multi-size URLs, source, dimensions). Page with `page`/`size`. |
59
+ | `ta_get_location_reviews` | Traveler reviews. Page with `page`/`size`. |
60
+ | `ta_web_healthcheck` | Diagnose the optional tripadvisor.com browser-bridge connection (fetchproxy Transporter). Reports bridge role/port/timing and an actionable hint if it's not connected. |
61
+ | `ta_web_get_location` | Get a location's details (rating, review count, address, coordinates, phone, photo, URL) from its public TripAdvisor page via the browser bridge — **works without an API key**. Covers attractions/hotels/restaurants; no individual review text. |
61
62
 
62
63
  ## Workflow
63
64
 
64
- 1. `ta_search_locations` (or `ta_search_nearby`) to get `location_id`s.
65
- 2. `ta_get_location_details` for ratings/amenities; `ta_get_location_reviews` and `ta_get_location_photos` for review text and images.
65
+ 1. `ta_search_locations` (or `ta_search_nearby`) to get a location `id`.
66
+ 2. `ta_get_location_details` for ratings/address; `ta_get_location_reviews` and `ta_get_location_photos` for reviews and images.
66
67
 
67
68
  Notes:
68
- - Everything is **read-only** — the Content API has no write endpoints.
69
- - A `403` usually means a domain/IP-restricted key: set `TRIPADVISOR_REFERER` to a matching domain.
70
- - Reviews come back at most 5 per call — page with `offset` for more.
69
+ - Everything is **read-only** — Terra has no write endpoints.
70
+ - A `403`/`401` means the key isn't a valid Terra key (a legacy Content API key won't work) or its plan isn't active.
71
+ - List endpoints paginate with `page`/`size` (max 20 per page).
package/dist/bundle.js CHANGED
@@ -7237,26 +7237,26 @@ var require_permessage_deflate = __commonJS({
7237
7237
  value = value[0];
7238
7238
  if (key === "client_max_window_bits") {
7239
7239
  if (value !== true) {
7240
- const num = +value;
7241
- if (!Number.isInteger(num) || num < 8 || num > 15) {
7240
+ const num2 = +value;
7241
+ if (!Number.isInteger(num2) || num2 < 8 || num2 > 15) {
7242
7242
  throw new TypeError(
7243
7243
  `Invalid value for parameter "${key}": ${value}`
7244
7244
  );
7245
7245
  }
7246
- value = num;
7246
+ value = num2;
7247
7247
  } else if (!this._isServer) {
7248
7248
  throw new TypeError(
7249
7249
  `Invalid value for parameter "${key}": ${value}`
7250
7250
  );
7251
7251
  }
7252
7252
  } else if (key === "server_max_window_bits") {
7253
- const num = +value;
7254
- if (!Number.isInteger(num) || num < 8 || num > 15) {
7253
+ const num2 = +value;
7254
+ if (!Number.isInteger(num2) || num2 < 8 || num2 > 15) {
7255
7255
  throw new TypeError(
7256
7256
  `Invalid value for parameter "${key}": ${value}`
7257
7257
  );
7258
7258
  }
7259
- value = num;
7259
+ value = num2;
7260
7260
  } else if (key === "client_no_context_takeover" || key === "server_no_context_takeover") {
7261
7261
  if (value !== true) {
7262
7262
  throw new TypeError(
@@ -7969,8 +7969,8 @@ var require_receiver = __commonJS({
7969
7969
  return;
7970
7970
  }
7971
7971
  const buf = this.consume(8);
7972
- const num = buf.readUInt32BE(0);
7973
- if (num > Math.pow(2, 53 - 32) - 1) {
7972
+ const num2 = buf.readUInt32BE(0);
7973
+ if (num2 > Math.pow(2, 53 - 32) - 1) {
7974
7974
  const error51 = this.createError(
7975
7975
  RangeError,
7976
7976
  "Unsupported WebSocket frame: payload length > 2^53 - 1",
@@ -7981,7 +7981,7 @@ var require_receiver = __commonJS({
7981
7981
  cb(error51);
7982
7982
  return;
7983
7983
  }
7984
- this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
7984
+ this._payloadLength = num2 * Math.pow(2, 32) + buf.readUInt32BE(4);
7985
7985
  this.haveLength(cb);
7986
7986
  }
7987
7987
  /**
@@ -34841,15 +34841,15 @@ var pageSchema = {
34841
34841
  };
34842
34842
 
34843
34843
  // src/version.ts
34844
- var VERSION = "0.0.0";
34844
+ var VERSION = "0.1.0";
34845
34845
 
34846
34846
  // src/client.ts
34847
34847
  import { dirname, join } from "node:path";
34848
34848
  import { fileURLToPath } from "node:url";
34849
34849
  var __dirname = dirname(fileURLToPath(import.meta.url));
34850
34850
  await loadDotenvSafely({ path: join(__dirname, "..", ".env"), override: false });
34851
- var BASE_URL = "https://api.content.tripadvisor.com/api/v1";
34852
- var SERVICE = "TripAdvisor Content API";
34851
+ var BASE_URL = "https://terra.tripadvisor.com/api";
34852
+ var SERVICE = "TripAdvisor Terra API";
34853
34853
  var REQUEST_TIMEOUT_MS = 3e4;
34854
34854
  var DEFAULT_CACHE_TTL_MS = 3e5;
34855
34855
  var DEFAULT_STATIC_CACHE_TTL_MS = 36e5;
@@ -34864,7 +34864,6 @@ function readCacheTtlMs(envVar, defaultMs) {
34864
34864
  var TripAdvisorClient = class {
34865
34865
  apiKey;
34866
34866
  configError;
34867
- referer;
34868
34867
  fetchImpl;
34869
34868
  sleep;
34870
34869
  cacheTtlMs;
@@ -34882,12 +34881,11 @@ var TripAdvisorClient = class {
34882
34881
  this.cacheTtlMs = opts.cacheTtlMs ?? readCacheTtlMs("TRIPADVISOR_CACHE_TTL", DEFAULT_CACHE_TTL_MS);
34883
34882
  this.staticCacheTtlMs = opts.staticCacheTtlMs ?? readCacheTtlMs("TRIPADVISOR_STATIC_CACHE_TTL", DEFAULT_STATIC_CACHE_TTL_MS);
34884
34883
  this.fetchImpl = opts.fetchImpl ?? fetch;
34885
- this.referer = readEnvVar("TRIPADVISOR_REFERER");
34886
34884
  const key = readEnvVar("TRIPADVISOR_API_KEY");
34887
34885
  if (!key) {
34888
34886
  this.apiKey = null;
34889
34887
  this.configError = new McpToolError("TRIPADVISOR_API_KEY environment variable is required", {
34890
- 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)."
34888
+ 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)."
34891
34889
  });
34892
34890
  } else {
34893
34891
  this.apiKey = key;
@@ -34929,11 +34927,8 @@ var TripAdvisorClient = class {
34929
34927
  }
34930
34928
  async request(path, isRetry = false) {
34931
34929
  const key = this.requireKey();
34932
- const sep = path.includes("?") ? "&" : "?";
34933
- const url2 = `${BASE_URL}${path}${sep}key=${encodeURIComponent(key)}`;
34934
- const headers = { Accept: "application/json" };
34935
- if (this.referer) headers.Referer = this.referer;
34936
- const res = await this.fetchImpl(url2, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
34930
+ const headers = { "X-API-Key": key, Accept: "application/json" };
34931
+ const res = await this.fetchImpl(`${BASE_URL}${path}`, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
34937
34932
  if (res.ok) return await res.json();
34938
34933
  const text = await res.text();
34939
34934
  if (res.status === 429 && !isRetry) {
@@ -34942,22 +34937,20 @@ var TripAdvisorClient = class {
34942
34937
  await this.sleep(delayMs);
34943
34938
  return this.request(path, true);
34944
34939
  }
34945
- if (res.status === 401) {
34946
- throw new McpToolError(`${SERVICE} returned 401 Unauthorized \u2014 TRIPADVISOR_API_KEY is missing or invalid.`, {
34947
- hint: "Check the key in your MCP host env or .env; create one at https://www.tripadvisor.com/developers"
34948
- });
34949
- }
34950
- if (res.status === 403) {
34940
+ if (res.status === 401 || res.status === 403) {
34951
34941
  throw new McpToolError(
34952
- `${SERVICE} returned 403 Forbidden \u2014 the key is valid but blocked, usually by its domain/IP restriction (set at key creation) or an unapproved application.`,
34953
- {
34954
- hint: "If the key is domain-restricted, set TRIPADVISOR_REFERER to a matching https://domain; if IP-restricted, call from a listed address."
34955
- }
34942
+ `${SERVICE} returned ${res.status} \u2014 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).`,
34943
+ { hint: "Confirm the key on the Terra dashboard at https://www.tripadvisor.com/developers and that its plan is active." }
34956
34944
  );
34957
34945
  }
34958
34946
  if (res.status === 429) {
34959
- throw new McpToolError(`${SERVICE} rate limit or monthly quota exceeded (429).`, {
34960
- hint: "The free tier is 5,000 calls/month \u2014 check usage at https://www.tripadvisor.com/developers. Cached reads (TRIPADVISOR_CACHE_TTL) stretch the quota."
34947
+ throw new McpToolError(`${SERVICE} rate limit or daily quota exceeded (429).`, {
34948
+ hint: "The Discover tier allows 10 QPS and 10,000 calls/day \u2014 check usage at https://www.tripadvisor.com/developers. Cached reads (TRIPADVISOR_CACHE_TTL) stretch the quota."
34949
+ });
34950
+ }
34951
+ if (res.status === 400) {
34952
+ throw new McpToolError(`${SERVICE} rejected the request (400): ${truncateErrorMessage(text)}`, {
34953
+ 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)."
34961
34954
  });
34962
34955
  }
34963
34956
  throw new McpToolError(formatApiError(res.status, "GET", path, text, { service: SERVICE }));
@@ -34967,15 +34960,11 @@ var client = new TripAdvisorClient();
34967
34960
 
34968
34961
  // src/tools/shared.ts
34969
34962
  var LocationId = external_exports.number().int().positive().describe("TripAdvisor location ID (from a search tool)");
34970
- var LatLong = external_exports.string().regex(/^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/, 'must be a "lat,long" pair like "42.3455,-71.10767"');
34971
- var Category = external_exports.enum(["hotels", "attractions", "restaurants", "geos"]);
34972
- var searchFilterParams = {
34973
- category: Category.optional().describe("Restrict results to one property type"),
34974
- phone: external_exports.string().optional().describe('Phone number filter (spaces/dashes ok, no leading "+")'),
34975
- address: external_exports.string().optional().describe("Address filter"),
34976
- radius: external_exports.number().positive().optional().describe("Search radius around latLong (must be > 0)"),
34977
- radiusUnit: external_exports.enum(["km", "mi", "m"]).optional().describe("Unit for radius"),
34978
- language: external_exports.string().optional().describe("Result language code (default: en)")
34963
+ var Category = external_exports.enum(["RESTAURANT", "ATTRACTION", "HOTEL"]);
34964
+ var LocaleList = external_exports.array(external_exports.string()).optional().describe('Preferred locales for localized fields, in priority order (e.g. ["en","es"])');
34965
+ var pageParams = {
34966
+ page: external_exports.number().int().min(1).optional().describe("Page index (1-based)"),
34967
+ size: external_exports.number().int().min(1).max(20).optional().describe("Results per page (max 20)")
34979
34968
  };
34980
34969
  function qs(params) {
34981
34970
  return buildQueryString(params);
@@ -34986,17 +34975,21 @@ function registerSearchTools(server) {
34986
34975
  server.registerTool(
34987
34976
  "ta_search_locations",
34988
34977
  {
34989
- description: "Search TripAdvisor locations (hotels, restaurants, attractions, geos) by name. Returns up to 10 matches with location_id for the detail tools.",
34978
+ description: "Search TripAdvisor locations (restaurants, attractions, hotels) by name. Returns matches with a location id for the detail tools, plus pagination.",
34990
34979
  annotations: { readOnlyHint: true, openWorldHint: true },
34991
34980
  inputSchema: {
34992
- searchQuery: external_exports.string().min(1).describe("Text to search location names for"),
34993
- latLong: LatLong.optional().describe('Center point to scope the search, e.g. "42.3455,-71.10767"'),
34994
- ...searchFilterParams
34981
+ query: external_exports.string().min(1).max(500).describe("Text to search location names for"),
34982
+ category: Category.optional().describe("Restrict to one category"),
34983
+ country_code: external_exports.string().length(2).optional().describe('Alpha-2 country code (e.g. "US")'),
34984
+ geo_name: external_exports.string().optional().describe("City, town, or country name to scope the search"),
34985
+ postal_code: external_exports.string().optional().describe("Postal/ZIP code (takes precedence over geo_name)"),
34986
+ locale: LocaleList,
34987
+ ...pageParams
34995
34988
  }
34996
34989
  },
34997
- async ({ searchQuery, latLong, category, phone, address, radius, radiusUnit, language }) => {
34990
+ async ({ query, category, country_code, geo_name, postal_code, locale, page, size }) => {
34998
34991
  const data = await client.get(
34999
- `/location/search${qs({ searchQuery, category, phone, address, latLong, radius, radiusUnit, language })}`,
34992
+ `/locations/search${qs({ query, category, country_code, geo_name, postal_code, locale, page, size })}`,
35000
34993
  { cache: "dynamic" }
35001
34994
  );
35002
34995
  return textResult(data);
@@ -35005,16 +34998,24 @@ function registerSearchTools(server) {
35005
34998
  server.registerTool(
35006
34999
  "ta_search_nearby",
35007
35000
  {
35008
- description: "Find TripAdvisor locations near a latitude/longitude. Returns up to 10 locations with location_id for the detail tools.",
35001
+ description: "Find TripAdvisor locations near a latitude/longitude within a radius. Returns matches with distance and a location id for the detail tools.",
35009
35002
  annotations: { readOnlyHint: true, openWorldHint: true },
35010
35003
  inputSchema: {
35011
- latLong: LatLong.describe('Center point, e.g. "42.3455,-71.10767"'),
35012
- ...searchFilterParams
35004
+ lat: external_exports.number().min(-90).max(90).describe("Center latitude"),
35005
+ lon: external_exports.number().min(-180).max(180).describe("Center longitude"),
35006
+ radius: external_exports.number().positive().describe("Search radius (must be > 0)"),
35007
+ unit: external_exports.enum(["MI", "KM"]).optional().describe("Radius unit (default MI)"),
35008
+ category: Category.optional().describe("Restrict to one category"),
35009
+ min_rating: external_exports.number().min(1).max(5).optional().describe("Minimum traveler rating (1.0\u20135.0)"),
35010
+ include_photo: external_exports.boolean().optional().describe("Include a photo per result"),
35011
+ sort: external_exports.enum(["distance", "rating"]).optional().describe("Sort order (default distance)"),
35012
+ locale: LocaleList,
35013
+ ...pageParams
35013
35014
  }
35014
35015
  },
35015
- async ({ latLong, category, phone, address, radius, radiusUnit, language }) => {
35016
+ async ({ lat, lon, radius, unit, category, min_rating, include_photo, sort, locale, page, size }) => {
35016
35017
  const data = await client.get(
35017
- `/location/nearby_search${qs({ latLong, category, phone, address, radius, radiusUnit, language })}`,
35018
+ `/locations/nearby${qs({ lat, lon, radius, unit, category, min_rating, include_photo, sort, locale, page, size })}`,
35018
35019
  { cache: "dynamic" }
35019
35020
  );
35020
35021
  return textResult(data);
@@ -35023,46 +35024,35 @@ function registerSearchTools(server) {
35023
35024
  }
35024
35025
 
35025
35026
  // src/tools/location.ts
35026
- var PhotoSource = external_exports.string().regex(
35027
- /^(Expert|Management|Traveler)(,(Expert|Management|Traveler))*$/,
35028
- "must be a comma-separated list of: Expert, Management, Traveler"
35029
- );
35030
35027
  function registerLocationTools(server) {
35031
35028
  server.registerTool(
35032
35029
  "ta_get_location_details",
35033
35030
  {
35034
- description: "Get full details for a TripAdvisor location: name, address, coordinates, rating, ranking, subratings, awards, review count, amenities, hours, and listing URLs.",
35031
+ description: "Get full details for a TripAdvisor location: names, descriptions, address, coordinates, traveler ratings, phone, category, and listing URLs.",
35035
35032
  annotations: { readOnlyHint: true, openWorldHint: true },
35036
35033
  inputSchema: {
35037
35034
  locationId: LocationId,
35038
- language: external_exports.string().optional().describe("Result language code (default: en)"),
35039
- currency: external_exports.string().optional().describe("ISO 4217 currency code for prices (default: USD)")
35035
+ locale: LocaleList
35040
35036
  }
35041
35037
  },
35042
- async ({ locationId, language, currency }) => {
35043
- const data = await client.get(`/location/${locationId}/details${qs({ language, currency })}`, {
35044
- cache: "static"
35045
- });
35038
+ async ({ locationId, locale }) => {
35039
+ const data = await client.get(`/locations/${locationId}${qs({ locale })}`, { cache: "static" });
35046
35040
  return textResult(data);
35047
35041
  }
35048
35042
  );
35049
35043
  server.registerTool(
35050
35044
  "ta_get_location_photos",
35051
35045
  {
35052
- description: "Get photos for a TripAdvisor location (multi-size image URLs, captions, sources).",
35046
+ description: "Get photos for a TripAdvisor location (multi-size image URLs, source, dimensions), with pagination.",
35053
35047
  annotations: { readOnlyHint: true, openWorldHint: true },
35054
35048
  inputSchema: {
35055
35049
  locationId: LocationId,
35056
- language: external_exports.string().optional().describe("Caption language code (default: en)"),
35057
- limit: external_exports.number().int().positive().optional().describe("Number of photos to return"),
35058
- offset: external_exports.number().int().min(0).optional().describe("Index of the first photo"),
35059
- source: PhotoSource.optional().describe(
35060
- "Comma-separated photo sources to allow: Expert, Management, Traveler (default: all)"
35061
- )
35050
+ locale: LocaleList,
35051
+ ...pageParams
35062
35052
  }
35063
35053
  },
35064
- async ({ locationId, language, limit, offset, source }) => {
35065
- const data = await client.get(`/location/${locationId}/photos${qs({ language, limit, offset, source })}`, {
35054
+ async ({ locationId, locale, page, size }) => {
35055
+ const data = await client.get(`/locations/${locationId}/photos${qs({ locale, page, size })}`, {
35066
35056
  cache: "static"
35067
35057
  });
35068
35058
  return textResult(data);
@@ -35071,17 +35061,16 @@ function registerLocationTools(server) {
35071
35061
  server.registerTool(
35072
35062
  "ta_get_location_reviews",
35073
35063
  {
35074
- description: "Get the most recent reviews for a TripAdvisor location (up to 5 per call; page with offset).",
35064
+ description: "Get traveler reviews for a TripAdvisor location, with pagination.",
35075
35065
  annotations: { readOnlyHint: true, openWorldHint: true },
35076
35066
  inputSchema: {
35077
35067
  locationId: LocationId,
35078
- language: external_exports.string().optional().describe("Review language code (default: en)"),
35079
- limit: external_exports.number().int().positive().optional().describe("Number of reviews to return"),
35080
- offset: external_exports.number().int().min(0).optional().describe("Index of the first review")
35068
+ locale: LocaleList,
35069
+ ...pageParams
35081
35070
  }
35082
35071
  },
35083
- async ({ locationId, language, limit, offset }) => {
35084
- const data = await client.get(`/location/${locationId}/reviews${qs({ language, limit, offset })}`, {
35072
+ async ({ locationId, locale, page, size }) => {
35073
+ const data = await client.get(`/locations/${locationId}/reviews${qs({ locale, page, size })}`, {
35085
35074
  cache: "static"
35086
35075
  });
35087
35076
  return textResult(data);
@@ -38513,6 +38502,56 @@ function createTripAdvisorTransport(createServer2) {
38513
38502
  });
38514
38503
  }
38515
38504
 
38505
+ // src/web/parse.ts
38506
+ function locationDetailPath(locationId) {
38507
+ return `/Attraction_Review-g1-d${locationId}-Reviews-a-a.html`;
38508
+ }
38509
+ function ldJsonBlocks(html) {
38510
+ const out = [];
38511
+ for (const m of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
38512
+ try {
38513
+ out.push(JSON.parse(m[1].trim()));
38514
+ } catch {
38515
+ }
38516
+ }
38517
+ return out;
38518
+ }
38519
+ function num(v) {
38520
+ if (v === void 0 || v === null) return void 0;
38521
+ const n = typeof v === "number" ? v : Number(v);
38522
+ return Number.isFinite(n) ? n : void 0;
38523
+ }
38524
+ function parseLocationDetail(html) {
38525
+ const node = ldJsonBlocks(html).find(
38526
+ (b) => typeof b === "object" && b !== null && typeof b.name === "string" && "aggregateRating" in b
38527
+ );
38528
+ if (!node) return null;
38529
+ const rating = node.aggregateRating ?? {};
38530
+ const geo = node.geo ?? {};
38531
+ const detail = { name: node.name };
38532
+ const assign = (key, value) => {
38533
+ if (value !== void 0) detail[key] = value;
38534
+ };
38535
+ assign("type", typeof node["@type"] === "string" ? node["@type"] : void 0);
38536
+ assign("url", typeof node.url === "string" ? node.url : void 0);
38537
+ assign("rating", num(rating.ratingValue));
38538
+ assign("review_count", num(rating.reviewCount));
38539
+ assign("best_rating", num(rating.bestRating));
38540
+ assign("telephone", typeof node.telephone === "string" ? node.telephone : void 0);
38541
+ assign("image", typeof node.image === "string" ? node.image : void 0);
38542
+ assign("latitude", num(geo.latitude));
38543
+ assign("longitude", num(geo.longitude));
38544
+ assign("same_as", typeof node.sameAs === "string" ? node.sameAs : void 0);
38545
+ if (node.address && typeof node.address === "object") {
38546
+ const addr = {};
38547
+ for (const [k, v] of Object.entries(node.address)) {
38548
+ if (k !== "@type" && typeof v === "string") addr[k] = v;
38549
+ }
38550
+ if (Object.keys(addr).length) detail.address = addr;
38551
+ }
38552
+ return detail;
38553
+ }
38554
+
38516
38555
  // src/web/client.ts
38517
38556
  var TripAdvisorWebClient = class {
38518
38557
  constructor(injected = {}) {
@@ -38599,6 +38638,10 @@ var TripAdvisorWebClient = class {
38599
38638
  });
38600
38639
  }
38601
38640
  }
38641
+ /** Fetch a location detail page's HTML by numeric d-id (canonicalized by TripAdvisor). */
38642
+ async getLocationHtml(locationId) {
38643
+ return this.getHtml(locationDetailPath(locationId));
38644
+ }
38602
38645
  assertNotWalled(status, body, path) {
38603
38646
  const wall = classifyBotWall(body, status);
38604
38647
  if (wall.blocked) {
@@ -38626,6 +38669,26 @@ function registerWebTools(server) {
38626
38669
  },
38627
38670
  probeFn: (path) => webClient.getHtml(path)
38628
38671
  });
38672
+ server.registerTool(
38673
+ "ta_web_get_location",
38674
+ {
38675
+ 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 \u2014 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.",
38676
+ annotations: { readOnlyHint: true, openWorldHint: true },
38677
+ inputSchema: {
38678
+ locationId: LocationId
38679
+ }
38680
+ },
38681
+ async ({ locationId }) => {
38682
+ const html = await webClient.getLocationHtml(locationId);
38683
+ const detail = parseLocationDetail(html);
38684
+ if (!detail) {
38685
+ throw new McpToolError(`Could not parse location ${locationId} from its TripAdvisor page.`, {
38686
+ hint: "The page may be a bot-challenge shell or the id may be wrong \u2014 run ta_web_healthcheck and confirm a signed-in www.tripadvisor.com tab is open, then retry."
38687
+ });
38688
+ }
38689
+ return textResult({ location_id: locationId, ...detail });
38690
+ }
38691
+ );
38629
38692
  }
38630
38693
 
38631
38694
  // src/index.ts
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 }));
@@ -1,53 +1,42 @@
1
- import { z } from 'zod';
2
1
  import { textResult } from '@chrischall/mcp-utils';
3
2
  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');
3
+ import { LocationId, LocaleList, pageParams, qs } from './shared.js';
9
4
  export function registerLocationTools(server) {
10
5
  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.',
6
+ description: 'Get full details for a TripAdvisor location: names, descriptions, address, coordinates, traveler ratings, phone, category, and listing URLs.',
12
7
  annotations: { readOnlyHint: true, openWorldHint: true },
13
8
  inputSchema: {
14
9
  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)'),
10
+ locale: LocaleList,
17
11
  },
18
- }, async ({ locationId, language, currency }) => {
19
- const data = await client.get(`/location/${locationId}/details${qs({ language, currency })}`, {
20
- cache: 'static',
21
- });
12
+ }, async ({ locationId, locale }) => {
13
+ const data = await client.get(`/locations/${locationId}${qs({ locale })}`, { cache: 'static' });
22
14
  return textResult(data);
23
15
  });
24
16
  server.registerTool('ta_get_location_photos', {
25
- description: 'Get photos for a TripAdvisor location (multi-size image URLs, captions, sources).',
17
+ description: 'Get photos for a TripAdvisor location (multi-size image URLs, source, dimensions), with pagination.',
26
18
  annotations: { readOnlyHint: true, openWorldHint: true },
27
19
  inputSchema: {
28
20
  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)'),
21
+ locale: LocaleList,
22
+ ...pageParams,
33
23
  },
34
- }, async ({ locationId, language, limit, offset, source }) => {
35
- const data = await client.get(`/location/${locationId}/photos${qs({ language, limit, offset, source })}`, {
24
+ }, async ({ locationId, locale, page, size }) => {
25
+ const data = await client.get(`/locations/${locationId}/photos${qs({ locale, page, size })}`, {
36
26
  cache: 'static',
37
27
  });
38
28
  return textResult(data);
39
29
  });
40
30
  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).',
31
+ description: 'Get traveler reviews for a TripAdvisor location, with pagination.',
42
32
  annotations: { readOnlyHint: true, openWorldHint: true },
43
33
  inputSchema: {
44
34
  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'),
35
+ locale: LocaleList,
36
+ ...pageParams,
48
37
  },
49
- }, async ({ locationId, language, limit, offset }) => {
50
- const data = await client.get(`/location/${locationId}/reviews${qs({ language, limit, offset })}`, {
38
+ }, async ({ locationId, locale, page, size }) => {
39
+ const data = await client.get(`/locations/${locationId}/reviews${qs({ locale, page, size })}`, {
51
40
  cache: 'static',
52
41
  });
53
42
  return textResult(data);
@@ -1,29 +1,41 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult } 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
5
  export function registerSearchTools(server) {
6
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.',
7
+ description: 'Search TripAdvisor locations (restaurants, attractions, hotels) by name. Returns matches with a location id for the detail tools, plus pagination.',
8
8
  annotations: { readOnlyHint: true, openWorldHint: true },
9
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,
10
+ query: z.string().min(1).max(500).describe('Text to search location names for'),
11
+ category: Category.optional().describe('Restrict to one category'),
12
+ country_code: z.string().length(2).optional().describe('Alpha-2 country code (e.g. "US")'),
13
+ geo_name: z.string().optional().describe('City, town, or country name to scope the search'),
14
+ postal_code: z.string().optional().describe('Postal/ZIP code (takes precedence over geo_name)'),
15
+ locale: LocaleList,
16
+ ...pageParams,
13
17
  },
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' });
18
+ }, async ({ query, category, country_code, geo_name, postal_code, locale, page, size }) => {
19
+ const data = await client.get(`/locations/search${qs({ query, category, country_code, geo_name, postal_code, locale, page, size })}`, { cache: 'dynamic' });
16
20
  return textResult(data);
17
21
  });
18
22
  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.',
23
+ description: 'Find TripAdvisor locations near a latitude/longitude within a radius. Returns matches with distance and a location id for the detail tools.',
20
24
  annotations: { readOnlyHint: true, openWorldHint: true },
21
25
  inputSchema: {
22
- latLong: LatLong.describe('Center point, e.g. "42.3455,-71.10767"'),
23
- ...searchFilterParams,
26
+ lat: z.number().min(-90).max(90).describe('Center latitude'),
27
+ lon: z.number().min(-180).max(180).describe('Center longitude'),
28
+ radius: z.number().positive().describe('Search radius (must be > 0)'),
29
+ unit: z.enum(['MI', 'KM']).optional().describe('Radius unit (default MI)'),
30
+ category: Category.optional().describe('Restrict to one category'),
31
+ min_rating: z.number().min(1).max(5).optional().describe('Minimum traveler rating (1.0–5.0)'),
32
+ include_photo: z.boolean().optional().describe('Include a photo per result'),
33
+ sort: z.enum(['distance', 'rating']).optional().describe('Sort order (default distance)'),
34
+ locale: LocaleList,
35
+ ...pageParams,
24
36
  },
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' });
37
+ }, async ({ lat, lon, radius, unit, category, min_rating, include_photo, sort, locale, page, size }) => {
38
+ const data = await client.get(`/locations/nearby${qs({ lat, lon, radius, unit, category, min_rating, include_photo, sort, locale, page, size })}`, { cache: 'dynamic' });
27
39
  return textResult(data);
28
40
  });
29
41
  }
@@ -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.1.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.1.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.1.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/tripadvisor-mcp",
14
- "version": "0.0.0",
14
+ "version": "0.1.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