@thehikingworld/mcp 0.0.0-stage → 1.1.1

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/README.md CHANGED
@@ -1,3 +1,40 @@
1
- # Temporary Holding Version
1
+ # @thehikingworld/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read-only access to [The Hiking World](https://www.thehikingworld.com): sourced, dated trail and permit records. A figure is returned only when the catalog stored it. Unverified fees stay labelled unverified. Null means not recorded.
4
+
5
+ Geometry is OpenStreetMap, ODbL. Editorial text is The Hiking World. Permit tariffs stay the issuer's. Cite the trail URL and the verified date.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx -y @thehikingworld/mcp
11
+ ```
12
+
13
+ Claude Desktop:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "thehikingworld": {
19
+ "command": "npx",
20
+ "args": ["-y", "@thehikingworld/mcp"]
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ The process speaks MCP on stdin/stdout. `THW_API_BASE` overrides the default origin `https://www.thehikingworld.com`.
27
+
28
+ ## Hosted
29
+
30
+ `https://www.thehikingworld.com/api/mcp` is the same tool set over Streamable HTTP. No install. No API key. Read-only.
31
+
32
+ ## Tools
33
+
34
+ `get_trail`, `search_trails`, `get_permit`, `plan_permits`, `get_updates`, `list_regions`, `list_countries`, `get_geojson`, `get_provenance`.
35
+
36
+ `plan_permits` takes one or more trail slugs and returns the stored permit rows for each, summing only confident same-currency official figures; a mixed or unverified set keeps a null total. It is a reference, not a route planner or a booking step.
37
+
38
+ `get_updates` returns confirmed fee changes, newest first, each with a citation. `get_permit` and `get_trail` carry the citation string when a fee is citeable.
39
+
40
+ If the feed is down, the tool returns an error and the process stays up.
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { pathToFileURL } from "node:url";
5
+ import { registerTools } from "./tools.js";
6
+ export { filterSummaries, matchPermits, registerTools } from "./tools.js";
7
+ async function main() {
8
+ const server = new McpServer({ name: "thehikingworld", version: "1.1.1" });
9
+ registerTools(server);
10
+ const transport = new StdioServerTransport();
11
+ await server.connect(transport);
12
+ }
13
+ const invokedDirectly = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href;
14
+ if (invokedDirectly) {
15
+ main().catch((error) => {
16
+ const message = error instanceof Error ? error.message : "MCP server failed";
17
+ process.stderr.write(`${message}\n`);
18
+ process.exit(1);
19
+ });
20
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,268 @@
1
+ import { z } from "zod";
2
+ const DEFAULT_BASE = "https://www.thehikingworld.com";
3
+ const HONESTY = "Figures are sourced and dated. They may be provisional. Cite the trail URL and the verifiedDate. Permit status is verified-official, reported-secondary, or unverified, or null when the feed has none. Null means not recorded. This server does not invent figures.";
4
+ const VERIFY_NOTE = " A returned figure can be verified with get_provenance and the record signature.";
5
+ function baseUrl() {
6
+ return (process.env.THW_API_BASE || DEFAULT_BASE).replace(/\/$/, "");
7
+ }
8
+ async function getJson(path) {
9
+ // TODO perf: direct lib calls
10
+ const url = `${baseUrl()}${path}`;
11
+ try {
12
+ const response = await fetch(url, { headers: { Accept: "application/json" } });
13
+ let body = null;
14
+ try {
15
+ body = (await response.json());
16
+ }
17
+ catch {
18
+ body = null;
19
+ }
20
+ if (!response.ok) {
21
+ return { status: response.status, body, error: `Feed returned ${response.status} for ${path}` };
22
+ }
23
+ return { status: response.status, body };
24
+ }
25
+ catch (cause) {
26
+ const message = cause instanceof Error ? cause.message : "network error";
27
+ return { status: 0, body: null, error: `The Hiking World feed is unreachable (${message}).` };
28
+ }
29
+ }
30
+ async function postJson(path, payload) {
31
+ const url = `${baseUrl()}${path}`;
32
+ try {
33
+ const response = await fetch(url, {
34
+ method: "POST",
35
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
36
+ body: JSON.stringify(payload),
37
+ });
38
+ let body = null;
39
+ try {
40
+ body = (await response.json());
41
+ }
42
+ catch {
43
+ body = null;
44
+ }
45
+ if (!response.ok)
46
+ return { status: response.status, body, error: `Feed returned ${response.status} for ${path}` };
47
+ return { status: response.status, body };
48
+ }
49
+ catch (cause) {
50
+ const message = cause instanceof Error ? cause.message : "network error";
51
+ return { status: 0, body: null, error: `The Hiking World feed is unreachable (${message}).` };
52
+ }
53
+ }
54
+ function text(value, isError = false) {
55
+ return {
56
+ isError,
57
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
58
+ };
59
+ }
60
+ function km(summary) {
61
+ const value = summary.distanceKm?.value;
62
+ return typeof value === "number" ? value : null;
63
+ }
64
+ export function filterSummaries(rows, query) {
65
+ const country = query.country?.trim().toLowerCase();
66
+ const region = query.region?.trim().toLowerCase();
67
+ const sac = query.sacScale?.trim().toLowerCase();
68
+ return rows.filter((row) => {
69
+ if (country && row.country.toLowerCase() !== country)
70
+ return false;
71
+ if (region && row.region.toLowerCase() !== region)
72
+ return false;
73
+ if (sac && (row.sacScale ?? "").toLowerCase() !== sac)
74
+ return false;
75
+ if (query.permitRequired != null && row.permitRequired !== query.permitRequired)
76
+ return false;
77
+ if (query.maxTypicalDays != null && (row.typicalDays == null || row.typicalDays > query.maxTypicalDays)) {
78
+ return false;
79
+ }
80
+ const distance = km(row);
81
+ if (query.minDistanceKm != null && (distance == null || distance < query.minDistanceKm))
82
+ return false;
83
+ if (query.maxDistanceKm != null && (distance == null || distance > query.maxDistanceKm))
84
+ return false;
85
+ return true;
86
+ });
87
+ }
88
+ function norm(value) {
89
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
90
+ }
91
+ export function matchPermits(rows, query) {
92
+ const needle = norm(query);
93
+ const slugNeedle = needle.replace(/ /g, "-");
94
+ const exactSlug = rows.filter((row) => row.trailSlug === slugNeedle || row.trailSlug === query.trim());
95
+ if (exactSlug.length)
96
+ return { match: "rows", rows: exactSlug };
97
+ const exactName = rows.filter((row) => norm(row.trailName) === needle);
98
+ if (exactName.length)
99
+ return { match: "rows", rows: exactName };
100
+ const fuzzy = rows.filter((row) => norm(row.trailName).includes(needle) || row.trailSlug.includes(slugNeedle));
101
+ const slugs = [...new Set(fuzzy.map((row) => row.trailSlug))];
102
+ if (slugs.length === 1)
103
+ return { match: "rows", rows: fuzzy };
104
+ if (slugs.length > 1) {
105
+ const candidates = slugs.map((slug) => fuzzy.find((row) => row.trailSlug === slug));
106
+ return { match: "candidates", rows: candidates };
107
+ }
108
+ return { match: "none", rows: [] };
109
+ }
110
+ export function registerTools(server) {
111
+ server.registerTool("plan_permits", {
112
+ description: `Structured permit plan from stored rows only. The model does not author fees. A missing total means no confident total. ${HONESTY}`,
113
+ inputSchema: { query: z.string().describe("Trail name or permit question. No fee figures.") },
114
+ }, async ({ query }) => {
115
+ const result = await postJson("/api/agent/permit-planner", { query });
116
+ if (result.error || !result.body)
117
+ return text({ error: result.error ?? "Planner unavailable" }, true);
118
+ return text({ caveat: "permit facts and issuer links, not legal/visa advice.", plan: result.body });
119
+ });
120
+ server.registerTool("get_trail", {
121
+ description: `Full sourced trail record from /api/v1/trails/{slug}, including permits, provenance, and geometryUrl. Unknown slug returns not found. ${HONESTY}${VERIFY_NOTE}`,
122
+ inputSchema: { slug: z.string().describe("Trail slug, for example inca-trail") },
123
+ }, async ({ slug }) => {
124
+ const aliases = {
125
+ "makalu-basecamp": "makalu-base-camp",
126
+ "makalu-base-camp-trek-with-arun-valley": "makalu-base-camp",
127
+ "tsum-valley-trek": "tsum-valley",
128
+ gosaikunda: "gosainkunda-trek",
129
+ "mardi-himal-base-camp-trail": "mardi-himal-trek",
130
+ };
131
+ const canonicalSlug = aliases[slug];
132
+ const result = await getJson(`/api/v1/trails/${encodeURIComponent(canonicalSlug ?? slug)}`);
133
+ if (result.error && result.status === 0)
134
+ return text({ error: result.error }, true);
135
+ if (result.status === 404 || result.body?.data == null) {
136
+ return text({ error: "not found", slug });
137
+ }
138
+ return text(canonicalSlug ? { ...result.body, canonicalSlug } : result.body);
139
+ });
140
+ server.registerTool("search_trails", {
141
+ description: `Filter trail summaries from /api/v1/trails. Each distance and elevation stays the feed object (value, source, verifiedDate, provisional). ${HONESTY}${VERIFY_NOTE}`,
142
+ inputSchema: {
143
+ country: z.string().optional(),
144
+ region: z.string().optional(),
145
+ maxDistanceKm: z.number().optional(),
146
+ minDistanceKm: z.number().optional(),
147
+ sacScale: z.string().optional().describe("SAC grade such as T3"),
148
+ permitRequired: z.boolean().optional(),
149
+ maxTypicalDays: z.number().optional(),
150
+ },
151
+ }, async (query) => {
152
+ const result = await getJson("/api/v1/trails");
153
+ if (result.error || !result.body?.data)
154
+ return text({ error: result.error ?? "Empty trail feed" }, true);
155
+ const data = filterSummaries(result.body.data, query);
156
+ return text({
157
+ schemaVersion: result.body.schemaVersion,
158
+ generatedAt: result.body.generatedAt,
159
+ license: result.body.license,
160
+ data,
161
+ });
162
+ });
163
+ server.registerTool("get_permit", {
164
+ description: `Permit rows from /api/v1/permits for a slug or a name ("Manaslu", "EBC"). Ambiguous names return candidates instead of a guess. Every row keeps status and verifiedDate. ${HONESTY}${VERIFY_NOTE}`,
165
+ inputSchema: { trail: z.string().describe("Slug or trail name") },
166
+ }, async ({ trail }) => {
167
+ const result = await getJson("/api/v1/permits");
168
+ if (result.error || !result.body?.data)
169
+ return text({ error: result.error ?? "Empty permit feed" }, true);
170
+ const found = matchPermits(result.body.data, trail);
171
+ if (found.match === "none")
172
+ return text({ error: "not found", trail });
173
+ if (found.match === "candidates") {
174
+ return text({
175
+ ambiguous: true,
176
+ candidates: found.rows.map((row) => ({
177
+ trailSlug: row.trailSlug,
178
+ trailName: row.trailName,
179
+ country: row.country,
180
+ status: row.status,
181
+ verifiedDate: row.verifiedDate,
182
+ })),
183
+ });
184
+ }
185
+ return text({
186
+ schemaVersion: result.body.schemaVersion,
187
+ generatedAt: result.body.generatedAt,
188
+ license: result.body.license,
189
+ data: found.rows,
190
+ });
191
+ });
192
+ server.registerTool("list_regions", {
193
+ description: `Distinct region and country names on /api/v1/trails, so an agent can see coverage. ${HONESTY}${VERIFY_NOTE}`,
194
+ inputSchema: {},
195
+ }, async () => {
196
+ const result = await getJson("/api/v1/trails");
197
+ if (result.error || !result.body?.data)
198
+ return text({ error: result.error ?? "Empty trail feed" }, true);
199
+ const seen = new Map();
200
+ for (const row of result.body.data) {
201
+ seen.set(`${row.country}\t${row.region}`, { region: row.region, country: row.country });
202
+ }
203
+ return text({
204
+ schemaVersion: result.body.schemaVersion,
205
+ generatedAt: result.body.generatedAt,
206
+ license: result.body.license,
207
+ data: [...seen.values()].sort((a, b) => a.country.localeCompare(b.country) || a.region.localeCompare(b.region)),
208
+ });
209
+ });
210
+ server.registerTool("list_countries", {
211
+ description: `Distinct countries on /api/v1/trails. ${HONESTY}${VERIFY_NOTE}`,
212
+ inputSchema: {},
213
+ }, async () => {
214
+ const result = await getJson("/api/v1/trails");
215
+ if (result.error || !result.body?.data)
216
+ return text({ error: result.error ?? "Empty trail feed" }, true);
217
+ const counts = new Map();
218
+ for (const row of result.body.data)
219
+ counts.set(row.country, (counts.get(row.country) ?? 0) + 1);
220
+ return text({
221
+ schemaVersion: result.body.schemaVersion,
222
+ generatedAt: result.body.generatedAt,
223
+ license: result.body.license,
224
+ data: [...counts.entries()]
225
+ .map(([country, trails]) => ({ country, trails }))
226
+ .sort((a, b) => a.country.localeCompare(b.country)),
227
+ });
228
+ });
229
+ server.registerTool("get_geojson", {
230
+ description: `Trail line from /api/v1/trails/{slug}/geojson. A schematic line has schematic: true and is not a GPS track. ${HONESTY}${VERIFY_NOTE}`,
231
+ inputSchema: { slug: z.string() },
232
+ }, async ({ slug }) => {
233
+ const result = await getJson(`/api/v1/trails/${encodeURIComponent(slug)}/geojson`);
234
+ if (result.error && result.status === 0)
235
+ return text({ error: result.error }, true);
236
+ if (result.status === 404 || result.body?.data == null)
237
+ return text({ error: "not found", slug });
238
+ return text(result.body);
239
+ });
240
+ server.registerTool("get_provenance", {
241
+ description: `Published public key, signing policy, and the verification recipe from /api/v1/provenance. ${HONESTY}`,
242
+ inputSchema: {},
243
+ }, async () => {
244
+ const result = await getJson("/api/v1/provenance");
245
+ if (result.error || !result.body)
246
+ return text({ error: result.error ?? "Empty provenance feed" }, true);
247
+ return text(result.body);
248
+ });
249
+ server.registerTool("get_updates", {
250
+ description: `Confirmed permit-fee changes from /updates.json, newest first. Each row has the old figure, the new figure, the issuer, the source URL, and a citation. ${HONESTY}`,
251
+ inputSchema: {
252
+ limit: z.number().int().min(1).max(50).optional().describe("How many changes to return. Default 20."),
253
+ },
254
+ }, async ({ limit }) => {
255
+ const result = await getJson("/updates.json");
256
+ if (result.error || !result.body?.changes)
257
+ return text({ error: result.error ?? "Empty updates feed" }, true);
258
+ const cap = limit ?? 20;
259
+ const changes = [...result.body.changes]
260
+ .sort((a, b) => b.date.localeCompare(a.date))
261
+ .slice(0, cap)
262
+ .map((change) => ({
263
+ ...change,
264
+ citation: change.citation ?? `${change.sentence ?? `${change.trail} fee change`} Source: ${change.sourceUrl}`,
265
+ }));
266
+ return text({ data: changes });
267
+ });
268
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@thehikingworld/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.1",
4
+ "description": "MCP server for The Hiking World sourced trail catalog.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://gitlab.com/ravitejarx/the-hiking-world.git",
10
+ "directory": "packages/mcp"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "bin": {
16
+ "thehikingworld-mcp": "./dist/index.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "typecheck": "tsc --noEmit",
25
+ "start": "node dist/index.js"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.32.1",
32
+ "zod": "^3.25.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.0.0",
36
+ "typescript": "^5.6.0"
37
+ }
38
+ }