tdx-mcp 1.0.0 → 1.0.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 +19 -0
- package/dist/api.d.ts +13 -0
- package/dist/api.js +172 -0
- package/dist/license.d.ts +8 -0
- package/dist/license.js +79 -0
- package/dist/server.js +61 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,3 +18,22 @@ Needs free TDX client id + secret (TDX_CLIENT_SECRET) from tdx.transportdata.tw.
|
|
|
18
18
|
- `get_tra_timetable` — Taiwan Railway daily timetable for a station on a date.
|
|
19
19
|
|
|
20
20
|
Part of [awesome-mcps](https://github.com/mrfentmen/awesome-mcps).
|
|
21
|
+
|
|
22
|
+
<!-- paywall -->
|
|
23
|
+
## Premium tools
|
|
24
|
+
|
|
25
|
+
These tools need a license key:
|
|
26
|
+
|
|
27
|
+
* `ods_train_departures`
|
|
28
|
+
* `ods_train_route`
|
|
29
|
+
|
|
30
|
+
Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-tdx-mcp and set it in your MCP client config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
"env": { "MCP_LICENSE_KEY": "mcp_live_..." }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The key is checked against MCP Marketplace, cached for 24 hours, and keeps
|
|
37
|
+
working offline once one check has succeeded. Every other tool on this server
|
|
38
|
+
stays free.
|
|
39
|
+
<!-- /paywall -->
|
package/dist/api.d.ts
CHANGED
|
@@ -6,3 +6,16 @@ export declare function listTraStations(): Promise<string>;
|
|
|
6
6
|
export declare function getTraLiveboard(stationId: string): Promise<string>;
|
|
7
7
|
export declare function listThsrStations(): Promise<string>;
|
|
8
8
|
export declare function getTraTimetable(stationId: string, date: string): Promise<string>;
|
|
9
|
+
export declare function odsStationSearch(args: {
|
|
10
|
+
query: string;
|
|
11
|
+
limit?: number;
|
|
12
|
+
}): Promise<string>;
|
|
13
|
+
export declare function odsTrainDepartures(args: {
|
|
14
|
+
stationId: string;
|
|
15
|
+
date?: string;
|
|
16
|
+
limit?: number;
|
|
17
|
+
}): Promise<string>;
|
|
18
|
+
export declare function odsTrainRoute(args: {
|
|
19
|
+
trainNumber: string;
|
|
20
|
+
date?: string;
|
|
21
|
+
}): Promise<string>;
|
package/dist/api.js
CHANGED
|
@@ -97,3 +97,175 @@ export async function getTraTimetable(stationId, date) {
|
|
|
97
97
|
const data = await req(url);
|
|
98
98
|
return pretty(data);
|
|
99
99
|
}
|
|
100
|
+
// ---- muxB tools:
|
|
101
|
+
// Taiwan Railway's own Open Data System (ods.railway.gov.tw) publishes the
|
|
102
|
+
// station list and a rolling window of daily timetables with no key at all.
|
|
103
|
+
// These two URLs are the download links data.gov.tw points at for datasets
|
|
104
|
+
// 33425 (station basic data) and the daily timetable.
|
|
105
|
+
const ODS_BASE = "https://ods.railway.gov.tw/tra-ods-web/ods/download/dataResource";
|
|
106
|
+
const ODS_STATIONS = `${ODS_BASE}/0518b833e8964d53bfea3f7691aea0ee`;
|
|
107
|
+
const ODS_TIMETABLE_INDEX = `${ODS_BASE}/railway_schedule/JSON/list`;
|
|
108
|
+
let stationCache = null;
|
|
109
|
+
let timetableIndex = null;
|
|
110
|
+
async function odsFetch(url, timeout = 30000) {
|
|
111
|
+
const res = await fetch(url, {
|
|
112
|
+
headers: { ...UA, Accept: "application/json, text/html" },
|
|
113
|
+
signal: AbortSignal.timeout(timeout),
|
|
114
|
+
});
|
|
115
|
+
if (res.status === 404)
|
|
116
|
+
throw new TdxError(`Taiwan Railway Open Data has nothing at ${url}`);
|
|
117
|
+
if (!res.ok)
|
|
118
|
+
throw new TdxError(`Taiwan Railway Open Data returned HTTP ${res.status}`);
|
|
119
|
+
return (await res.json());
|
|
120
|
+
}
|
|
121
|
+
async function getStations() {
|
|
122
|
+
if (stationCache)
|
|
123
|
+
return stationCache;
|
|
124
|
+
const rows = await odsFetch(ODS_STATIONS);
|
|
125
|
+
const map = new Map();
|
|
126
|
+
for (const s of Array.isArray(rows) ? rows : []) {
|
|
127
|
+
if (s.stationCode)
|
|
128
|
+
map.set(String(s.stationCode), `${String(s.stationName ?? "")} ${String(s.stationEName ?? "")}`.trim());
|
|
129
|
+
}
|
|
130
|
+
if (map.size === 0)
|
|
131
|
+
throw new TdxError("The station dataset came back empty.");
|
|
132
|
+
stationCache = map;
|
|
133
|
+
return map;
|
|
134
|
+
}
|
|
135
|
+
async function getTimetableIndex() {
|
|
136
|
+
if (timetableIndex)
|
|
137
|
+
return timetableIndex;
|
|
138
|
+
const res = await fetch(ODS_TIMETABLE_INDEX, { headers: { ...UA }, signal: AbortSignal.timeout(30000) });
|
|
139
|
+
if (!res.ok)
|
|
140
|
+
throw new TdxError(`The TRA timetable index returned HTTP ${res.status}`);
|
|
141
|
+
const html = await res.text();
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const m of html.matchAll(/<a href="([^"]+)">(\d{8})\.json<\/a>/g)) {
|
|
144
|
+
out.push({ date: m[2], url: `https://ods.railway.gov.tw${m[1]}` });
|
|
145
|
+
}
|
|
146
|
+
if (!out.length)
|
|
147
|
+
throw new TdxError("The TRA timetable index listed no daily JSON files.");
|
|
148
|
+
out.sort((a, b) => b.date.localeCompare(a.date));
|
|
149
|
+
timetableIndex = out;
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
function requireDate(date) {
|
|
153
|
+
const d = (date ?? "").trim();
|
|
154
|
+
if (!/^\d{8}$/.test(d))
|
|
155
|
+
throw new TdxError(`Date must be YYYYMMDD, e.g. 20260928, got "${date ?? ""}".`);
|
|
156
|
+
return d;
|
|
157
|
+
}
|
|
158
|
+
// The index carries a rolling window of daily timetables, so with no date given
|
|
159
|
+
// pick the one closest to today rather than blindly the last one published.
|
|
160
|
+
function nearestDate(index) {
|
|
161
|
+
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
162
|
+
let best = index[0].date;
|
|
163
|
+
let bestGap = Number.MAX_SAFE_INTEGER;
|
|
164
|
+
for (const row of index) {
|
|
165
|
+
const gap = Math.abs(Number(row.date) - Number(today));
|
|
166
|
+
if (gap < bestGap) {
|
|
167
|
+
bestGap = gap;
|
|
168
|
+
best = row.date;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return best;
|
|
172
|
+
}
|
|
173
|
+
function clock(value) {
|
|
174
|
+
return typeof value === "string" && value ? value.slice(0, 5) : "--:--";
|
|
175
|
+
}
|
|
176
|
+
export async function odsStationSearch(args) {
|
|
177
|
+
const q = (args.query ?? "").trim().toLowerCase();
|
|
178
|
+
if (!q)
|
|
179
|
+
throw new TdxError("Search text is empty.");
|
|
180
|
+
const limit = Math.min(Math.max(args.limit ?? 8, 1), 30);
|
|
181
|
+
const rows = await odsFetch(ODS_STATIONS);
|
|
182
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
183
|
+
const matches = list
|
|
184
|
+
.filter((s) => String(s.stationCode ?? "").toLowerCase().includes(q) || String(s.stationName ?? "").includes(q) || String(s.stationEName ?? "").toLowerCase().includes(q))
|
|
185
|
+
.slice(0, limit);
|
|
186
|
+
if (matches.length === 0) {
|
|
187
|
+
const sample = list.slice(0, 3).map((s) => `${s.stationCode ?? "?"} ${s.stationName ?? ""}`).join(", ");
|
|
188
|
+
return `No Taiwan Railway station matches "${args.query ?? ""}". For example: ${sample}`;
|
|
189
|
+
}
|
|
190
|
+
return [
|
|
191
|
+
`Taiwan Railway stations matching "${args.query ?? ""}" - ${matches.length} of ${list.length}, showing ${matches.length}:`,
|
|
192
|
+
...matches.map((s, i) => {
|
|
193
|
+
const gps = String(s.gps ?? "").split(/\s+/).filter(Boolean);
|
|
194
|
+
return [
|
|
195
|
+
`${i + 1}. ${s.stationName ?? "(unnamed)"} / ${s.stationEName ?? "?"} [code ${s.stationCode ?? "?"}]`,
|
|
196
|
+
` ${String(s.stationAddrTw ?? "address not reported")}`,
|
|
197
|
+
` tel ${s.stationTel ?? "not reported"}${gps.length === 2 ? ` · ${gps[0]}, ${gps[1]}` : ""}${s.haveBike === "Y" ? " · bike rental" : ""}`,
|
|
198
|
+
].join("\n");
|
|
199
|
+
}),
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
export async function odsTrainDepartures(args) {
|
|
203
|
+
const station = (args.stationId ?? "").trim();
|
|
204
|
+
if (!/^\d{4}$/.test(station))
|
|
205
|
+
throw new TdxError(`Station code must be 4 digits, e.g. 1000 for Taipei, got "${args.stationId ?? ""}".`);
|
|
206
|
+
const limit = Math.min(Math.max(args.limit ?? 12, 1), 60);
|
|
207
|
+
const index = await getTimetableIndex();
|
|
208
|
+
const wanted = (args.date ?? "").trim() ? requireDate(args.date) : nearestDate(index);
|
|
209
|
+
const entry = index.find((d) => d.date === wanted);
|
|
210
|
+
if (!entry) {
|
|
211
|
+
const range = `${index[index.length - 1].date} to ${index[0].date}`;
|
|
212
|
+
return `No Taiwan Railway timetable is published for ${wanted}. The index covers ${range}.`;
|
|
213
|
+
}
|
|
214
|
+
const names = await getStations();
|
|
215
|
+
const data = await odsFetch(entry.url, 60000);
|
|
216
|
+
const trains = Array.isArray(data.TrainInfos) ? data.TrainInfos : [];
|
|
217
|
+
const rows = [];
|
|
218
|
+
for (const t of trains) {
|
|
219
|
+
const stops = Array.isArray(t.TimeInfos) ? t.TimeInfos : [];
|
|
220
|
+
const at = stops.find((s) => String(s.Station) === station);
|
|
221
|
+
if (!at)
|
|
222
|
+
continue;
|
|
223
|
+
const order = Number(at.Order ?? 0);
|
|
224
|
+
const origin = stops[0];
|
|
225
|
+
const dest = stops[stops.length - 1];
|
|
226
|
+
rows.push(` ${clock(at.DEPTime)} train ${t.Train ?? "?"} line ${t.Line ?? "?"}/${t.LineDir ?? "?"} ${names.get(String(origin.Station)) ?? origin.Station} -> ${names.get(String(dest.Station)) ?? dest.Station} (stop ${order} of ${stops.length})`);
|
|
227
|
+
}
|
|
228
|
+
if (!rows.length)
|
|
229
|
+
return `No train calls at station ${station} (${names.get(station) ?? "unknown"}) on ${wanted}.`;
|
|
230
|
+
rows.sort((a, b) => a.localeCompare(b));
|
|
231
|
+
return [
|
|
232
|
+
`Departures at ${names.get(station) ?? `station ${station}`} [${station}] on ${wanted} - ${rows.length} of ${trains.length} published trains, showing ${Math.min(limit, rows.length)}:`,
|
|
233
|
+
...rows.slice(0, limit),
|
|
234
|
+
rows.length > limit ? `...and ${rows.length - limit} more, raise limit to see them.` : "",
|
|
235
|
+
]
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
.join("\n");
|
|
238
|
+
}
|
|
239
|
+
export async function odsTrainRoute(args) {
|
|
240
|
+
const train = (args.trainNumber ?? "").trim();
|
|
241
|
+
if (!/^\d{1,5}$/.test(train))
|
|
242
|
+
throw new TdxError(`Train number must be digits, e.g. 1001, got "${args.trainNumber ?? ""}".`);
|
|
243
|
+
const index = await getTimetableIndex();
|
|
244
|
+
const wanted = (args.date ?? "").trim() ? requireDate(args.date) : nearestDate(index);
|
|
245
|
+
const entry = index.find((d) => d.date === wanted);
|
|
246
|
+
if (!entry) {
|
|
247
|
+
const range = `${index[index.length - 1].date} to ${index[0].date}`;
|
|
248
|
+
return `No Taiwan Railway timetable is published for ${wanted}. The index covers ${range}.`;
|
|
249
|
+
}
|
|
250
|
+
const names = await getStations();
|
|
251
|
+
const data = await odsFetch(entry.url, 60000);
|
|
252
|
+
const trains = Array.isArray(data.TrainInfos) ? data.TrainInfos : [];
|
|
253
|
+
const found = trains.find((t) => String(t.Train ?? "").trim() === train);
|
|
254
|
+
if (!found)
|
|
255
|
+
return `Train ${train} does not run on ${wanted}.`;
|
|
256
|
+
const stops = Array.isArray(found.TimeInfos) ? found.TimeInfos : [];
|
|
257
|
+
const last = stops.length - 1;
|
|
258
|
+
return [
|
|
259
|
+
`Taiwan Railway train ${train} on ${wanted} - line ${found.Line ?? "?"}, direction ${found.LineDir ?? "?"}, type ${found.Type ?? "?"}, carriage class ${found.CarClass ?? "not reported"}`,
|
|
260
|
+
`Bicycle: ${found.Bike === "Y" ? "carries bikes" : found.Bike === "N" ? "no bikes" : "not reported"} · dining: ${found.Dinning === "Y" ? "yes" : "no"} · food service: ${found.FoodSrv === "Y" ? "yes" : "no"} · accessible: ${found.Cripple === "Y" ? "yes" : "no"}`,
|
|
261
|
+
found.Note ? `Note: ${String(found.Note).slice(0, 200)}` : "",
|
|
262
|
+
stops.length ? "Stops:" : "Stops: none published",
|
|
263
|
+
...stops.map((s, i) => {
|
|
264
|
+
const arrive = i === 0 || i === last ? clock(s.ARRTime) : clock(s.ARRTime);
|
|
265
|
+
const depart = i === last ? "(terminus)" : clock(s.DEPTime);
|
|
266
|
+
return ` ${String(s.Order ?? i + 1).padStart(2)}. ${names.get(String(s.Station)) ?? `station ${s.Station}`} [${s.Station}] arr ${arrive} dep ${depart}`;
|
|
267
|
+
}),
|
|
268
|
+
]
|
|
269
|
+
.filter(Boolean)
|
|
270
|
+
.join("\n");
|
|
271
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
3
|
+
* them how to get a key.
|
|
4
|
+
*
|
|
5
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
6
|
+
* license server on every call and keeps working through a brief outage.
|
|
7
|
+
*/
|
|
8
|
+
export declare function premiumRequired(tool: string): Promise<string | null>;
|
package/dist/license.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Premium tools on this server need a license key bought from MCP Marketplace.
|
|
2
|
+
// Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
|
|
3
|
+
// that is not listed in PREMIUM keeps working without a key.
|
|
4
|
+
const SLUG = "tdx-mcp";
|
|
5
|
+
// Tools that need a key. Everything else is free.
|
|
6
|
+
const PREMIUM = new Set([
|
|
7
|
+
"ods_train_departures",
|
|
8
|
+
"ods_train_route",
|
|
9
|
+
]);
|
|
10
|
+
const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
|
|
11
|
+
// The license check calls the marketplace's verify endpoint directly instead of
|
|
12
|
+
// using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
|
|
13
|
+
// check it makes is rejected by Supabase before it reaches the function. The
|
|
14
|
+
// publishable key below is public by design - it ships in mcp-marketplace.io's
|
|
15
|
+
// own JavaScript and only ever reaches their licence check.
|
|
16
|
+
const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
|
|
17
|
+
const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
|
|
18
|
+
const OK_CACHE_MS = 60 * 60 * 1000;
|
|
19
|
+
const BAD_CACHE_MS = 60 * 1000;
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const REASONS = {
|
|
22
|
+
missing_key: "no key is set",
|
|
23
|
+
invalid_format: "that key is not a valid MCP Marketplace key",
|
|
24
|
+
not_found: "that key was not found",
|
|
25
|
+
revoked: "that key has been revoked",
|
|
26
|
+
rotated: "that key was rotated, use your new one",
|
|
27
|
+
expired: "that key has expired, renew it",
|
|
28
|
+
rate_limited: "there were too many checks just now, try again shortly",
|
|
29
|
+
network_error: "the license server could not be reached",
|
|
30
|
+
};
|
|
31
|
+
function blocked(tool, reason) {
|
|
32
|
+
const why = REASONS[reason] ?? `the license server answered "${reason}"`;
|
|
33
|
+
return `"${tool}" needs a license key, but ${why}. ` +
|
|
34
|
+
`Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
|
|
35
|
+
}
|
|
36
|
+
async function verify(key) {
|
|
37
|
+
const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
apikey: PUBLISHABLE_KEY,
|
|
41
|
+
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ key, slug: SLUG }),
|
|
45
|
+
// an unusable key answers 400 with a JSON body, so read the body either way
|
|
46
|
+
signal: AbortSignal.timeout(15000),
|
|
47
|
+
});
|
|
48
|
+
const data = (await res.json());
|
|
49
|
+
if (typeof data?.valid !== "boolean")
|
|
50
|
+
return { valid: false, reason: "unexpected_response" };
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
55
|
+
* them how to get a key.
|
|
56
|
+
*
|
|
57
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
58
|
+
* license server on every call and keeps working through a brief outage.
|
|
59
|
+
*/
|
|
60
|
+
export async function premiumRequired(tool) {
|
|
61
|
+
if (!PREMIUM.has(tool))
|
|
62
|
+
return null;
|
|
63
|
+
const key = process.env.MCP_LICENSE_KEY;
|
|
64
|
+
if (!key)
|
|
65
|
+
return blocked(tool, "missing_key");
|
|
66
|
+
const hit = seen.get(key);
|
|
67
|
+
if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
|
|
68
|
+
return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
|
|
69
|
+
}
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = await verify(key);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
result = { valid: false, reason: "network_error" };
|
|
76
|
+
}
|
|
77
|
+
seen.set(key, { ...result, at: Date.now() });
|
|
78
|
+
return result.valid ? null : blocked(tool, result.reason ?? "invalid");
|
|
79
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { errorMessage, listTraStations, getTraLiveboard, listThsrStations, getTraTimetable, } from "./api.js";
|
|
3
|
+
import { errorMessage, listTraStations, getTraLiveboard, listThsrStations, getTraTimetable, odsStationSearch, odsTrainDepartures, odsTrainRoute } from "./api.js";
|
|
4
|
+
import { premiumRequired } from "./license.js";
|
|
4
5
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
6
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
7
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -66,5 +67,64 @@ export function createServer() {
|
|
|
66
67
|
return textError(errorMessage(e));
|
|
67
68
|
}
|
|
68
69
|
});
|
|
70
|
+
server.registerTool("ods_station_search", {
|
|
71
|
+
title: "Search TRA stations",
|
|
72
|
+
description: "Search Taiwan Railway stations from the rail company's open data station list: code, Chinese and English name, address, phone, GPS coordinates and bike rental.",
|
|
73
|
+
inputSchema: z.object({
|
|
74
|
+
query: z.string().describe("Station code, Chinese name or English name, e.g. '1000', 'Taipei', '板橋'"),
|
|
75
|
+
limit: z.number().int().min(1).max(30).default(8),
|
|
76
|
+
}),
|
|
77
|
+
annotations: READ_ONLY,
|
|
78
|
+
}, async ({ query, limit }) => {
|
|
79
|
+
try {
|
|
80
|
+
return text(await odsStationSearch({ query, limit }));
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return textError(errorMessage(e));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
server.registerTool("ods_train_departures", {
|
|
87
|
+
title: "TRA departures board",
|
|
88
|
+
description: "Real Taiwan Railway departures for one station on one published date, from the rail company's open daily timetable: departure time, train number, line and its origin and destination.",
|
|
89
|
+
inputSchema: z.object({
|
|
90
|
+
stationId: z.string().describe("4-digit station code from ods_station_search, e.g. 1000 for Taipei"),
|
|
91
|
+
date: z.string().default("").describe("Date YYYYMMDD. Empty = newest published timetable."),
|
|
92
|
+
limit: z.number().int().min(1).max(60).default(12),
|
|
93
|
+
}),
|
|
94
|
+
annotations: READ_ONLY,
|
|
95
|
+
}, async ({ stationId, date, limit }) => {
|
|
96
|
+
// ---- paywall: ods_train_departures ----
|
|
97
|
+
const paywallMessage = await premiumRequired("ods_train_departures");
|
|
98
|
+
if (paywallMessage)
|
|
99
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
100
|
+
// ---- paywall: end ----
|
|
101
|
+
try {
|
|
102
|
+
return text(await odsTrainDepartures({ stationId, date, limit }));
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return textError(errorMessage(e));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
server.registerTool("ods_train_route", {
|
|
109
|
+
title: "TRA train route",
|
|
110
|
+
description: "The whole run of one Taiwan Railway train on one published date: every stop with arrival and departure times and station names, plus carriage class, bike, dining and accessibility flags.",
|
|
111
|
+
inputSchema: z.object({
|
|
112
|
+
trainNumber: z.string().describe("Train number, e.g. 1001"),
|
|
113
|
+
date: z.string().default("").describe("Date YYYYMMDD. Empty = newest published timetable."),
|
|
114
|
+
}),
|
|
115
|
+
annotations: READ_ONLY,
|
|
116
|
+
}, async ({ trainNumber, date }) => {
|
|
117
|
+
// ---- paywall: ods_train_route ----
|
|
118
|
+
const paywallMessage = await premiumRequired("ods_train_route");
|
|
119
|
+
if (paywallMessage)
|
|
120
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
121
|
+
// ---- paywall: end ----
|
|
122
|
+
try {
|
|
123
|
+
return text(await odsTrainRoute({ trainNumber, date }));
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
return textError(errorMessage(e));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
69
129
|
return server;
|
|
70
130
|
}
|
package/package.json
CHANGED