@chrischall/tripadvisor-mcp 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/.mcp.json +3 -2
- package/README.md +32 -12
- package/SKILL.md +18 -16
- package/dist/bundle.js +303 -127
- package/dist/client.js +18 -25
- package/dist/projection.js +78 -0
- package/dist/tools/location.js +31 -25
- package/dist/tools/search.js +72 -15
- 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
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 }));
|
|
@@ -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
|
+
}
|
package/dist/tools/location.js
CHANGED
|
@@ -1,53 +1,59 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult } from '@chrischall/mcp-utils';
|
|
3
3
|
import { client } from '../client.js';
|
|
4
|
-
import { LocationId, qs } from './shared.js';
|
|
5
|
-
|
|
6
|
-
const PhotoSource = z
|
|
7
|
-
.string()
|
|
8
|
-
.regex(/^(Expert|Management|Traveler)(,(Expert|Management|Traveler))*$/, 'must be a comma-separated list of: Expert, Management, Traveler');
|
|
4
|
+
import { LocationId, LocaleList, pageParams, qs } from './shared.js';
|
|
5
|
+
import { compactLocationList } from '../projection.js';
|
|
9
6
|
export function registerLocationTools(server) {
|
|
7
|
+
server.registerTool('ta_get_locations', {
|
|
8
|
+
description: 'Get details for MULTIPLE locations in one call (batch). Pass an array of location ids — cheaper than repeated ta_get_location_details. Unknown or unlicensed ids are silently omitted. Pass compact:true for slim summaries.',
|
|
9
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
10
|
+
inputSchema: {
|
|
11
|
+
ids: z.array(LocationId).min(1).max(50).describe('Location IDs to fetch (1–50)'),
|
|
12
|
+
locale: LocaleList,
|
|
13
|
+
compact: z
|
|
14
|
+
.boolean()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Return a slim summary per location instead of full records'),
|
|
17
|
+
},
|
|
18
|
+
}, async ({ ids, locale, compact }) => {
|
|
19
|
+
const data = await client.get(`/locations${qs({ id: ids, locale })}`, { cache: 'static' });
|
|
20
|
+
return textResult(compact ? compactLocationList(data) : data);
|
|
21
|
+
});
|
|
10
22
|
server.registerTool('ta_get_location_details', {
|
|
11
|
-
description: 'Get full details for a TripAdvisor location:
|
|
23
|
+
description: 'Get full details for a TripAdvisor location: names, descriptions, address, coordinates, traveler ratings, phone, category, and listing URLs.',
|
|
12
24
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
13
25
|
inputSchema: {
|
|
14
26
|
locationId: LocationId,
|
|
15
|
-
|
|
16
|
-
currency: z.string().optional().describe('ISO 4217 currency code for prices (default: USD)'),
|
|
27
|
+
locale: LocaleList,
|
|
17
28
|
},
|
|
18
|
-
}, async ({ locationId,
|
|
19
|
-
const data = await client.get(`/
|
|
20
|
-
cache: 'static',
|
|
21
|
-
});
|
|
29
|
+
}, async ({ locationId, locale }) => {
|
|
30
|
+
const data = await client.get(`/locations/${locationId}${qs({ locale })}`, { cache: 'static' });
|
|
22
31
|
return textResult(data);
|
|
23
32
|
});
|
|
24
33
|
server.registerTool('ta_get_location_photos', {
|
|
25
|
-
description: 'Get photos for a TripAdvisor location (multi-size image URLs,
|
|
34
|
+
description: 'Get photos for a TripAdvisor location (multi-size image URLs, source, dimensions), with pagination.',
|
|
26
35
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
27
36
|
inputSchema: {
|
|
28
37
|
locationId: LocationId,
|
|
29
|
-
|
|
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)'),
|
|
38
|
+
locale: LocaleList,
|
|
39
|
+
...pageParams,
|
|
33
40
|
},
|
|
34
|
-
}, async ({ locationId,
|
|
35
|
-
const data = await client.get(`/
|
|
41
|
+
}, async ({ locationId, locale, page, size }) => {
|
|
42
|
+
const data = await client.get(`/locations/${locationId}/photos${qs({ locale, page, size })}`, {
|
|
36
43
|
cache: 'static',
|
|
37
44
|
});
|
|
38
45
|
return textResult(data);
|
|
39
46
|
});
|
|
40
47
|
server.registerTool('ta_get_location_reviews', {
|
|
41
|
-
description: 'Get
|
|
48
|
+
description: 'Get traveler reviews for a TripAdvisor location, with pagination.',
|
|
42
49
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
43
50
|
inputSchema: {
|
|
44
51
|
locationId: LocationId,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
offset: z.number().int().min(0).optional().describe('Index of the first review'),
|
|
52
|
+
locale: LocaleList,
|
|
53
|
+
...pageParams,
|
|
48
54
|
},
|
|
49
|
-
}, async ({ locationId,
|
|
50
|
-
const data = await client.get(`/
|
|
55
|
+
}, async ({ locationId, locale, page, size }) => {
|
|
56
|
+
const data = await client.get(`/locations/${locationId}/reviews${qs({ locale, page, size })}`, {
|
|
51
57
|
cache: 'static',
|
|
52
58
|
});
|
|
53
59
|
return textResult(data);
|
package/dist/tools/search.js
CHANGED
|
@@ -1,29 +1,86 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
import { textResult, McpToolError } from '@chrischall/mcp-utils';
|
|
3
3
|
import { client } from '../client.js';
|
|
4
|
-
import {
|
|
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 (
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
query: z.string().min(1).max(500).describe('Text to search location names for'),
|
|
19
|
+
category: Category.optional().describe('Restrict to one category'),
|
|
20
|
+
country_code: z.string().length(2).optional().describe('Alpha-2 country code (e.g. "US")'),
|
|
21
|
+
geo_name: z.string().optional().describe('City, town, or country name to scope the search'),
|
|
22
|
+
postal_code: z.string().optional().describe('Postal/ZIP code (takes precedence over geo_name)'),
|
|
23
|
+
locale: LocaleList,
|
|
24
|
+
...pageParams,
|
|
25
|
+
...compactParam,
|
|
13
26
|
},
|
|
14
|
-
}, async ({
|
|
15
|
-
const data = await client.get(`/
|
|
16
|
-
return textResult(data);
|
|
27
|
+
}, async ({ query, category, country_code, geo_name, postal_code, locale, page, size, compact }) => {
|
|
28
|
+
const data = await client.get(`/locations/search${qs({ query, category, country_code, geo_name, postal_code, locale, page, size })}`, { cache: 'dynamic' });
|
|
29
|
+
return textResult(compact ? compactList(data) : data);
|
|
17
30
|
});
|
|
18
31
|
server.registerTool('ta_search_nearby', {
|
|
19
|
-
description: 'Find TripAdvisor locations near a
|
|
32
|
+
description: 'Find TripAdvisor locations near a point within a radius, or inside a bounding box. Center by lat+lon+radius, by a reference location_id+radius, or by a sw/ne bounding box. Returns matches with distance and a location id. Pass compact:true for slim summaries.',
|
|
20
33
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
21
34
|
inputSchema: {
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
// Center — supply exactly one of: lat+lon, location_id, or the sw/ne box.
|
|
36
|
+
lat: z.number().min(-90).max(90).optional().describe('Center latitude (with lon+radius)'),
|
|
37
|
+
lon: z.number().min(-180).max(180).optional().describe('Center longitude (with lat+radius)'),
|
|
38
|
+
location_id: z.number().int().positive().optional().describe('Reference location as center (with radius)'),
|
|
39
|
+
radius: z.number().positive().optional().describe('Search radius (required with lat/lon or location_id; must be > 0)'),
|
|
40
|
+
unit: z.enum(['MI', 'KM']).optional().describe('Radius unit (default MI)'),
|
|
41
|
+
sw_lat: z.number().min(-90).max(90).optional().describe('Bounding box SW latitude'),
|
|
42
|
+
sw_lon: z.number().min(-180).max(180).optional().describe('Bounding box SW longitude'),
|
|
43
|
+
ne_lat: z.number().min(-90).max(90).optional().describe('Bounding box NE latitude'),
|
|
44
|
+
ne_lon: z.number().min(-180).max(180).optional().describe('Bounding box NE longitude'),
|
|
45
|
+
category: Category.optional().describe('Restrict to one category'),
|
|
46
|
+
min_rating: z.number().min(1).max(5).optional().describe('Minimum traveler rating (1.0–5.0)'),
|
|
47
|
+
include_photo: z.boolean().optional().describe('Include a photo per result'),
|
|
48
|
+
sort: z.enum(['distance', 'rating']).optional().describe('Sort order (default distance)'),
|
|
49
|
+
locale: LocaleList,
|
|
50
|
+
...pageParams,
|
|
51
|
+
...compactParam,
|
|
24
52
|
},
|
|
25
|
-
}, async (
|
|
26
|
-
const
|
|
27
|
-
|
|
53
|
+
}, async (args) => {
|
|
54
|
+
const { lat, lon, location_id, radius, sw_lat, sw_lon, ne_lat, ne_lon, compact, ...rest } = args;
|
|
55
|
+
const boxParts = [sw_lat, sw_lon, ne_lat, ne_lon];
|
|
56
|
+
const boxGiven = boxParts.filter((v) => v !== undefined).length;
|
|
57
|
+
// A partial box (1–3 of 4) is never valid: it can't form a center on its
|
|
58
|
+
// own, and if it rode alongside a lat/lon or location_id center it would
|
|
59
|
+
// bleed into the Terra URL and 400. Reject it before the center count.
|
|
60
|
+
if (boxGiven > 0 && boxGiven < 4) {
|
|
61
|
+
throw new McpToolError('ta_search_nearby bounding box needs all four of sw_lat, sw_lon, ne_lat, ne_lon.', {
|
|
62
|
+
hint: 'Provide all four box corners, or use lat+lon+radius or location_id+radius instead.',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// Same defect class: a lone lat or lon can't form a center and would bleed
|
|
66
|
+
// into the URL alongside another center — require them together or not at all.
|
|
67
|
+
if ((lat === undefined) !== (lon === undefined)) {
|
|
68
|
+
throw new McpToolError('ta_search_nearby needs both lat and lon together (or neither).', {
|
|
69
|
+
hint: 'Provide lat AND lon with a radius, or use location_id+radius or a sw/ne box.',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const hasLatLon = lat !== undefined && lon !== undefined;
|
|
73
|
+
const hasBox = boxGiven === 4;
|
|
74
|
+
const centers = [hasLatLon, location_id !== undefined, hasBox].filter(Boolean).length;
|
|
75
|
+
if (centers !== 1) {
|
|
76
|
+
throw new McpToolError('ta_search_nearby needs exactly one center: lat+lon (+radius), location_id (+radius), or the sw/ne bounding box.', { hint: 'Provide lat AND lon, OR location_id, OR all four of sw_lat/sw_lon/ne_lat/ne_lon — not more than one.' });
|
|
77
|
+
}
|
|
78
|
+
if ((hasLatLon || location_id !== undefined) && radius === undefined) {
|
|
79
|
+
throw new McpToolError('lat/lon and location_id center modes require radius.', {
|
|
80
|
+
hint: 'Add radius (with optional unit MI/KM), or switch to a sw/ne bounding box.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const data = await client.get(`/locations/nearby${qs({ lat, lon, location_id, radius, sw_lat, sw_lon, ne_lat, ne_lon, ...rest })}`, { cache: 'dynamic' });
|
|
84
|
+
return textResult(compact ? compactList(data) : data);
|
|
28
85
|
});
|
|
29
86
|
}
|
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.2.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.2.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.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/tripadvisor-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
18
18
|
"environmentVariables": [
|
|
19
19
|
{
|
|
20
20
|
"name": "TRIPADVISOR_API_KEY",
|
|
21
|
-
"description": "Your TripAdvisor
|
|
21
|
+
"description": "Your TripAdvisor Terra API key (tripadvisor.com/developers)",
|
|
22
22
|
"isRequired": true,
|
|
23
23
|
"format": "string",
|
|
24
24
|
"isSecret": true
|