@chrischall/tripadvisor-mcp 0.2.0 → 0.3.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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/dist/bundle.js +260 -55
- package/dist/client.js +9 -48
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/server.json +2 -2
- package/skills/tripadvisor-api/SKILL.md +127 -0
- package/skills/tripadvisor-api/references/terra-endpoints.md +118 -0
- package/skills/tripadvisor-api/references/web-fallback.md +91 -0
- /package/{SKILL.md → skills/tripadvisor/SKILL.md} +0 -0
|
@@ -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.
|
|
10
|
+
"version": "0.3.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.
|
|
18
|
+
"version": "0.3.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.
|
|
4
|
+
"version": "0.3.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",
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
"reviews",
|
|
20
20
|
"mcp"
|
|
21
21
|
],
|
|
22
|
-
"skills": "./
|
|
22
|
+
"skills": "./skills/",
|
|
23
23
|
"mcp": "./.mcp.json"
|
|
24
24
|
}
|
package/dist/bundle.js
CHANGED
|
@@ -34718,8 +34718,12 @@ var API_KEY_RE = new RegExp([
|
|
|
34718
34718
|
// webhook signing secret (Stripe-style)
|
|
34719
34719
|
].map((p) => `\\b${p}`).join("|"), "g");
|
|
34720
34720
|
var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
|
|
34721
|
+
var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
|
|
34722
|
+
var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
|
|
34723
|
+
var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
|
|
34724
|
+
var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
|
|
34721
34725
|
function redactSecrets(text) {
|
|
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]");
|
|
34726
|
+
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(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
|
|
34723
34727
|
}
|
|
34724
34728
|
function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
|
|
34725
34729
|
const str2 = text === null || text === void 0 ? "" : String(text);
|
|
@@ -34795,6 +34799,100 @@ async function loadDotenvSafely(opts = {}) {
|
|
|
34795
34799
|
return false;
|
|
34796
34800
|
}
|
|
34797
34801
|
}
|
|
34802
|
+
function readIntEnv(key, opts = {}) {
|
|
34803
|
+
const raw = readEnvVar(key, opts.env ? { env: opts.env } : {});
|
|
34804
|
+
if (raw === void 0)
|
|
34805
|
+
return opts.default;
|
|
34806
|
+
if (!/^-?\d+$/.test(raw))
|
|
34807
|
+
return opts.default;
|
|
34808
|
+
const n = Number(raw);
|
|
34809
|
+
if (!Number.isSafeInteger(n))
|
|
34810
|
+
return opts.default;
|
|
34811
|
+
const min = opts.min ?? 0;
|
|
34812
|
+
if (n < min)
|
|
34813
|
+
return opts.default;
|
|
34814
|
+
if (opts.max !== void 0 && n > opts.max)
|
|
34815
|
+
return opts.default;
|
|
34816
|
+
return n;
|
|
34817
|
+
}
|
|
34818
|
+
function readTtlMsEnv(key, defaultMs, opts = {}) {
|
|
34819
|
+
const seconds = readIntEnv(key, { ...opts.env ? { env: opts.env } : {}, min: 0 });
|
|
34820
|
+
return seconds === void 0 ? defaultMs : seconds * 1e3;
|
|
34821
|
+
}
|
|
34822
|
+
|
|
34823
|
+
// node_modules/@chrischall/mcp-utils/dist/http/response-cache.js
|
|
34824
|
+
var RESPONSE_CACHE_MAX_ENTRIES = 256;
|
|
34825
|
+
function createResponseCache(opts) {
|
|
34826
|
+
const now = opts.now ?? Date.now;
|
|
34827
|
+
const maxEntries = opts.maxEntries ?? RESPONSE_CACHE_MAX_ENTRIES;
|
|
34828
|
+
const store = /* @__PURE__ */ new Map();
|
|
34829
|
+
const ttlFor = (tier) => opts.ttlMs[tier] ?? 0;
|
|
34830
|
+
function evictForInsert() {
|
|
34831
|
+
if (store.size < maxEntries)
|
|
34832
|
+
return;
|
|
34833
|
+
const t = now();
|
|
34834
|
+
for (const [key, entry] of store) {
|
|
34835
|
+
if (entry.expiresAt <= t)
|
|
34836
|
+
store.delete(key);
|
|
34837
|
+
}
|
|
34838
|
+
while (store.size >= maxEntries) {
|
|
34839
|
+
const oldest = store.keys().next();
|
|
34840
|
+
if (oldest.done)
|
|
34841
|
+
break;
|
|
34842
|
+
store.delete(oldest.value);
|
|
34843
|
+
}
|
|
34844
|
+
}
|
|
34845
|
+
function get(key) {
|
|
34846
|
+
const entry = store.get(key);
|
|
34847
|
+
if (!entry)
|
|
34848
|
+
return void 0;
|
|
34849
|
+
if (entry.expiresAt <= now()) {
|
|
34850
|
+
store.delete(key);
|
|
34851
|
+
return void 0;
|
|
34852
|
+
}
|
|
34853
|
+
return entry.value;
|
|
34854
|
+
}
|
|
34855
|
+
function set2(key, value, tier = "dynamic") {
|
|
34856
|
+
const ttl = ttlFor(tier);
|
|
34857
|
+
if (ttl <= 0)
|
|
34858
|
+
return;
|
|
34859
|
+
if (!store.has(key))
|
|
34860
|
+
evictForInsert();
|
|
34861
|
+
else
|
|
34862
|
+
store.delete(key);
|
|
34863
|
+
store.set(key, { expiresAt: now() + ttl, value });
|
|
34864
|
+
}
|
|
34865
|
+
return {
|
|
34866
|
+
get,
|
|
34867
|
+
set: set2,
|
|
34868
|
+
async fetchThrough(key, load, tier = "dynamic") {
|
|
34869
|
+
const hit = get(key);
|
|
34870
|
+
if (hit !== void 0)
|
|
34871
|
+
return hit;
|
|
34872
|
+
const value = await load();
|
|
34873
|
+
set2(key, value, tier);
|
|
34874
|
+
return value;
|
|
34875
|
+
},
|
|
34876
|
+
clear() {
|
|
34877
|
+
store.clear();
|
|
34878
|
+
},
|
|
34879
|
+
get size() {
|
|
34880
|
+
return store.size;
|
|
34881
|
+
}
|
|
34882
|
+
};
|
|
34883
|
+
}
|
|
34884
|
+
|
|
34885
|
+
// node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
|
|
34886
|
+
function parseRetryAfterMs(header, opts = {}) {
|
|
34887
|
+
const defaultMs = opts.defaultMs ?? 2e3;
|
|
34888
|
+
const capMs = opts.capMs ?? 3e4;
|
|
34889
|
+
if (header == null)
|
|
34890
|
+
return defaultMs;
|
|
34891
|
+
const trimmed = header.trim();
|
|
34892
|
+
if (!/^\d+$/.test(trimmed))
|
|
34893
|
+
return defaultMs;
|
|
34894
|
+
return Math.min(Number(trimmed) * 1e3, capMs);
|
|
34895
|
+
}
|
|
34798
34896
|
|
|
34799
34897
|
// node_modules/@chrischall/mcp-utils/dist/http/index.js
|
|
34800
34898
|
function buildQueryString(params) {
|
|
@@ -34841,7 +34939,7 @@ var pageSchema = {
|
|
|
34841
34939
|
};
|
|
34842
34940
|
|
|
34843
34941
|
// src/version.ts
|
|
34844
|
-
var VERSION = "0.
|
|
34942
|
+
var VERSION = "0.3.0";
|
|
34845
34943
|
|
|
34846
34944
|
// src/client.ts
|
|
34847
34945
|
import { dirname, join } from "node:path";
|
|
@@ -34853,33 +34951,24 @@ var SERVICE = "TripAdvisor Terra API";
|
|
|
34853
34951
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
34854
34952
|
var DEFAULT_CACHE_TTL_MS = 3e5;
|
|
34855
34953
|
var DEFAULT_STATIC_CACHE_TTL_MS = 36e5;
|
|
34856
|
-
var CACHE_MAX_ENTRIES = 256;
|
|
34857
34954
|
var MAX_RETRY_AFTER_MS = 1e4;
|
|
34858
|
-
function readCacheTtlMs(envVar, defaultMs) {
|
|
34859
|
-
const raw = readEnvVar(envVar);
|
|
34860
|
-
if (raw === void 0) return defaultMs;
|
|
34861
|
-
const secs = Number(raw);
|
|
34862
|
-
return Number.isFinite(secs) && secs >= 0 ? secs * 1e3 : defaultMs;
|
|
34863
|
-
}
|
|
34864
34955
|
var TripAdvisorClient = class {
|
|
34865
34956
|
apiKey;
|
|
34866
34957
|
configError;
|
|
34867
34958
|
fetchImpl;
|
|
34868
34959
|
sleep;
|
|
34869
|
-
|
|
34870
|
-
staticCacheTtlMs;
|
|
34871
|
-
now;
|
|
34872
|
-
cache = /* @__PURE__ */ new Map();
|
|
34960
|
+
cache;
|
|
34873
34961
|
/**
|
|
34874
34962
|
* Defer the config error so the server still boots (and answers the host's
|
|
34875
34963
|
* install-time tools/list probe) when TRIPADVISOR_API_KEY isn't set yet. The
|
|
34876
34964
|
* error is re-raised at request time via requireKey().
|
|
34877
34965
|
*/
|
|
34878
34966
|
constructor(opts = {}) {
|
|
34879
|
-
|
|
34967
|
+
const now = opts.now ?? Date.now;
|
|
34880
34968
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
34881
|
-
|
|
34882
|
-
|
|
34969
|
+
const cacheTtlMs = opts.cacheTtlMs ?? readTtlMsEnv("TRIPADVISOR_CACHE_TTL", DEFAULT_CACHE_TTL_MS);
|
|
34970
|
+
const staticCacheTtlMs = opts.staticCacheTtlMs ?? readTtlMsEnv("TRIPADVISOR_STATIC_CACHE_TTL", DEFAULT_STATIC_CACHE_TTL_MS);
|
|
34971
|
+
this.cache = createResponseCache({ ttlMs: { dynamic: cacheTtlMs, static: staticCacheTtlMs }, now });
|
|
34883
34972
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
34884
34973
|
const key = readEnvVar("TRIPADVISOR_API_KEY");
|
|
34885
34974
|
if (!key) {
|
|
@@ -34905,25 +34994,8 @@ var TripAdvisorClient = class {
|
|
|
34905
34994
|
* (TRIPADVISOR_CACHE_TTL) is for searches.
|
|
34906
34995
|
*/
|
|
34907
34996
|
async get(path, opts = {}) {
|
|
34908
|
-
const
|
|
34909
|
-
|
|
34910
|
-
const hit = this.cache.get(path);
|
|
34911
|
-
if (hit && hit.expiresAt > this.now()) return hit.value;
|
|
34912
|
-
}
|
|
34913
|
-
const value = await this.request(path);
|
|
34914
|
-
if (ttl > 0) {
|
|
34915
|
-
if (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
34916
|
-
const t = this.now();
|
|
34917
|
-
for (const [k, v] of this.cache) if (v.expiresAt <= t) this.cache.delete(k);
|
|
34918
|
-
while (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
34919
|
-
const oldest = this.cache.keys().next().value;
|
|
34920
|
-
if (oldest === void 0) break;
|
|
34921
|
-
this.cache.delete(oldest);
|
|
34922
|
-
}
|
|
34923
|
-
}
|
|
34924
|
-
this.cache.set(path, { expiresAt: this.now() + ttl, value });
|
|
34925
|
-
}
|
|
34926
|
-
return value;
|
|
34997
|
+
const tier = opts.cache === "static" ? "static" : "dynamic";
|
|
34998
|
+
return this.cache.fetchThrough(path, () => this.request(path), tier);
|
|
34927
34999
|
}
|
|
34928
35000
|
async request(path, isRetry = false) {
|
|
34929
35001
|
const key = this.requireKey();
|
|
@@ -34932,8 +35004,7 @@ var TripAdvisorClient = class {
|
|
|
34932
35004
|
if (res.ok) return await res.json();
|
|
34933
35005
|
const text = await res.text();
|
|
34934
35006
|
if (res.status === 429 && !isRetry) {
|
|
34935
|
-
const
|
|
34936
|
-
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1e3, MAX_RETRY_AFTER_MS) : 1e3;
|
|
35007
|
+
const delayMs = parseRetryAfterMs(res.headers.get("retry-after"), { defaultMs: 1e3, capMs: MAX_RETRY_AFTER_MS });
|
|
34937
35008
|
await this.sleep(delayMs);
|
|
34938
35009
|
return this.request(path, true);
|
|
34939
35010
|
}
|
|
@@ -35202,6 +35273,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
35202
35273
|
"capture_request_header",
|
|
35203
35274
|
"capture_redirect",
|
|
35204
35275
|
"read_indexed_db",
|
|
35276
|
+
"read_dom",
|
|
35205
35277
|
"download"
|
|
35206
35278
|
]);
|
|
35207
35279
|
|
|
@@ -35503,6 +35575,44 @@ function assertIndexedDbScopesArray(value, label) {
|
|
|
35503
35575
|
}
|
|
35504
35576
|
}
|
|
35505
35577
|
}
|
|
35578
|
+
var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
|
|
35579
|
+
var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
|
|
35580
|
+
function assertDomSelectorsArray(value, label) {
|
|
35581
|
+
if (!Array.isArray(value)) {
|
|
35582
|
+
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
35583
|
+
}
|
|
35584
|
+
const seen = /* @__PURE__ */ new Set();
|
|
35585
|
+
for (let i = 0; i < value.length; i++) {
|
|
35586
|
+
const entry = value[i];
|
|
35587
|
+
assertObject(entry, `${label}[${i}]`);
|
|
35588
|
+
if (entry.name === void 0) {
|
|
35589
|
+
throw new ProtocolError(`${label}[${i}].name: missing`);
|
|
35590
|
+
}
|
|
35591
|
+
if (entry.selector === void 0) {
|
|
35592
|
+
throw new ProtocolError(`${label}[${i}].selector: missing`);
|
|
35593
|
+
}
|
|
35594
|
+
if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
|
|
35595
|
+
throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
|
|
35596
|
+
}
|
|
35597
|
+
if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
|
|
35598
|
+
throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
|
|
35599
|
+
}
|
|
35600
|
+
if (entry.attribute !== void 0) {
|
|
35601
|
+
if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
|
|
35602
|
+
throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
|
|
35603
|
+
}
|
|
35604
|
+
}
|
|
35605
|
+
if (seen.has(entry.name)) {
|
|
35606
|
+
throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
|
|
35607
|
+
}
|
|
35608
|
+
seen.add(entry.name);
|
|
35609
|
+
for (const k of Object.keys(entry)) {
|
|
35610
|
+
if (k !== "name" && k !== "selector" && k !== "attribute") {
|
|
35611
|
+
throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
|
|
35612
|
+
}
|
|
35613
|
+
}
|
|
35614
|
+
}
|
|
35615
|
+
}
|
|
35506
35616
|
function validateFrame(raw) {
|
|
35507
35617
|
assertObject(raw, "frame");
|
|
35508
35618
|
const t = raw.type;
|
|
@@ -35578,6 +35688,9 @@ function validateHello(raw) {
|
|
|
35578
35688
|
if (raw.sessionStoragePointers !== void 0) {
|
|
35579
35689
|
assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
|
|
35580
35690
|
}
|
|
35691
|
+
if (raw.domSelectors !== void 0) {
|
|
35692
|
+
assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
|
|
35693
|
+
}
|
|
35581
35694
|
assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
|
|
35582
35695
|
assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
|
|
35583
35696
|
assertBase64(raw.sessionNonce, "hello.sessionNonce");
|
|
@@ -35812,6 +35925,21 @@ function validateInnerRequest(raw) {
|
|
|
35812
35925
|
}
|
|
35813
35926
|
return raw;
|
|
35814
35927
|
}
|
|
35928
|
+
if (raw.op === "read_dom") {
|
|
35929
|
+
assertObject(raw.init, "inner.init");
|
|
35930
|
+
if (raw.init.origin === void 0)
|
|
35931
|
+
throw new ProtocolError("inner.init.origin: missing");
|
|
35932
|
+
if (raw.init.names === void 0)
|
|
35933
|
+
throw new ProtocolError("inner.init.names: missing");
|
|
35934
|
+
assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
|
|
35935
|
+
assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
|
|
35936
|
+
for (const k of Object.keys(raw.init)) {
|
|
35937
|
+
if (k !== "origin" && k !== "names") {
|
|
35938
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
|
|
35939
|
+
}
|
|
35940
|
+
}
|
|
35941
|
+
return raw;
|
|
35942
|
+
}
|
|
35815
35943
|
if (raw.op === "download") {
|
|
35816
35944
|
assertObject(raw.init, "inner.init");
|
|
35817
35945
|
if (raw.init.url === void 0) {
|
|
@@ -35837,7 +35965,7 @@ function validateInnerRequest(raw) {
|
|
|
35837
35965
|
}
|
|
35838
35966
|
return raw;
|
|
35839
35967
|
}
|
|
35840
|
-
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
|
|
35968
|
+
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
|
|
35841
35969
|
}
|
|
35842
35970
|
function assertNonEmptyKeyArray(value, label) {
|
|
35843
35971
|
if (!Array.isArray(value)) {
|
|
@@ -35922,6 +36050,13 @@ function validateInnerResponse(raw) {
|
|
|
35922
36050
|
assertObject(raw.values, "inner.values");
|
|
35923
36051
|
return raw;
|
|
35924
36052
|
}
|
|
36053
|
+
if (op === "read_dom") {
|
|
36054
|
+
if (raw.values === void 0) {
|
|
36055
|
+
throw new ProtocolError("inner.values: missing on read_dom response");
|
|
36056
|
+
}
|
|
36057
|
+
assertStringMap(raw.values, "inner.values");
|
|
36058
|
+
return raw;
|
|
36059
|
+
}
|
|
35925
36060
|
if (op === "download") {
|
|
35926
36061
|
assertObject(raw.value, "inner.value");
|
|
35927
36062
|
assertString(raw.value.path, "inner.value.path");
|
|
@@ -36261,6 +36396,13 @@ async function buildServerHello(opts) {
|
|
|
36261
36396
|
jsonPointer: d.jsonPointer
|
|
36262
36397
|
}));
|
|
36263
36398
|
}
|
|
36399
|
+
if (opts.domSelectors && opts.domSelectors.length > 0) {
|
|
36400
|
+
hello.domSelectors = opts.domSelectors.map((d) => ({
|
|
36401
|
+
name: d.name,
|
|
36402
|
+
selector: d.selector,
|
|
36403
|
+
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
36404
|
+
}));
|
|
36405
|
+
}
|
|
36264
36406
|
return hello;
|
|
36265
36407
|
}
|
|
36266
36408
|
|
|
@@ -36350,7 +36492,8 @@ async function startHost(opts) {
|
|
|
36350
36492
|
captureHeaders: opts.ownCaptureHeaders,
|
|
36351
36493
|
indexedDbScopes: opts.ownIndexedDbScopes,
|
|
36352
36494
|
localStoragePointers: opts.ownLocalStoragePointers,
|
|
36353
|
-
sessionStoragePointers: opts.ownSessionStoragePointers
|
|
36495
|
+
sessionStoragePointers: opts.ownSessionStoragePointers,
|
|
36496
|
+
domSelectors: opts.ownDomSelectors
|
|
36354
36497
|
});
|
|
36355
36498
|
const ownSessionNonce = fromB64(ownHello.sessionNonce);
|
|
36356
36499
|
let extensionWs = null;
|
|
@@ -36585,6 +36728,7 @@ async function startPeer(opts) {
|
|
|
36585
36728
|
sessionStorageKeys: opts.sessionStorageKeys,
|
|
36586
36729
|
captureHeaders: opts.captureHeaders,
|
|
36587
36730
|
indexedDbScopes: opts.indexedDbScopes,
|
|
36731
|
+
domSelectors: opts.domSelectors,
|
|
36588
36732
|
localStoragePointers: opts.localStoragePointers,
|
|
36589
36733
|
sessionStoragePointers: opts.sessionStoragePointers
|
|
36590
36734
|
});
|
|
@@ -36992,6 +37136,11 @@ var FetchproxyServer = class {
|
|
|
36992
37136
|
key: d.key,
|
|
36993
37137
|
jsonPointer: d.jsonPointer
|
|
36994
37138
|
})),
|
|
37139
|
+
domSelectors: (opts.domSelectors ?? []).map((d) => ({
|
|
37140
|
+
name: d.name,
|
|
37141
|
+
selector: d.selector,
|
|
37142
|
+
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
37143
|
+
})),
|
|
36995
37144
|
// 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
|
|
36996
37145
|
// adapter was about to set these to the same numbers anyway; the
|
|
36997
37146
|
// back-door is `0` (explicit opt-out) if a caller genuinely wants
|
|
@@ -37112,6 +37261,7 @@ var FetchproxyServer = class {
|
|
|
37112
37261
|
ownIndexedDbScopes: this.opts.indexedDbScopes,
|
|
37113
37262
|
ownLocalStoragePointers: this.opts.localStoragePointers,
|
|
37114
37263
|
ownSessionStoragePointers: this.opts.sessionStoragePointers,
|
|
37264
|
+
ownDomSelectors: this.opts.domSelectors,
|
|
37115
37265
|
onPairCode: this.opts.onPairCode
|
|
37116
37266
|
});
|
|
37117
37267
|
this.hostHandle.onOwnInner((inner) => this.onInner(inner));
|
|
@@ -37139,7 +37289,8 @@ var FetchproxyServer = class {
|
|
|
37139
37289
|
captureHeaders: this.opts.captureHeaders,
|
|
37140
37290
|
indexedDbScopes: this.opts.indexedDbScopes,
|
|
37141
37291
|
localStoragePointers: this.opts.localStoragePointers,
|
|
37142
|
-
sessionStoragePointers: this.opts.sessionStoragePointers
|
|
37292
|
+
sessionStoragePointers: this.opts.sessionStoragePointers,
|
|
37293
|
+
domSelectors: this.opts.domSelectors
|
|
37143
37294
|
});
|
|
37144
37295
|
this.peerHandle.onInner((inner) => this.onInner(inner));
|
|
37145
37296
|
this.peerHandle.onRenegotiate(() => {
|
|
@@ -38114,6 +38265,46 @@ var FetchproxyServer = class {
|
|
|
38114
38265
|
await this.sendInnerFrame(inner);
|
|
38115
38266
|
return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
|
|
38116
38267
|
}
|
|
38268
|
+
/**
|
|
38269
|
+
* 1.4.0+: read declared DOM values from the user's signed-in tab.
|
|
38270
|
+
* Requires `'read_dom'` in capabilities AND every requested `name` to
|
|
38271
|
+
* match a declared `domSelectors` entry. The extension reads each
|
|
38272
|
+
* declared selector from the matched tab's DOM (isolated-world
|
|
38273
|
+
* `querySelector`, value or attribute) — no page-JS execution.
|
|
38274
|
+
*
|
|
38275
|
+
* Returns a `Record<string, string>` of `name → value`, with names
|
|
38276
|
+
* whose element (or attribute) was absent omitted. Throws
|
|
38277
|
+
* `FetchproxyProtocolError` on bridge failures and a plain `Error` on
|
|
38278
|
+
* developer mistakes (undeclared capability, undeclared name).
|
|
38279
|
+
*/
|
|
38280
|
+
async readDom(opts) {
|
|
38281
|
+
if (!this.opts.capabilities.includes("read_dom")) {
|
|
38282
|
+
throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
|
|
38283
|
+
}
|
|
38284
|
+
await this.ensureConnected();
|
|
38285
|
+
this.throwIfPendingPair();
|
|
38286
|
+
if (!Array.isArray(opts.names) || opts.names.length === 0) {
|
|
38287
|
+
throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
|
|
38288
|
+
}
|
|
38289
|
+
this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
|
|
38290
|
+
if (opts.subdomain !== void 0)
|
|
38291
|
+
assertSubdomainLabel(opts.subdomain);
|
|
38292
|
+
const baseDomain = this.resolveBaseDomain(opts.domain);
|
|
38293
|
+
const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
|
|
38294
|
+
const origin = `https://${host}`;
|
|
38295
|
+
const id = this.nextRequestId++;
|
|
38296
|
+
const inner = {
|
|
38297
|
+
type: "request",
|
|
38298
|
+
id,
|
|
38299
|
+
op: "read_dom",
|
|
38300
|
+
init: { origin, names: [...opts.names] }
|
|
38301
|
+
};
|
|
38302
|
+
const pending = new Promise((resolve, reject) => {
|
|
38303
|
+
this.pendingStorage.set(id, { resolve, reject });
|
|
38304
|
+
});
|
|
38305
|
+
await this.sendInnerFrame(inner);
|
|
38306
|
+
return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
|
|
38307
|
+
}
|
|
38117
38308
|
assertScopeSubset(requested, declared, label) {
|
|
38118
38309
|
const undeclared = undeclaredKeys(requested, declared);
|
|
38119
38310
|
if (undeclared.length > 0) {
|
|
@@ -38185,7 +38376,7 @@ var FetchproxyServer = class {
|
|
|
38185
38376
|
if (storageCb) {
|
|
38186
38377
|
this.pendingStorage.delete(inner.id);
|
|
38187
38378
|
if (inner.ok) {
|
|
38188
|
-
if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
|
|
38379
|
+
if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
|
|
38189
38380
|
storageCb.resolve({ ...inner.values });
|
|
38190
38381
|
} else {
|
|
38191
38382
|
storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
|
|
@@ -38511,7 +38702,7 @@ function healthcheckHint(args) {
|
|
|
38511
38702
|
return `Unexpected error \u2014 see the error.message field for details.`;
|
|
38512
38703
|
}
|
|
38513
38704
|
function registerBridgeHealthcheckTool(args) {
|
|
38514
|
-
const { server, prefix, probePath, hostLabel, transport, probeFn } = args;
|
|
38705
|
+
const { server, prefix, probePath, hostLabel, transport, probeFn, classifyThrown, hints } = args;
|
|
38515
38706
|
const probeUrl = `https://${hostLabel}${probePath}`;
|
|
38516
38707
|
server.registerTool(`${prefix}_healthcheck`, {
|
|
38517
38708
|
title: "Verify the fetchproxy bridge end-to-end",
|
|
@@ -38544,16 +38735,39 @@ function registerBridgeHealthcheckTool(args) {
|
|
|
38544
38735
|
} : { url: probeUrl, elapsed_ms: probeResult.elapsed_ms };
|
|
38545
38736
|
let error51;
|
|
38546
38737
|
let bridgeHint;
|
|
38738
|
+
let customHint;
|
|
38739
|
+
let customDetail;
|
|
38547
38740
|
if (probeResult.error) {
|
|
38548
|
-
|
|
38741
|
+
let kind = probeResult.error.kind === "other" ? "unknown" : probeResult.error.kind;
|
|
38549
38742
|
bridgeHint = thrown instanceof FetchproxyBridgeDownError ? thrown.hint : void 0;
|
|
38743
|
+
if (thrown !== void 0 && classifyThrown) {
|
|
38744
|
+
const custom2 = classifyThrown(thrown);
|
|
38745
|
+
if (custom2) {
|
|
38746
|
+
kind = custom2.kind;
|
|
38747
|
+
customHint = custom2.hint;
|
|
38748
|
+
customDetail = custom2.detail;
|
|
38749
|
+
}
|
|
38750
|
+
}
|
|
38550
38751
|
error51 = {
|
|
38551
38752
|
kind,
|
|
38552
38753
|
message: probeResult.error.message,
|
|
38553
|
-
...bridgeHint !== void 0 ? { bridge_hint: bridgeHint } : {}
|
|
38754
|
+
...bridgeHint !== void 0 ? { bridge_hint: bridgeHint } : {},
|
|
38755
|
+
...customDetail !== void 0 ? { detail: customDetail } : {}
|
|
38554
38756
|
};
|
|
38555
38757
|
}
|
|
38556
38758
|
const lastExtensionMessageAt = transport.status().lastExtensionMessageAt;
|
|
38759
|
+
const arm = ok ? "ok" : error51?.kind === "bridge_down" ? "bridge_down" : probeResult.bridge.role === null ? "no_role" : error51?.kind === "timeout" ? "timeout" : error51?.kind === "protocol" || error51?.kind === "http" ? "protocol" : "unknown";
|
|
38760
|
+
const defaultHint = healthcheckHint({
|
|
38761
|
+
ok,
|
|
38762
|
+
role: probeResult.bridge.role,
|
|
38763
|
+
// FIX: the real configured port from bridgeHealth(), not a literal 37149.
|
|
38764
|
+
port: probeResult.bridge.port,
|
|
38765
|
+
hostLabel,
|
|
38766
|
+
prefix,
|
|
38767
|
+
probePath,
|
|
38768
|
+
errorKind: error51?.kind,
|
|
38769
|
+
bridgeHint: error51?.kind === "bridge_down" ? bridgeHint : void 0
|
|
38770
|
+
});
|
|
38557
38771
|
const result = {
|
|
38558
38772
|
ok,
|
|
38559
38773
|
bridge: {
|
|
@@ -38562,17 +38776,8 @@ function registerBridgeHealthcheckTool(args) {
|
|
|
38562
38776
|
},
|
|
38563
38777
|
probe,
|
|
38564
38778
|
...error51 ? { error: error51 } : {},
|
|
38565
|
-
|
|
38566
|
-
|
|
38567
|
-
role: probeResult.bridge.role,
|
|
38568
|
-
// FIX: the real configured port from bridgeHealth(), not a literal 37149.
|
|
38569
|
-
port: probeResult.bridge.port,
|
|
38570
|
-
hostLabel,
|
|
38571
|
-
prefix,
|
|
38572
|
-
probePath,
|
|
38573
|
-
errorKind: error51?.kind,
|
|
38574
|
-
bridgeHint: error51?.kind === "bridge_down" ? bridgeHint : void 0
|
|
38575
|
-
})
|
|
38779
|
+
// Precedence: classifyThrown's hint > per-arm override > default ladder.
|
|
38780
|
+
hint: customHint ?? hints?.[arm] ?? defaultHint
|
|
38576
38781
|
};
|
|
38577
38782
|
return {
|
|
38578
38783
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { loadDotenvSafely, readEnvVar, formatApiError, truncateErrorMessage, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
+
import { loadDotenvSafely, readEnvVar, readTtlMsEnv, createResponseCache, parseRetryAfterMs, 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));
|
|
@@ -15,40 +15,25 @@ const DEFAULT_CACHE_TTL_MS = 300_000;
|
|
|
15
15
|
// Location details/photos/reviews change slowly — 1 hour by default.
|
|
16
16
|
// Override with TRIPADVISOR_STATIC_CACHE_TTL (seconds; 0 = off).
|
|
17
17
|
const DEFAULT_STATIC_CACHE_TTL_MS = 3_600_000;
|
|
18
|
-
// Bound the cache so a long-lived server doesn't grow unbounded across many
|
|
19
|
-
// distinct paths; oldest entries are evicted first.
|
|
20
|
-
const CACHE_MAX_ENTRIES = 256;
|
|
21
18
|
// Cap a server-supplied Retry-After so one bad header can't stall a tool call.
|
|
22
19
|
const MAX_RETRY_AFTER_MS = 10_000;
|
|
23
|
-
/** Resolve a cache TTL (ms) from an env var holding seconds. A blank or
|
|
24
|
-
* non-numeric value falls back to `defaultMs`; a valid `0` disables caching. */
|
|
25
|
-
function readCacheTtlMs(envVar, defaultMs) {
|
|
26
|
-
const raw = readEnvVar(envVar);
|
|
27
|
-
if (raw === undefined)
|
|
28
|
-
return defaultMs;
|
|
29
|
-
const secs = Number(raw);
|
|
30
|
-
return Number.isFinite(secs) && secs >= 0 ? secs * 1000 : defaultMs;
|
|
31
|
-
}
|
|
32
20
|
export class TripAdvisorClient {
|
|
33
21
|
apiKey;
|
|
34
22
|
configError;
|
|
35
23
|
fetchImpl;
|
|
36
24
|
sleep;
|
|
37
|
-
|
|
38
|
-
staticCacheTtlMs;
|
|
39
|
-
now;
|
|
40
|
-
cache = new Map();
|
|
25
|
+
cache;
|
|
41
26
|
/**
|
|
42
27
|
* Defer the config error so the server still boots (and answers the host's
|
|
43
28
|
* install-time tools/list probe) when TRIPADVISOR_API_KEY isn't set yet. The
|
|
44
29
|
* error is re-raised at request time via requireKey().
|
|
45
30
|
*/
|
|
46
31
|
constructor(opts = {}) {
|
|
47
|
-
|
|
32
|
+
const now = opts.now ?? Date.now;
|
|
48
33
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
const cacheTtlMs = opts.cacheTtlMs ?? readTtlMsEnv('TRIPADVISOR_CACHE_TTL', DEFAULT_CACHE_TTL_MS);
|
|
35
|
+
const staticCacheTtlMs = opts.staticCacheTtlMs ?? readTtlMsEnv('TRIPADVISOR_STATIC_CACHE_TTL', DEFAULT_STATIC_CACHE_TTL_MS);
|
|
36
|
+
this.cache = createResponseCache({ ttlMs: { dynamic: cacheTtlMs, static: staticCacheTtlMs }, now });
|
|
52
37
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
53
38
|
const key = readEnvVar('TRIPADVISOR_API_KEY');
|
|
54
39
|
if (!key) {
|
|
@@ -76,31 +61,8 @@ export class TripAdvisorClient {
|
|
|
76
61
|
* (TRIPADVISOR_CACHE_TTL) is for searches.
|
|
77
62
|
*/
|
|
78
63
|
async get(path, opts = {}) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const hit = this.cache.get(path);
|
|
82
|
-
if (hit && hit.expiresAt > this.now())
|
|
83
|
-
return hit.value;
|
|
84
|
-
}
|
|
85
|
-
const value = await this.request(path);
|
|
86
|
-
if (ttl > 0) {
|
|
87
|
-
if (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
88
|
-
// Evict expired entries first; if still full, drop the oldest (Map
|
|
89
|
-
// preserves insertion order, so the first key is the oldest).
|
|
90
|
-
const t = this.now();
|
|
91
|
-
for (const [k, v] of this.cache)
|
|
92
|
-
if (v.expiresAt <= t)
|
|
93
|
-
this.cache.delete(k);
|
|
94
|
-
while (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
95
|
-
const oldest = this.cache.keys().next().value;
|
|
96
|
-
if (oldest === undefined)
|
|
97
|
-
break;
|
|
98
|
-
this.cache.delete(oldest);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
this.cache.set(path, { expiresAt: this.now() + ttl, value });
|
|
102
|
-
}
|
|
103
|
-
return value;
|
|
64
|
+
const tier = opts.cache === 'static' ? 'static' : 'dynamic';
|
|
65
|
+
return this.cache.fetchThrough(path, () => this.request(path), tier);
|
|
104
66
|
}
|
|
105
67
|
async request(path, isRetry = false) {
|
|
106
68
|
const key = this.requireKey();
|
|
@@ -112,8 +74,7 @@ export class TripAdvisorClient {
|
|
|
112
74
|
return (await res.json());
|
|
113
75
|
const text = await res.text();
|
|
114
76
|
if (res.status === 429 && !isRetry) {
|
|
115
|
-
const
|
|
116
|
-
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS) : 1_000;
|
|
77
|
+
const delayMs = parseRetryAfterMs(res.headers.get('retry-after'), { defaultMs: 1_000, capMs: MAX_RETRY_AFTER_MS });
|
|
117
78
|
await this.sleep(delayMs);
|
|
118
79
|
return this.request(path, true);
|
|
119
80
|
}
|
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.3.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/tripadvisor-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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>",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
33
|
".claude-plugin",
|
|
34
|
-
"
|
|
34
|
+
"skills",
|
|
35
35
|
".mcp.json",
|
|
36
36
|
"server.json"
|
|
37
37
|
],
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test:coverage": "vitest run --coverage"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@chrischall/mcp-utils": "^0.
|
|
47
|
+
"@chrischall/mcp-utils": "^0.13.0",
|
|
48
48
|
"@fetchproxy/server": "^1.3.4",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
50
|
"dotenv": "^17.4.0",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@types/node": "^26.0.0",
|
|
55
55
|
"@vitest/coverage-v8": "^4.1.2",
|
|
56
56
|
"esbuild": "^0.28.0",
|
|
57
|
-
"typescript": "^
|
|
57
|
+
"typescript": "^7.0.2",
|
|
58
58
|
"vitest": "^4.1.2"
|
|
59
59
|
},
|
|
60
60
|
"allowScripts": {
|
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.
|
|
9
|
+
"version": "0.3.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/tripadvisor-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.3.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tripadvisor-api
|
|
3
|
+
description: >-
|
|
4
|
+
Query TripAdvisor location data (search, nearby, details, photos, reviews)
|
|
5
|
+
straight from a shell with curl against the Terra REST API
|
|
6
|
+
(terra.tripadvisor.com), instead of running the tripadvisor-mcp server —
|
|
7
|
+
plus a no-API-key fallback that reads a location's public page through the
|
|
8
|
+
fpx browser bridge. Use when you want TripAdvisor data without the MCP, in
|
|
9
|
+
a script, or on a machine where the MCP isn't installed. Triggers on "check
|
|
10
|
+
TripAdvisor", "TripAdvisor location/restaurant/hotel/attraction search,
|
|
11
|
+
details, photos, reviews", or any TripAdvisor data request that should hit
|
|
12
|
+
the API directly.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# TripAdvisor Terra API via curl (no MCP)
|
|
16
|
+
|
|
17
|
+
TripAdvisor's **Terra** API (`terra.tripadvisor.com/api`) is a plain
|
|
18
|
+
API-key REST API reachable directly from a server or shell — no browser
|
|
19
|
+
bridge needed. This skill shells out to `curl` with the key in an
|
|
20
|
+
`X-API-Key` header, exactly as `tripadvisor-mcp`'s `src/client.ts` does.
|
|
21
|
+
Terra is the current API (the legacy Content API sunsets 2026-08-31); it
|
|
22
|
+
has **no write endpoints** — everything here is a read.
|
|
23
|
+
|
|
24
|
+
A second, smaller tier at the bottom covers the one thing Terra can't do
|
|
25
|
+
without a key: reading a location's core details from the public
|
|
26
|
+
consumer page via the `fpx` browser bridge.
|
|
27
|
+
|
|
28
|
+
## One-time setup: get a Terra key
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
# Prefer the env var tripadvisor-mcp itself reads (check its .env first):
|
|
32
|
+
grep -h TRIPADVISOR_API_KEY ~/git/tripadvisor-mcp/.env 2>/dev/null
|
|
33
|
+
export TRIPADVISOR_API_KEY='...'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If you don't have one: create a free **Discover**-tier key at
|
|
37
|
+
https://www.tripadvisor.com/developers (pay-as-you-go, 10 QPS / 10,000
|
|
38
|
+
calls/day). A **legacy** Content API key does NOT work here (and vice
|
|
39
|
+
versa) — a mismatched key gets a `403`.
|
|
40
|
+
|
|
41
|
+
## Core call pattern
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
BASE=https://terra.tripadvisor.com/api
|
|
45
|
+
|
|
46
|
+
curl -sS "$BASE/locations/search?query=Golden%20Gate%20Bridge" \
|
|
47
|
+
-H "X-API-Key: $TRIPADVISOR_API_KEY" -H 'accept: application/json' \
|
|
48
|
+
| jq '.data[].location | {id, name: (.names[] | select(.primary) | .value)}'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every call needs just the two headers above — no session/cookie, no
|
|
52
|
+
mutual TLS. Ready-to-run recipes for all 6 endpoints are in
|
|
53
|
+
`references/terra-endpoints.md`.
|
|
54
|
+
|
|
55
|
+
## The one rule: resolve a location id first
|
|
56
|
+
|
|
57
|
+
Details/photos/reviews are keyed by numeric `location_id` — get one from
|
|
58
|
+
`ta_search_locations`'s equivalent, `GET /locations/search`, before
|
|
59
|
+
calling the id-scoped endpoints:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
curl -sS "$BASE/locations/search?query=Golden+Gate+Bridge" \
|
|
63
|
+
-H "X-API-Key: $TRIPADVISOR_API_KEY" | jq -r '.data[].location.id'
|
|
64
|
+
# 104675
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Response shapes (quick reference)
|
|
68
|
+
|
|
69
|
+
- List endpoints (`search`, `nearby`, `reviews`, `photos`) wrap results:
|
|
70
|
+
`{"data": [...], "pagination": {"page","size","total_pages","total_elements"}}`.
|
|
71
|
+
- `GET /locations` (batch) and `GET /locations/{id}` (details) do **not**
|
|
72
|
+
wrap in `pagination` — batch returns `{"data": [...]}`, details returns
|
|
73
|
+
the location object directly.
|
|
74
|
+
- A **location** object keys `names`/`descriptions`/`addresses` as
|
|
75
|
+
**arrays** tagged by language — the primary entry has `"primary": true`
|
|
76
|
+
(`jq '.names[] | select(.primary) | .value'`), not a flat `name` field.
|
|
77
|
+
|
|
78
|
+
Full field lists and all 6 curl+jq recipes: `references/terra-endpoints.md`.
|
|
79
|
+
|
|
80
|
+
## Output / error contract
|
|
81
|
+
|
|
82
|
+
- A 2xx body is JSON; pipe to `jq`.
|
|
83
|
+
- `400` — validation error; body is `{type, title, status, detail,
|
|
84
|
+
field_errors: [{field, message}], trace_id}` — `detail`/`field_errors`
|
|
85
|
+
name the bad param.
|
|
86
|
+
- `401`/`403` — key missing, invalid, or the wrong API family (legacy key
|
|
87
|
+
on Terra, or vice versa).
|
|
88
|
+
- `404` — unknown id, or a typo'd path (`/locations/{id}` is **plural**;
|
|
89
|
+
the singular form 404s).
|
|
90
|
+
- `429` — QPS (10) or daily quota (10,000) exceeded on the Discover tier;
|
|
91
|
+
back off and retry (the response may carry `Retry-After`).
|
|
92
|
+
|
|
93
|
+
## Fallback tier: no API key (fpx browser bridge)
|
|
94
|
+
|
|
95
|
+
TripAdvisor's consumer site (`www.tripadvisor.com`) is DataDome-walled, so
|
|
96
|
+
it can't be curled directly — but a location's public detail page is
|
|
97
|
+
plain server-rendered HTML with a clean `application/ld+json` block
|
|
98
|
+
(name, rating, review count, address, phone, coordinates), reachable with
|
|
99
|
+
**no API key** by routing the fetch through your own signed-in browser
|
|
100
|
+
tab via `fpx` (`@fetchproxy/cli`). Use this when you don't have (or don't
|
|
101
|
+
want to use) a Terra key, or `ta_get_location_details` is blocked.
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
npm install -g @fetchproxy/cli # provides `fpx`
|
|
105
|
+
fpx profile add tripadvisor --domain tripadvisor.com # fetch capability only
|
|
106
|
+
fpx pair -p tripadvisor # prints a pair code → approve in Transporter
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Requires the **Transporter** extension with an open `www.tripadvisor.com`
|
|
110
|
+
tab. This covers attractions, hotels, and restaurants — it does **not**
|
|
111
|
+
return individual review text (only the aggregate rating/count). Fetch +
|
|
112
|
+
parse recipe: `references/web-fallback.md`.
|
|
113
|
+
|
|
114
|
+
### fpx exit codes (fetch verbs)
|
|
115
|
+
|
|
116
|
+
- `0` — success.
|
|
117
|
+
- `2` — bridge unavailable: extension not connected / pairing pending →
|
|
118
|
+
`fpx pair -p tripadvisor`.
|
|
119
|
+
- `3` — bot wall: the tab hasn't cleared DataDome → open/refresh a
|
|
120
|
+
`www.tripadvisor.com` tab and retry.
|
|
121
|
+
- `4` — upstream non-2xx from TripAdvisor.
|
|
122
|
+
|
|
123
|
+
## Notes
|
|
124
|
+
|
|
125
|
+
- Terra reads only — nothing here mutates TripAdvisor data.
|
|
126
|
+
- This project (`tripadvisor-mcp`) is developed and maintained by AI
|
|
127
|
+
(Claude Code).
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Terra API endpoints (curl + jq)
|
|
2
|
+
|
|
3
|
+
All paths are relative to `$BASE=https://terra.tripadvisor.com/api`. Every
|
|
4
|
+
call carries `-H "X-API-Key: $TRIPADVISOR_API_KEY" -H 'accept: application/json'`
|
|
5
|
+
(shorthand `"${H[@]}"` below). Shapes captured live with a Discover-plan key;
|
|
6
|
+
transcribed from `tripadvisor-mcp`'s `src/tools/search.ts` + `src/tools/location.ts`
|
|
7
|
+
(each section names its source tool). All 6 are `GET`, all read-only.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
BASE=https://terra.tripadvisor.com/api
|
|
11
|
+
H=(-H "X-API-Key: $TRIPADVISOR_API_KEY" -H 'accept: application/json')
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Categories are `RESTAURANT` | `ATTRACTION` | `HOTEL` (uppercase). `size` on
|
|
15
|
+
list endpoints defaults to 20 and is **capped at 20**.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. Location search (`ta_search_locations` / `src/tools/search.ts`)
|
|
20
|
+
|
|
21
|
+
`GET /locations/search` — `query` (1–500 chars) required; optional
|
|
22
|
+
`category`, `search_type` (default `NAME`), `country_code` (alpha-2),
|
|
23
|
+
`geo_name`, `postal_code` (takes precedence over `geo_name`), `locale`
|
|
24
|
+
(repeated), `page`, `size`.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
curl -sS "${H[@]}" "$BASE/locations/search?query=Golden+Gate+Bridge&category=ATTRACTION" \
|
|
28
|
+
| jq '[.data[] | {id: .location.id, name: (.location.names[] | select(.primary) | .value),
|
|
29
|
+
geo: .location.geo, rating: .location.traveler_ratings.overall.rating}]'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Response: `{"data": [{"location": <Location>, "matched_value": {"language","value"}}], "pagination": {...}}`.
|
|
33
|
+
|
|
34
|
+
## 2. Nearby search (`ta_search_nearby` / `src/tools/search.ts`)
|
|
35
|
+
|
|
36
|
+
`GET /locations/nearby` — center is **exactly one** of:
|
|
37
|
+
`lat`+`lon`+`radius` (`unit=MI|KM`, default `MI`), `location_id`+`radius`,
|
|
38
|
+
or the box `sw_lat`,`sw_lon`,`ne_lat`,`ne_lon` (box mode ignores `radius`).
|
|
39
|
+
Plus optional `category`, `min_rating` (1.0–5.0), `include_photo` (bool),
|
|
40
|
+
`sort` (`distance`|`rating`), `page`, `size`, `locale`.
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
# lat/lon + radius
|
|
44
|
+
curl -sS "${H[@]}" "$BASE/locations/nearby?lat=37.8199&lon=-122.4783&radius=5&unit=MI&category=RESTAURANT&sort=rating" \
|
|
45
|
+
| jq '[.data[] | {id: .location.id, name: (.location.names[] | select(.primary) | .value),
|
|
46
|
+
distance_mi: .distance_miles, bearing}]'
|
|
47
|
+
|
|
48
|
+
# location_id + radius (reference location as center)
|
|
49
|
+
curl -sS "${H[@]}" "$BASE/locations/nearby?location_id=104675&radius=2&unit=KM&category=HOTEL" | jq '.data'
|
|
50
|
+
|
|
51
|
+
# bounding box
|
|
52
|
+
curl -sS "${H[@]}" "$BASE/locations/nearby?sw_lat=37.70&sw_lon=-122.55&ne_lat=37.85&ne_lon=-122.35&category=ATTRACTION" \
|
|
53
|
+
| jq '.data'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Response item: `{"location": <Location>, "bearing", "distance_miles", "distance_kilometers"}`.
|
|
57
|
+
|
|
58
|
+
## 3. Batch multi-get (`ta_get_locations` / `src/tools/location.ts`)
|
|
59
|
+
|
|
60
|
+
`GET /locations` — repeated `id` param (1–50 ids), required; optional
|
|
61
|
+
`locale`. **No `pagination` wrapper.** Unknown/unlicensed ids are silently
|
|
62
|
+
omitted (not an error) — a malformed id, e.g. one exceeding int32, does 400.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
curl -sS "${H[@]}" "$BASE/locations?id=104675&id=93520&id=423942" \
|
|
66
|
+
| jq '[.data[] | {id, name: (.names[] | select(.primary) | .value)}]'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Response: `{"data": [<Location>, ...]}` — cheaper than N single-id calls.
|
|
70
|
+
|
|
71
|
+
## 4. Location details (`ta_get_location_details` / `src/tools/location.ts`)
|
|
72
|
+
|
|
73
|
+
`GET /locations/{id}` — path `id` (int), **plural** `/locations/{id}` (the
|
|
74
|
+
docs' llms.txt index shows the singular form; that 404s). Optional
|
|
75
|
+
`locale` (repeated).
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
curl -sS "${H[@]}" "$BASE/locations/104675" | jq '{
|
|
79
|
+
id, geo, name: (.names[] | select(.primary) | .value),
|
|
80
|
+
rating: .traveler_ratings.overall, address: .addresses[0],
|
|
81
|
+
phone: .phone_numbers[0].value, url: .urls.tripadvisor.main
|
|
82
|
+
}'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Response: the full `Location` object directly (not wrapped in `data`) —
|
|
86
|
+
`names`/`descriptions`/`addresses` are language-tagged arrays; the primary
|
|
87
|
+
entry has `"primary": true`.
|
|
88
|
+
|
|
89
|
+
## 5. Location photos (`ta_get_location_photos` / `src/tools/location.ts`)
|
|
90
|
+
|
|
91
|
+
`GET /locations/{id}/photos` — optional `page`, `size` (max 20), `locale`.
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
curl -sS "${H[@]}" "$BASE/locations/104675/photos?size=10" \
|
|
95
|
+
| jq '[.data[] | {id, url: .photo.original_size_url, w: .photo.original_width, h: .photo.original_height}]'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Response: `{"data": [{"id","location_id","photo": {"key","original_size_url","original_height","original_width","media_type"}, "publish_ts", "source": {"name"}, "user"}], "pagination": {...}}`.
|
|
99
|
+
|
|
100
|
+
## 6. Location reviews (`ta_get_location_reviews` / `src/tools/location.ts`)
|
|
101
|
+
|
|
102
|
+
`GET /locations/{id}/reviews` — optional `page`, `size` (max 20), `locale`.
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
curl -sS "${H[@]}" "$BASE/locations/104675/reviews?size=10" | jq '.data'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Response: `{"data": [...review objects...], "pagination": {...}}`.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Error bodies
|
|
113
|
+
|
|
114
|
+
- `400` — `{"type","title","status","detail","field_errors": [{"field","message"}],"trace_id"}`.
|
|
115
|
+
- `401`/`403` — `{"Message": "..."}` (a legacy-vs-Terra key mismatch reads
|
|
116
|
+
as an AWS-gateway "explicit deny" message on the legacy endpoint).
|
|
117
|
+
- `404` — `{"message": "Not Found"}`.
|
|
118
|
+
- `429` — QPS (10) or daily quota (10,000) exceeded on Discover.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Web fallback: location detail via fpx (no API key)
|
|
2
|
+
|
|
3
|
+
Covers `ta_web_get_location` (`tripadvisor-mcp`'s `src/tools/web.ts` +
|
|
4
|
+
`src/web/parse.ts`) — the one solid endpoint on the consumer site, reached
|
|
5
|
+
with **no Terra key** by routing through your own signed-in browser tab.
|
|
6
|
+
Shapes captured live 2026-07-04; re-verify if parsing drifts (see
|
|
7
|
+
`docs/TRIPADVISOR-WEB-API.md` in the repo for the full recon).
|
|
8
|
+
|
|
9
|
+
Setup (once): see `../SKILL.md`'s "Fallback tier" section
|
|
10
|
+
(`fpx profile add tripadvisor --domain tripadvisor.com` + `fpx pair -p tripadvisor`).
|
|
11
|
+
|
|
12
|
+
## Canonical URL — works for every category
|
|
13
|
+
|
|
14
|
+
TripAdvisor canonicalizes on the `d<id>` segment; the `g<geo>` and the
|
|
15
|
+
`_Review` type prefix are corrected by a same-origin redirect that the
|
|
16
|
+
in-tab fetch follows. **One fixed URL form works whether the id is an
|
|
17
|
+
attraction, hotel, or restaurant** — no need to know the category up front:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
https://www.tripadvisor.com/Attraction_Review-g1-d<locationId>-Reviews-a-a.html
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Fetch + parse
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
LOCATION_ID=104675
|
|
27
|
+
fpx get "https://www.tripadvisor.com/Attraction_Review-g1-d${LOCATION_ID}-Reviews-a-a.html" \
|
|
28
|
+
-p tripadvisor > /tmp/ta-location.html
|
|
29
|
+
|
|
30
|
+
# The page embeds 3 application/ld+json blocks; the business node is the
|
|
31
|
+
# one with BOTH `name` and `aggregateRating` (its @type varies by category:
|
|
32
|
+
# LocalBusiness=attraction, LodgingBusiness=hotel, FoodEstablishment=restaurant).
|
|
33
|
+
python3 - /tmp/ta-location.html <<'PY'
|
|
34
|
+
import re, json, sys
|
|
35
|
+
html = open(sys.argv[1]).read()
|
|
36
|
+
for m in re.findall(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.S | re.I):
|
|
37
|
+
try:
|
|
38
|
+
obj = json.loads(m.strip())
|
|
39
|
+
except Exception:
|
|
40
|
+
continue
|
|
41
|
+
if isinstance(obj, dict) and 'name' in obj and 'aggregateRating' in obj:
|
|
42
|
+
print(json.dumps(obj))
|
|
43
|
+
break
|
|
44
|
+
PY
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Pipe that single-line JSON into `jq` for a slim projection matching what
|
|
48
|
+
`ta_web_get_location` returns:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
... | jq '{
|
|
52
|
+
name, type: .["@type"], url,
|
|
53
|
+
rating: (.aggregateRating.ratingValue | tonumber),
|
|
54
|
+
review_count: .aggregateRating.reviewCount,
|
|
55
|
+
best_rating: .aggregateRating.bestRating,
|
|
56
|
+
telephone, image,
|
|
57
|
+
latitude: .geo.latitude, longitude: .geo.longitude,
|
|
58
|
+
same_as: .sameAs, address
|
|
59
|
+
}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Example fields (attraction, `d104675`):
|
|
63
|
+
|
|
64
|
+
```jsonc
|
|
65
|
+
{
|
|
66
|
+
"@type": "LocalBusiness",
|
|
67
|
+
"name": "Golden Gate Bridge",
|
|
68
|
+
"url": "https://www.tripadvisor.com/Attraction_Review-g60713-d104675-...html",
|
|
69
|
+
"address": {"addressLocality": "San Francisco", "addressRegion": "California", "addressCountry": "US", "postalCode": "94129"},
|
|
70
|
+
"aggregateRating": {"ratingValue": "4.7", "reviewCount": 49969, "bestRating": 5},
|
|
71
|
+
"image": "https://dynamic-media-cdn.tripadvisor.com/media/photo-o/.../golden-gate-bridge.jpg?...",
|
|
72
|
+
"telephone": "+1 415-921-5858",
|
|
73
|
+
"geo": {"latitude": 37.820026, "longitude": -122.47859},
|
|
74
|
+
"sameAs": "https://www.goldengate.org/"
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Limits
|
|
79
|
+
|
|
80
|
+
- No key-free search/typeahead endpoint exists — every plausible one
|
|
81
|
+
(`/TypeAheadJson`, `/data/1.0/typeahead`, `/api/internal/1.14/typeahead`,
|
|
82
|
+
`/Search?q=`) is a dead end (empty body, 404, needs auth, or a hydrated
|
|
83
|
+
SPA shell with no SSR data). **Resolve a `locationId` via the Terra
|
|
84
|
+
`GET /locations/search` endpoint** (`references/terra-endpoints.md` §1),
|
|
85
|
+
or take it from a TripAdvisor URL, then use this fallback for detail.
|
|
86
|
+
- Individual review **text** is not in the ld+json (no `Review` schema
|
|
87
|
+
block, no Apollo/redux store) — only the aggregate rating/count. Use the
|
|
88
|
+
Terra `GET /locations/{id}/reviews` endpoint for review text.
|
|
89
|
+
- If the fetched body isn't valid HTML with an ld+json block (a
|
|
90
|
+
bot-challenge interstitial slipped through), re-open/refresh the
|
|
91
|
+
`www.tripadvisor.com` tab and retry.
|
|
File without changes
|