@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.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/.mcp.json +3 -2
- package/README.md +31 -12
- package/SKILL.md +17 -16
- package/dist/bundle.js +141 -78
- package/dist/client.js +18 -25
- package/dist/tools/location.js +15 -26
- package/dist/tools/search.js +24 -12
- package/dist/tools/shared.js +13 -15
- package/dist/tools/web.js +19 -0
- package/dist/version.js +1 -1
- package/dist/web/client.js +5 -0
- package/dist/web/parse.js +70 -0
- package/dist/web/transport.js +1 -1
- package/package.json +4 -3
- package/server.json +4 -4
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
"email": "chris.c.hall@gmail.com"
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
|
-
"description": "MCP server for the TripAdvisor
|
|
10
|
-
"version": "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
|
|
18
|
-
"version": "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.
|
|
5
|
-
"description": "MCP server for the TripAdvisor
|
|
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
|
[](https://www.npmjs.com/package/@chrischall/tripadvisor-mcp)
|
|
4
4
|
|
|
5
|
-
MCP server for the **TripAdvisor
|
|
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-
|
|
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 **
|
|
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,
|
|
30
|
-
| `ta_search_nearby` | Find locations near a
|
|
31
|
-
| `ta_get_location_details` | Full details:
|
|
32
|
-
| `ta_get_location_photos` | Photos with multi-size image URLs,
|
|
33
|
-
| `ta_get_location_reviews` |
|
|
34
|
-
|
|
35
|
-
|
|
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 |
|
|
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
|
|
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
|
|
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-
|
|
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 **
|
|
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 |
|
|
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 — `
|
|
57
|
-
| `ta_search_nearby` | Find places near coordinates — `
|
|
58
|
-
| `ta_get_location_details` | Full listing for a `
|
|
59
|
-
| `ta_get_location_photos` | Photos (multi-size URLs
|
|
60
|
-
| `ta_get_location_reviews` |
|
|
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 `
|
|
65
|
-
2. `ta_get_location_details` for ratings/
|
|
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** —
|
|
69
|
-
- A `403`
|
|
70
|
-
-
|
|
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
|
|
7241
|
-
if (!Number.isInteger(
|
|
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 =
|
|
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
|
|
7254
|
-
if (!Number.isInteger(
|
|
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 =
|
|
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
|
|
7973
|
-
if (
|
|
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 =
|
|
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.
|
|
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://
|
|
34852
|
-
var SERVICE = "TripAdvisor
|
|
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
|
|
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
|
|
34933
|
-
const
|
|
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
|
|
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
|
|
34960
|
-
hint: "The
|
|
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
|
|
34971
|
-
var
|
|
34972
|
-
var
|
|
34973
|
-
|
|
34974
|
-
|
|
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 (
|
|
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
|
-
|
|
34993
|
-
|
|
34994
|
-
|
|
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 ({
|
|
34990
|
+
async ({ query, category, country_code, geo_name, postal_code, locale, page, size }) => {
|
|
34998
34991
|
const data = await client.get(
|
|
34999
|
-
`/
|
|
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
|
|
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
|
-
|
|
35012
|
-
|
|
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 ({
|
|
35016
|
+
async ({ lat, lon, radius, unit, category, min_rating, include_photo, sort, locale, page, size }) => {
|
|
35016
35017
|
const data = await client.get(
|
|
35017
|
-
`/
|
|
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:
|
|
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
|
-
|
|
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,
|
|
35043
|
-
const data = await client.get(`/
|
|
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,
|
|
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
|
-
|
|
35057
|
-
|
|
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,
|
|
35065
|
-
const data = await client.get(`/
|
|
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
|
|
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
|
-
|
|
35079
|
-
|
|
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,
|
|
35084
|
-
const data = await client.get(`/
|
|
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://
|
|
9
|
-
const SERVICE = 'TripAdvisor
|
|
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
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
const headers = { Accept: 'application/json' };
|
|
112
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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 ===
|
|
132
|
-
throw new McpToolError(`${SERVICE}
|
|
133
|
-
hint: '
|
|
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 ===
|
|
137
|
-
|
|
138
|
-
|
|
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 }));
|
package/dist/tools/location.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
16
|
-
currency: z.string().optional().describe('ISO 4217 currency code for prices (default: USD)'),
|
|
10
|
+
locale: LocaleList,
|
|
17
11
|
},
|
|
18
|
-
}, async ({ locationId,
|
|
19
|
-
const data = await client.get(`/
|
|
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,
|
|
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
|
-
|
|
30
|
-
|
|
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,
|
|
35
|
-
const data = await client.get(`/
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
offset: z.number().int().min(0).optional().describe('Index of the first review'),
|
|
35
|
+
locale: LocaleList,
|
|
36
|
+
...pageParams,
|
|
48
37
|
},
|
|
49
|
-
}, async ({ locationId,
|
|
50
|
-
const data = await client.get(`/
|
|
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);
|
package/dist/tools/search.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 ({
|
|
15
|
-
const data = await client.get(`/
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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 ({
|
|
26
|
-
const data = await client.get(`/
|
|
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
|
}
|
package/dist/tools/shared.js
CHANGED
|
@@ -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
|
-
/**
|
|
6
|
-
export const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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.
|
|
2
|
+
export const VERSION = '0.1.0'; // x-release-please-version
|
package/dist/web/client.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/web/transport.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/tripadvisor-mcp",
|
|
5
|
-
"description": "TripAdvisor
|
|
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
|
|
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.
|
|
9
|
+
"version": "0.1.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/tripadvisor-mcp",
|
|
14
|
-
"version": "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
|
|
21
|
+
"description": "Your TripAdvisor Terra API key (tripadvisor.com/developers)",
|
|
22
22
|
"isRequired": true,
|
|
23
23
|
"format": "string",
|
|
24
24
|
"isSecret": true
|