@chrischall/tripadvisor-mcp 0.1.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for the TripAdvisor Terra API — location search, details, photos, and reviews",
10
- "version": "0.1.0"
10
+ "version": "0.2.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "TripAdvisor",
16
16
  "source": "./",
17
17
  "description": "TripAdvisor travel data via the Terra API — search hotels, restaurants, and attractions, with details, photos, and reviews",
18
- "version": "0.1.0",
18
+ "version": "0.2.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tripadvisor-mcp",
3
3
  "displayName": "TripAdvisor",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "MCP server for the TripAdvisor Terra API — location search, details, photos, and reviews",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -26,9 +26,10 @@ Get a key at [tripadvisor.com/developers](https://www.tripadvisor.com/developers
26
26
 
27
27
  | Tool | What it does |
28
28
  | --- | --- |
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 |
29
+ | `ta_search_locations` | Search locations by name (optionally scoped by category, country/geo/postal code) — paginated; `compact:true` for slim summaries |
30
+ | `ta_search_nearby` | Find locations near a lat/lon+radius, a `location_id`+radius, or inside a sw/ne bounding box (category, min rating, sort) — `compact:true` supported |
31
31
  | `ta_get_location_details` | Full details: names, descriptions, address, coordinates, traveler ratings, phone, listing URLs |
32
+ | `ta_get_locations` | Batch — details for **multiple** location ids in one call (cheaper than N detail calls); `compact:true` supported |
32
33
  | `ta_get_location_photos` | Photos with multi-size image URLs, source, and dimensions — paginated |
33
34
  | `ta_get_location_reviews` | Traveler reviews — paginated |
34
35
  | `ta_web_healthcheck` | Diagnose the optional tripadvisor.com browser-bridge connection (see below) |
package/SKILL.md CHANGED
@@ -52,9 +52,10 @@ npm install && npm run build
52
52
 
53
53
  | Tool | Use for |
54
54
  | --- | --- |
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`. |
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). `compact:true` → slim summaries. Returns matches with a location `id`. |
56
+ | `ta_search_nearby` | Find places near a center supply **one** of: `lat`+`lon`+`radius`, `location_id`+`radius`, or a `sw_lat`/`sw_lon`/`ne_lat`/`ne_lon` box (`unit` `MI`/`KM`); filter `category`, `min_rating`, `sort` (`distance`/`rating`), `include_photo`. `compact:true` supported. |
57
57
  | `ta_get_location_details` | Full listing for a location `id`: names, descriptions, address, coordinates, traveler ratings, phone, URLs. Optional `locale`. |
58
+ | `ta_get_locations` | Batch details for **multiple** ids (`ids: [..]`, 1–50) in one call — cheaper than repeated details; unknown ids are omitted. `compact:true` supported. |
58
59
  | `ta_get_location_photos` | Photos (multi-size URLs, source, dimensions). Page with `page`/`size`. |
59
60
  | `ta_get_location_reviews` | Traveler reviews. Page with `page`/`size`. |
60
61
  | `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. |
package/dist/bundle.js CHANGED
@@ -108,7 +108,7 @@ var require_code = __commonJS({
108
108
  }
109
109
  exports._ = _;
110
110
  var plus = new _Code("+");
111
- function str(strs, ...args) {
111
+ function str2(strs, ...args) {
112
112
  const expr = [safeStringify(strs[0])];
113
113
  let i = 0;
114
114
  while (i < args.length) {
@@ -119,7 +119,7 @@ var require_code = __commonJS({
119
119
  optimize(expr);
120
120
  return new _Code(expr);
121
121
  }
122
- exports.str = str;
122
+ exports.str = str2;
123
123
  function addCodeArg(code, arg) {
124
124
  if (arg instanceof _Code)
125
125
  code.push(...arg._items);
@@ -162,7 +162,7 @@ var require_code = __commonJS({
162
162
  return;
163
163
  }
164
164
  function strConcat(c1, c2) {
165
- return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str`${c1}${c2}`;
165
+ return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str2`${c1}${c2}`;
166
166
  }
167
167
  exports.strConcat = strConcat;
168
168
  function interpolate(x) {
@@ -1124,22 +1124,22 @@ var require_util = __commonJS({
1124
1124
  return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
1125
1125
  }
1126
1126
  exports.schemaRefOrVal = schemaRefOrVal;
1127
- function unescapeFragment(str) {
1128
- return unescapeJsonPointer(decodeURIComponent(str));
1127
+ function unescapeFragment(str2) {
1128
+ return unescapeJsonPointer(decodeURIComponent(str2));
1129
1129
  }
1130
1130
  exports.unescapeFragment = unescapeFragment;
1131
- function escapeFragment(str) {
1132
- return encodeURIComponent(escapeJsonPointer(str));
1131
+ function escapeFragment(str2) {
1132
+ return encodeURIComponent(escapeJsonPointer(str2));
1133
1133
  }
1134
1134
  exports.escapeFragment = escapeFragment;
1135
- function escapeJsonPointer(str) {
1136
- if (typeof str == "number")
1137
- return `${str}`;
1138
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
1135
+ function escapeJsonPointer(str2) {
1136
+ if (typeof str2 == "number")
1137
+ return `${str2}`;
1138
+ return str2.replace(/~/g, "~0").replace(/\//g, "~1");
1139
1139
  }
1140
1140
  exports.escapeJsonPointer = escapeJsonPointer;
1141
- function unescapeJsonPointer(str) {
1142
- return str.replace(/~1/g, "/").replace(/~0/g, "~");
1141
+ function unescapeJsonPointer(str2) {
1142
+ return str2.replace(/~1/g, "/").replace(/~0/g, "~");
1143
1143
  }
1144
1144
  exports.unescapeJsonPointer = unescapeJsonPointer;
1145
1145
  function eachItem(xs, f) {
@@ -2164,8 +2164,8 @@ var require_json_schema_traverse = __commonJS({
2164
2164
  post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex);
2165
2165
  }
2166
2166
  }
2167
- function escapeJsonPtr(str) {
2168
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
2167
+ function escapeJsonPtr(str2) {
2168
+ return str2.replace(/~/g, "~0").replace(/\//g, "~1");
2169
2169
  }
2170
2170
  }
2171
2171
  });
@@ -2701,8 +2701,8 @@ var require_validate = __commonJS({
2701
2701
  if (!this.allErrors)
2702
2702
  this.gen.if(cond);
2703
2703
  }
2704
- setParams(obj, assign) {
2705
- if (assign)
2704
+ setParams(obj, assign2) {
2705
+ if (assign2)
2706
2706
  Object.assign(this.params, obj);
2707
2707
  else
2708
2708
  this.params = obj;
@@ -3229,10 +3229,10 @@ var require_utils = __commonJS({
3229
3229
  return { host, isIPV6: false };
3230
3230
  }
3231
3231
  }
3232
- function findToken(str, token) {
3232
+ function findToken(str2, token) {
3233
3233
  let ind = 0;
3234
- for (let i = 0; i < str.length; i++) {
3235
- if (str[i] === token) ind++;
3234
+ for (let i = 0; i < str2.length; i++) {
3235
+ if (str2[i] === token) ind++;
3236
3236
  }
3237
3237
  return ind;
3238
3238
  }
@@ -3969,7 +3969,7 @@ var require_core = __commonJS({
3969
3969
  var util_1 = require_util();
3970
3970
  var $dataRefSchema = require_data();
3971
3971
  var uri_1 = require_uri();
3972
- var defaultRegExp = (str, flags) => new RegExp(str, flags);
3972
+ var defaultRegExp = (str2, flags) => new RegExp(str2, flags);
3973
3973
  defaultRegExp.code = "new RegExp";
3974
3974
  var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
3975
3975
  var EXT_SCOPE_NAMES = /* @__PURE__ */ new Set([
@@ -4764,16 +4764,16 @@ var require_ucs2length = __commonJS({
4764
4764
  "node_modules/ajv/dist/runtime/ucs2length.js"(exports) {
4765
4765
  "use strict";
4766
4766
  Object.defineProperty(exports, "__esModule", { value: true });
4767
- function ucs2length(str) {
4768
- const len = str.length;
4767
+ function ucs2length(str2) {
4768
+ const len = str2.length;
4769
4769
  let length = 0;
4770
4770
  let pos = 0;
4771
4771
  let value;
4772
4772
  while (pos < len) {
4773
4773
  length++;
4774
- value = str.charCodeAt(pos++);
4774
+ value = str2.charCodeAt(pos++);
4775
4775
  if (value >= 55296 && value <= 56319 && pos < len) {
4776
- value = str.charCodeAt(pos);
4776
+ value = str2.charCodeAt(pos);
4777
4777
  if ((value & 64512) === 56320)
4778
4778
  pos++;
4779
4779
  }
@@ -6656,8 +6656,8 @@ var require_formats = __commonJS({
6656
6656
  }
6657
6657
  var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
6658
6658
  var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
6659
- function date5(str) {
6660
- const matches = DATE.exec(str);
6659
+ function date5(str2) {
6660
+ const matches = DATE.exec(str2);
6661
6661
  if (!matches)
6662
6662
  return false;
6663
6663
  const year = +matches[1];
@@ -6676,8 +6676,8 @@ var require_formats = __commonJS({
6676
6676
  }
6677
6677
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
6678
6678
  function getTime(strictTimeZone) {
6679
- return function time3(str) {
6680
- const matches = TIME.exec(str);
6679
+ return function time3(str2) {
6680
+ const matches = TIME.exec(str2);
6681
6681
  if (!matches)
6682
6682
  return false;
6683
6683
  const hr = +matches[1];
@@ -6723,8 +6723,8 @@ var require_formats = __commonJS({
6723
6723
  var DATE_TIME_SEPARATOR = /t|\s/i;
6724
6724
  function getDateTime(strictTimeZone) {
6725
6725
  const time3 = getTime(strictTimeZone);
6726
- return function date_time(str) {
6727
- const dateTime = str.split(DATE_TIME_SEPARATOR);
6726
+ return function date_time(str2) {
6727
+ const dateTime = str2.split(DATE_TIME_SEPARATOR);
6728
6728
  return dateTime.length === 2 && date5(dateTime[0]) && time3(dateTime[1]);
6729
6729
  };
6730
6730
  }
@@ -6749,13 +6749,13 @@ var require_formats = __commonJS({
6749
6749
  }
6750
6750
  var NOT_URI_FRAGMENT = /\/|:/;
6751
6751
  var URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
6752
- function uri(str) {
6753
- return NOT_URI_FRAGMENT.test(str) && URI.test(str);
6752
+ function uri(str2) {
6753
+ return NOT_URI_FRAGMENT.test(str2) && URI.test(str2);
6754
6754
  }
6755
6755
  var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm;
6756
- function byte(str) {
6756
+ function byte(str2) {
6757
6757
  BYTE.lastIndex = 0;
6758
- return BYTE.test(str);
6758
+ return BYTE.test(str2);
6759
6759
  }
6760
6760
  var MIN_INT32 = -(2 ** 31);
6761
6761
  var MAX_INT32 = 2 ** 31 - 1;
@@ -6769,11 +6769,11 @@ var require_formats = __commonJS({
6769
6769
  return true;
6770
6770
  }
6771
6771
  var Z_ANCHOR = /[^\\]\\Z/;
6772
- function regex(str) {
6773
- if (Z_ANCHOR.test(str))
6772
+ function regex(str2) {
6773
+ if (Z_ANCHOR.test(str2))
6774
6774
  return false;
6775
6775
  try {
6776
- new RegExp(str);
6776
+ new RegExp(str2);
6777
6777
  return true;
6778
6778
  } catch (e) {
6779
6779
  return false;
@@ -14992,14 +14992,14 @@ function promiseAllObject(promisesObj) {
14992
14992
  }
14993
14993
  function randomString(length = 10) {
14994
14994
  const chars = "abcdefghijklmnopqrstuvwxyz";
14995
- let str = "";
14995
+ let str2 = "";
14996
14996
  for (let i = 0; i < length; i++) {
14997
- str += chars[Math.floor(Math.random() * chars.length)];
14997
+ str2 += chars[Math.floor(Math.random() * chars.length)];
14998
14998
  }
14999
- return str;
14999
+ return str2;
15000
15000
  }
15001
- function esc(str) {
15002
- return JSON.stringify(str);
15001
+ function esc(str2) {
15002
+ return JSON.stringify(str2);
15003
15003
  }
15004
15004
  function slugify(input) {
15005
15005
  return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -15113,8 +15113,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
15113
15113
  "symbol",
15114
15114
  "undefined"
15115
15115
  ]);
15116
- function escapeRegex(str) {
15117
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15116
+ function escapeRegex(str2) {
15117
+ return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15118
15118
  }
15119
15119
  function clone(inst, def, params) {
15120
15120
  const cl = new inst._zod.constr(def ?? inst._zod.def);
@@ -34722,8 +34722,8 @@ function redactSecrets(text) {
34722
34722
  return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
34723
34723
  }
34724
34724
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
34725
- const str = text === null || text === void 0 ? "" : String(text);
34726
- const redacted = redactSecrets(str);
34725
+ const str2 = text === null || text === void 0 ? "" : String(text);
34726
+ const redacted = redactSecrets(str2);
34727
34727
  if (redacted.length <= max)
34728
34728
  return redacted;
34729
34729
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
@@ -34841,7 +34841,7 @@ var pageSchema = {
34841
34841
  };
34842
34842
 
34843
34843
  // src/version.ts
34844
- var VERSION = "0.1.0";
34844
+ var VERSION = "0.2.0";
34845
34845
 
34846
34846
  // src/client.ts
34847
34847
  import { dirname, join } from "node:path";
@@ -34970,12 +34970,74 @@ function qs(params) {
34970
34970
  return buildQueryString(params);
34971
34971
  }
34972
34972
 
34973
+ // src/projection.ts
34974
+ var str = (v) => typeof v === "string" ? v : void 0;
34975
+ var numOf = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
34976
+ function primaryValue(arr) {
34977
+ if (!Array.isArray(arr) || arr.length === 0) return void 0;
34978
+ const primary = arr.find((e) => e && typeof e === "object" && e.primary === true);
34979
+ const pick2 = primary ?? arr[0];
34980
+ return str(pick2?.value);
34981
+ }
34982
+ function categoryFromUrl(url2) {
34983
+ if (!url2) return void 0;
34984
+ const m = /\/(Attraction|Hotel|Restaurant)_Review/.exec(url2);
34985
+ return m ? m[1].toUpperCase() : void 0;
34986
+ }
34987
+ function assign(o, k, v) {
34988
+ if (v !== void 0) o[k] = v;
34989
+ }
34990
+ function compactLocation(loc) {
34991
+ const addr = Array.isArray(loc.addresses) ? loc.addresses[0] : void 0;
34992
+ const url2 = str(loc.urls?.tripadvisor && loc.urls.tripadvisor.main);
34993
+ const overall = loc.traveler_ratings?.overall ?? {};
34994
+ const out = {};
34995
+ assign(out, "id", numOf(loc.id));
34996
+ assign(out, "name", primaryValue(loc.names));
34997
+ assign(out, "category", categoryFromUrl(url2));
34998
+ assign(out, "geo", str(loc.geo));
34999
+ assign(out, "city", str(addr?.city));
35000
+ assign(out, "state", str(addr?.state));
35001
+ assign(out, "rating", numOf(overall.rating));
35002
+ assign(out, "review_count", numOf(overall.count));
35003
+ assign(out, "url", url2);
35004
+ return out;
35005
+ }
35006
+ function compactList(raw) {
35007
+ const env = raw;
35008
+ const rows = env?.data;
35009
+ if (!Array.isArray(rows) || !rows.every((r) => r && typeof r === "object" && "location" in r)) {
35010
+ console.error("[tripadvisor] compact: unexpected response shape, returning raw payload");
35011
+ return raw;
35012
+ }
35013
+ const data = rows.map((r) => {
35014
+ const row = r;
35015
+ const item = compactLocation(row.location);
35016
+ assign(item, "distance_miles", numOf(row.distance_miles));
35017
+ assign(item, "distance_kilometers", numOf(row.distance_kilometers));
35018
+ return item;
35019
+ });
35020
+ return env.pagination !== void 0 ? { data, pagination: env.pagination } : { data };
35021
+ }
35022
+ function compactLocationList(raw) {
35023
+ const env = raw;
35024
+ const rows = env?.data;
35025
+ if (!Array.isArray(rows) || !rows.every((r) => r && typeof r === "object" && "id" in r)) {
35026
+ console.error("[tripadvisor] compact: unexpected response shape, returning raw payload");
35027
+ return raw;
35028
+ }
35029
+ return { data: rows.map((r) => compactLocation(r)) };
35030
+ }
35031
+
34973
35032
  // src/tools/search.ts
35033
+ var compactParam = {
35034
+ compact: external_exports.boolean().optional().describe("Return a slim summary per result (id, name, category, city, rating, review_count, url) instead of full records")
35035
+ };
34974
35036
  function registerSearchTools(server) {
34975
35037
  server.registerTool(
34976
35038
  "ta_search_locations",
34977
35039
  {
34978
- description: "Search TripAdvisor locations (restaurants, attractions, hotels) by name. Returns matches with a location id for the detail tools, plus pagination.",
35040
+ 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.",
34979
35041
  annotations: { readOnlyHint: true, openWorldHint: true },
34980
35042
  inputSchema: {
34981
35043
  query: external_exports.string().min(1).max(500).describe("Text to search location names for"),
@@ -34984,47 +35046,98 @@ function registerSearchTools(server) {
34984
35046
  geo_name: external_exports.string().optional().describe("City, town, or country name to scope the search"),
34985
35047
  postal_code: external_exports.string().optional().describe("Postal/ZIP code (takes precedence over geo_name)"),
34986
35048
  locale: LocaleList,
34987
- ...pageParams
35049
+ ...pageParams,
35050
+ ...compactParam
34988
35051
  }
34989
35052
  },
34990
- async ({ query, category, country_code, geo_name, postal_code, locale, page, size }) => {
35053
+ async ({ query, category, country_code, geo_name, postal_code, locale, page, size, compact }) => {
34991
35054
  const data = await client.get(
34992
35055
  `/locations/search${qs({ query, category, country_code, geo_name, postal_code, locale, page, size })}`,
34993
35056
  { cache: "dynamic" }
34994
35057
  );
34995
- return textResult(data);
35058
+ return textResult(compact ? compactList(data) : data);
34996
35059
  }
34997
35060
  );
34998
35061
  server.registerTool(
34999
35062
  "ta_search_nearby",
35000
35063
  {
35001
- description: "Find TripAdvisor locations near a latitude/longitude within a radius. Returns matches with distance and a location id for the detail tools.",
35064
+ 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.",
35002
35065
  annotations: { readOnlyHint: true, openWorldHint: true },
35003
35066
  inputSchema: {
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)"),
35067
+ // Center — supply exactly one of: lat+lon, location_id, or the sw/ne box.
35068
+ lat: external_exports.number().min(-90).max(90).optional().describe("Center latitude (with lon+radius)"),
35069
+ lon: external_exports.number().min(-180).max(180).optional().describe("Center longitude (with lat+radius)"),
35070
+ location_id: external_exports.number().int().positive().optional().describe("Reference location as center (with radius)"),
35071
+ radius: external_exports.number().positive().optional().describe("Search radius (required with lat/lon or location_id; must be > 0)"),
35007
35072
  unit: external_exports.enum(["MI", "KM"]).optional().describe("Radius unit (default MI)"),
35073
+ sw_lat: external_exports.number().min(-90).max(90).optional().describe("Bounding box SW latitude"),
35074
+ sw_lon: external_exports.number().min(-180).max(180).optional().describe("Bounding box SW longitude"),
35075
+ ne_lat: external_exports.number().min(-90).max(90).optional().describe("Bounding box NE latitude"),
35076
+ ne_lon: external_exports.number().min(-180).max(180).optional().describe("Bounding box NE longitude"),
35008
35077
  category: Category.optional().describe("Restrict to one category"),
35009
35078
  min_rating: external_exports.number().min(1).max(5).optional().describe("Minimum traveler rating (1.0\u20135.0)"),
35010
35079
  include_photo: external_exports.boolean().optional().describe("Include a photo per result"),
35011
35080
  sort: external_exports.enum(["distance", "rating"]).optional().describe("Sort order (default distance)"),
35012
35081
  locale: LocaleList,
35013
- ...pageParams
35082
+ ...pageParams,
35083
+ ...compactParam
35014
35084
  }
35015
35085
  },
35016
- async ({ lat, lon, radius, unit, category, min_rating, include_photo, sort, locale, page, size }) => {
35086
+ async (args) => {
35087
+ const { lat, lon, location_id, radius, sw_lat, sw_lon, ne_lat, ne_lon, compact, ...rest } = args;
35088
+ const boxParts = [sw_lat, sw_lon, ne_lat, ne_lon];
35089
+ const boxGiven = boxParts.filter((v) => v !== void 0).length;
35090
+ if (boxGiven > 0 && boxGiven < 4) {
35091
+ throw new McpToolError("ta_search_nearby bounding box needs all four of sw_lat, sw_lon, ne_lat, ne_lon.", {
35092
+ hint: "Provide all four box corners, or use lat+lon+radius or location_id+radius instead."
35093
+ });
35094
+ }
35095
+ if (lat === void 0 !== (lon === void 0)) {
35096
+ throw new McpToolError("ta_search_nearby needs both lat and lon together (or neither).", {
35097
+ hint: "Provide lat AND lon with a radius, or use location_id+radius or a sw/ne box."
35098
+ });
35099
+ }
35100
+ const hasLatLon = lat !== void 0 && lon !== void 0;
35101
+ const hasBox = boxGiven === 4;
35102
+ const centers = [hasLatLon, location_id !== void 0, hasBox].filter(Boolean).length;
35103
+ if (centers !== 1) {
35104
+ throw new McpToolError(
35105
+ "ta_search_nearby needs exactly one center: lat+lon (+radius), location_id (+radius), or the sw/ne bounding box.",
35106
+ { hint: "Provide lat AND lon, OR location_id, OR all four of sw_lat/sw_lon/ne_lat/ne_lon \u2014 not more than one." }
35107
+ );
35108
+ }
35109
+ if ((hasLatLon || location_id !== void 0) && radius === void 0) {
35110
+ throw new McpToolError("lat/lon and location_id center modes require radius.", {
35111
+ hint: "Add radius (with optional unit MI/KM), or switch to a sw/ne bounding box."
35112
+ });
35113
+ }
35017
35114
  const data = await client.get(
35018
- `/locations/nearby${qs({ lat, lon, radius, unit, category, min_rating, include_photo, sort, locale, page, size })}`,
35115
+ `/locations/nearby${qs({ lat, lon, location_id, radius, sw_lat, sw_lon, ne_lat, ne_lon, ...rest })}`,
35019
35116
  { cache: "dynamic" }
35020
35117
  );
35021
- return textResult(data);
35118
+ return textResult(compact ? compactList(data) : data);
35022
35119
  }
35023
35120
  );
35024
35121
  }
35025
35122
 
35026
35123
  // src/tools/location.ts
35027
35124
  function registerLocationTools(server) {
35125
+ server.registerTool(
35126
+ "ta_get_locations",
35127
+ {
35128
+ description: "Get details for MULTIPLE locations in one call (batch). Pass an array of location ids \u2014 cheaper than repeated ta_get_location_details. Unknown or unlicensed ids are silently omitted. Pass compact:true for slim summaries.",
35129
+ annotations: { readOnlyHint: true, openWorldHint: true },
35130
+ inputSchema: {
35131
+ ids: external_exports.array(LocationId).min(1).max(50).describe("Location IDs to fetch (1\u201350)"),
35132
+ locale: LocaleList,
35133
+ compact: external_exports.boolean().optional().describe("Return a slim summary per location instead of full records")
35134
+ }
35135
+ },
35136
+ async ({ ids, locale, compact }) => {
35137
+ const data = await client.get(`/locations${qs({ id: ids, locale })}`, { cache: "static" });
35138
+ return textResult(compact ? compactLocationList(data) : data);
35139
+ }
35140
+ );
35028
35141
  server.registerTool(
35029
35142
  "ta_get_location_details",
35030
35143
  {
@@ -38529,19 +38642,19 @@ function parseLocationDetail(html) {
38529
38642
  const rating = node.aggregateRating ?? {};
38530
38643
  const geo = node.geo ?? {};
38531
38644
  const detail = { name: node.name };
38532
- const assign = (key, value) => {
38645
+ const assign2 = (key, value) => {
38533
38646
  if (value !== void 0) detail[key] = value;
38534
38647
  };
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);
38648
+ assign2("type", typeof node["@type"] === "string" ? node["@type"] : void 0);
38649
+ assign2("url", typeof node.url === "string" ? node.url : void 0);
38650
+ assign2("rating", num(rating.ratingValue));
38651
+ assign2("review_count", num(rating.reviewCount));
38652
+ assign2("best_rating", num(rating.bestRating));
38653
+ assign2("telephone", typeof node.telephone === "string" ? node.telephone : void 0);
38654
+ assign2("image", typeof node.image === "string" ? node.image : void 0);
38655
+ assign2("latitude", num(geo.latitude));
38656
+ assign2("longitude", num(geo.longitude));
38657
+ assign2("same_as", typeof node.sameAs === "string" ? node.sameAs : void 0);
38545
38658
  if (node.address && typeof node.address === "object") {
38546
38659
  const addr = {};
38547
38660
  for (const [k, v] of Object.entries(node.address)) {
@@ -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,7 +1,24 @@
1
+ import { z } from 'zod';
1
2
  import { textResult } from '@chrischall/mcp-utils';
2
3
  import { client } from '../client.js';
3
4
  import { LocationId, LocaleList, pageParams, qs } from './shared.js';
5
+ import { compactLocationList } from '../projection.js';
4
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
+ });
5
22
  server.registerTool('ta_get_location_details', {
6
23
  description: 'Get full details for a TripAdvisor location: names, descriptions, address, coordinates, traveler ratings, phone, category, and listing URLs.',
7
24
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -1,10 +1,18 @@
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
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 (restaurants, attractions, hotels) by name. Returns matches with a location id for the detail tools, plus pagination.',
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
18
  query: z.string().min(1).max(500).describe('Text to search location names for'),
@@ -14,28 +22,65 @@ export function registerSearchTools(server) {
14
22
  postal_code: z.string().optional().describe('Postal/ZIP code (takes precedence over geo_name)'),
15
23
  locale: LocaleList,
16
24
  ...pageParams,
25
+ ...compactParam,
17
26
  },
18
- }, async ({ query, category, country_code, geo_name, postal_code, locale, page, size }) => {
27
+ }, async ({ query, category, country_code, geo_name, postal_code, locale, page, size, compact }) => {
19
28
  const data = await client.get(`/locations/search${qs({ query, category, country_code, geo_name, postal_code, locale, page, size })}`, { cache: 'dynamic' });
20
- return textResult(data);
29
+ return textResult(compact ? compactList(data) : data);
21
30
  });
22
31
  server.registerTool('ta_search_nearby', {
23
- description: 'Find TripAdvisor locations near a latitude/longitude within a radius. Returns matches with distance and a 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.',
24
33
  annotations: { readOnlyHint: true, openWorldHint: true },
25
34
  inputSchema: {
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)'),
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)'),
29
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'),
30
45
  category: Category.optional().describe('Restrict to one category'),
31
46
  min_rating: z.number().min(1).max(5).optional().describe('Minimum traveler rating (1.0–5.0)'),
32
47
  include_photo: z.boolean().optional().describe('Include a photo per result'),
33
48
  sort: z.enum(['distance', 'rating']).optional().describe('Sort order (default distance)'),
34
49
  locale: LocaleList,
35
50
  ...pageParams,
51
+ ...compactParam,
36
52
  },
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' });
39
- 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);
40
85
  });
41
86
  }
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.1.0'; // x-release-please-version
2
+ export const VERSION = '0.2.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/tripadvisor-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "mcpName": "io.github.chrischall/tripadvisor-mcp",
5
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>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/tripadvisor-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.1.0",
9
+ "version": "0.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/tripadvisor-mcp",
14
- "version": "0.1.0",
14
+ "version": "0.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },