@tcliplab/transit-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/build/index.js +298 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Transit API Server
|
|
2
|
+
|
|
3
|
+
An MCP server for Transit API, a Japanese public transit API built on `https://api.transit.ls8h.com/`.
|
|
4
|
+
|
|
5
|
+
This server wraps the REST API and is still unofficial.
|
|
6
|
+
|
|
7
|
+
I made this to search Japanese transit information seamlessly from LLM chat.
|
|
8
|
+
|
|
9
|
+
## LICENSE
|
|
10
|
+
|
|
11
|
+
No license is granted at this time. OSS release is planned after permission and scope are clarified.
|
package/build/index.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import * as z from "zod/v4";
|
|
4
|
+
const API_BASE = "https://api.transit.ls8h.com";
|
|
5
|
+
function toSearchParams(params) {
|
|
6
|
+
const searchParams = new URLSearchParams();
|
|
7
|
+
for (const [key, value] of Object.entries(params)) {
|
|
8
|
+
if (value === undefined) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
searchParams.set(key, String(value));
|
|
12
|
+
}
|
|
13
|
+
return searchParams;
|
|
14
|
+
}
|
|
15
|
+
async function fetchTransit(path, params = {}) {
|
|
16
|
+
const url = new URL(path, API_BASE);
|
|
17
|
+
const searchParams = toSearchParams(params);
|
|
18
|
+
searchParams.forEach((value, key) => {
|
|
19
|
+
url.searchParams.set(key, value);
|
|
20
|
+
});
|
|
21
|
+
const response = await fetch(url, {
|
|
22
|
+
headers: {
|
|
23
|
+
accept: "application/json",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const rawText = await response.text();
|
|
27
|
+
let data = rawText;
|
|
28
|
+
try {
|
|
29
|
+
data = JSON.parse(rawText);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Keep raw text when the endpoint does not return JSON.
|
|
33
|
+
}
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const detail = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
36
|
+
throw new Error(`Transit API ${response.status} ${response.statusText}: ${detail}`);
|
|
37
|
+
}
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
function formatServiceSeconds(seconds) {
|
|
41
|
+
const day = 86_400;
|
|
42
|
+
const dayOffset = Math.floor(seconds / day);
|
|
43
|
+
const normalized = ((seconds % day) + day) % day;
|
|
44
|
+
const hours = Math.floor(normalized / 3600);
|
|
45
|
+
const minutes = Math.floor((normalized % 3600) / 60);
|
|
46
|
+
const clock = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
47
|
+
if (dayOffset === 0) {
|
|
48
|
+
return clock;
|
|
49
|
+
}
|
|
50
|
+
return `${clock} (${dayOffset > 0 ? `+${dayOffset}d` : `${dayOffset}d`})`;
|
|
51
|
+
}
|
|
52
|
+
function secondsToDuration(seconds) {
|
|
53
|
+
const total = Math.max(0, Math.round(seconds));
|
|
54
|
+
const hours = Math.floor(total / 3600);
|
|
55
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
56
|
+
const secs = total % 60;
|
|
57
|
+
if (hours > 0) {
|
|
58
|
+
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
59
|
+
}
|
|
60
|
+
if (minutes > 0) {
|
|
61
|
+
return `${minutes}m ${String(secs).padStart(2, "0")}s`;
|
|
62
|
+
}
|
|
63
|
+
return `${secs}s`;
|
|
64
|
+
}
|
|
65
|
+
function asRecord(value) {
|
|
66
|
+
if (typeof value === "object" && value !== null) {
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
function asArray(value) {
|
|
72
|
+
return Array.isArray(value) ? value : [];
|
|
73
|
+
}
|
|
74
|
+
function textResult(summary, data) {
|
|
75
|
+
const structuredContent = typeof data === "object" && data !== null
|
|
76
|
+
? data
|
|
77
|
+
: { value: data };
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: summary,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
structuredContent,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function summarizeJourneys(data) {
|
|
89
|
+
const record = asRecord(data);
|
|
90
|
+
const journeys = asArray(record.journeys);
|
|
91
|
+
const lines = journeys.slice(0, 3).map((journey, index) => {
|
|
92
|
+
const item = asRecord(journey);
|
|
93
|
+
const departureSecs = typeof item.departureSecs === "number" ? item.departureSecs : undefined;
|
|
94
|
+
const arrivalSecs = typeof item.arrivalSecs === "number" ? item.arrivalSecs : undefined;
|
|
95
|
+
const durationSecs = typeof item.durationSecs === "number" ? item.durationSecs : undefined;
|
|
96
|
+
const transferCount = typeof item.transferCount === "number" ? item.transferCount : undefined;
|
|
97
|
+
const legs = asArray(item.legs);
|
|
98
|
+
const firstLeg = asRecord(legs[0]);
|
|
99
|
+
const routeName = typeof firstLeg.routeName === "string" ? firstLeg.routeName : undefined;
|
|
100
|
+
const headsign = typeof firstLeg.headsign === "string" ? firstLeg.headsign : undefined;
|
|
101
|
+
const parts = [
|
|
102
|
+
`${index + 1}.`,
|
|
103
|
+
departureSecs !== undefined && arrivalSecs !== undefined
|
|
104
|
+
? `${formatServiceSeconds(departureSecs)} -> ${formatServiceSeconds(arrivalSecs)}`
|
|
105
|
+
: null,
|
|
106
|
+
durationSecs !== undefined ? secondsToDuration(durationSecs) : null,
|
|
107
|
+
transferCount !== undefined ? `${transferCount} transfers` : null,
|
|
108
|
+
routeName ?? null,
|
|
109
|
+
headsign ?? null,
|
|
110
|
+
].filter(Boolean);
|
|
111
|
+
return parts.join(", ");
|
|
112
|
+
});
|
|
113
|
+
const headerParts = [
|
|
114
|
+
"Transit plan",
|
|
115
|
+
typeof record.from === "object" && record.from !== null && "name" in record.from
|
|
116
|
+
? `from ${record.from.name ?? ""}`
|
|
117
|
+
: null,
|
|
118
|
+
typeof record.to === "object" && record.to !== null && "name" in record.to
|
|
119
|
+
? `to ${record.to.name ?? ""}`
|
|
120
|
+
: null,
|
|
121
|
+
typeof record.date === "string" ? record.date : null,
|
|
122
|
+
typeof record.timezone === "string" ? record.timezone : null,
|
|
123
|
+
].filter(Boolean);
|
|
124
|
+
return [headerParts.join(" | "), ...lines].join("\n");
|
|
125
|
+
}
|
|
126
|
+
function summarizePlaces(data) {
|
|
127
|
+
const record = asRecord(data);
|
|
128
|
+
const places = asArray(record.places);
|
|
129
|
+
const lines = places.slice(0, 5).map((place, index) => {
|
|
130
|
+
const item = asRecord(place);
|
|
131
|
+
const name = typeof item.name === "string" ? item.name : "unknown";
|
|
132
|
+
const kind = typeof item.kind === "string" ? item.kind : "place";
|
|
133
|
+
const endpoint = typeof item.endpoint === "string" ? item.endpoint : "";
|
|
134
|
+
const source = typeof item.source === "string" ? item.source : "";
|
|
135
|
+
return `${index + 1}. ${name} (${kind}${source ? `, ${source}` : ""}) ${endpoint ? `-> ${endpoint}` : ""}`.trim();
|
|
136
|
+
});
|
|
137
|
+
return ["Place suggestions", ...lines].join("\n");
|
|
138
|
+
}
|
|
139
|
+
function summarizeDepartures(data) {
|
|
140
|
+
const record = asRecord(data);
|
|
141
|
+
const departures = asArray(record.departures);
|
|
142
|
+
const lines = departures.slice(0, 5).map((departure, index) => {
|
|
143
|
+
const item = asRecord(departure);
|
|
144
|
+
const routeName = typeof item.routeName === "string" ? item.routeName : "unknown route";
|
|
145
|
+
const headsign = typeof item.headsign === "string" ? item.headsign : null;
|
|
146
|
+
const departureSecs = typeof item.departureSecs === "number" ? item.departureSecs : undefined;
|
|
147
|
+
return [
|
|
148
|
+
`${index + 1}.`,
|
|
149
|
+
departureSecs !== undefined ? formatServiceSeconds(departureSecs) : null,
|
|
150
|
+
routeName,
|
|
151
|
+
headsign,
|
|
152
|
+
]
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.join(", ");
|
|
155
|
+
});
|
|
156
|
+
return [
|
|
157
|
+
`Departures for ${typeof record.id === "string" ? record.id : "station"}`,
|
|
158
|
+
...lines,
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
161
|
+
function summarizeSimpleList(title, items, getLabel) {
|
|
162
|
+
const lines = items.slice(0, 5).map((item, index) => {
|
|
163
|
+
const record = asRecord(item);
|
|
164
|
+
return `${index + 1}. ${getLabel(record)}`;
|
|
165
|
+
});
|
|
166
|
+
return [title, ...lines].join("\n");
|
|
167
|
+
}
|
|
168
|
+
const planInput = z.object({
|
|
169
|
+
from: z.string().min(1),
|
|
170
|
+
to: z.string().min(1),
|
|
171
|
+
date: z.string().regex(/^\d{8}$/).optional(),
|
|
172
|
+
time: z.string().regex(/^\d{1,2}:\d{2}(:\d{2})?$/).optional(),
|
|
173
|
+
type: z.enum(["departure", "arrival", "first", "last"]).optional(),
|
|
174
|
+
maxTransfers: z.number().int().min(0).max(8).optional(),
|
|
175
|
+
numItineraries: z.number().int().min(1).max(6).optional(),
|
|
176
|
+
avoidWalk: z.enum(["true", "false"]).optional(),
|
|
177
|
+
});
|
|
178
|
+
const guidanceInput = planInput.extend({
|
|
179
|
+
strategy: z.enum(["balanced", "fastest", "fewestTransfers", "lowestFare", "shortestWalk"]).optional(),
|
|
180
|
+
live: z.enum(["true", "false"]).optional(),
|
|
181
|
+
tracking: z.enum(["none", "origin", "destination", "both"]).optional(),
|
|
182
|
+
});
|
|
183
|
+
const suggestInput = z.object({
|
|
184
|
+
q: z.string().min(1),
|
|
185
|
+
limit: z.number().int().min(1).max(30).optional(),
|
|
186
|
+
});
|
|
187
|
+
const departuresInput = z.object({
|
|
188
|
+
id: z.string().min(1),
|
|
189
|
+
date: z.string().regex(/^\d{8}$/).optional(),
|
|
190
|
+
time: z.string().regex(/^\d{1,2}:\d{2}(:\d{2})?$/).optional(),
|
|
191
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
192
|
+
});
|
|
193
|
+
const stationInput = z.object({
|
|
194
|
+
id: z.string().min(1),
|
|
195
|
+
});
|
|
196
|
+
const server = new McpServer({
|
|
197
|
+
name: "transit-mcp",
|
|
198
|
+
version: "1.0.0",
|
|
199
|
+
});
|
|
200
|
+
server.registerTool("plan_journey", {
|
|
201
|
+
title: "Plan journey",
|
|
202
|
+
description: "Find route options between two stations or geo: points.",
|
|
203
|
+
inputSchema: planInput,
|
|
204
|
+
}, async (input) => {
|
|
205
|
+
const data = await fetchTransit("/api/v1/plan", input);
|
|
206
|
+
return textResult(summarizeJourneys(data), data);
|
|
207
|
+
});
|
|
208
|
+
server.registerTool("guidance_plan", {
|
|
209
|
+
title: "Guidance plan",
|
|
210
|
+
description: "Ranked journey options with comparison-friendly guidance data.",
|
|
211
|
+
inputSchema: guidanceInput,
|
|
212
|
+
}, async (input) => {
|
|
213
|
+
const data = await fetchTransit("/api/v1/guidance/plan", input);
|
|
214
|
+
return textResult(summarizeJourneys(data), data);
|
|
215
|
+
});
|
|
216
|
+
server.registerTool("suggest_locations", {
|
|
217
|
+
title: "Suggest locations",
|
|
218
|
+
description: "Autocomplete stations and parent stations.",
|
|
219
|
+
inputSchema: suggestInput,
|
|
220
|
+
}, async (input) => {
|
|
221
|
+
const data = await fetchTransit("/api/v1/locations/suggest", input);
|
|
222
|
+
return textResult(summarizePlaces(data), data);
|
|
223
|
+
});
|
|
224
|
+
server.registerTool("suggest_places", {
|
|
225
|
+
title: "Suggest places",
|
|
226
|
+
description: "Autocomplete stations, stops, facilities, and addresses.",
|
|
227
|
+
inputSchema: suggestInput,
|
|
228
|
+
}, async (input) => {
|
|
229
|
+
const data = await fetchTransit("/api/v1/places/suggest", input);
|
|
230
|
+
return textResult(summarizePlaces(data), data);
|
|
231
|
+
});
|
|
232
|
+
server.registerTool("get_station", {
|
|
233
|
+
title: "Get station",
|
|
234
|
+
description: "Fetch station detail, platforms, and serving routes.",
|
|
235
|
+
inputSchema: stationInput,
|
|
236
|
+
}, async (input) => {
|
|
237
|
+
const data = await fetchTransit(`/api/v1/stations/${encodeURIComponent(input.id)}`);
|
|
238
|
+
const record = asRecord(data);
|
|
239
|
+
const stationName = typeof record.name === "string" ? record.name : input.id;
|
|
240
|
+
const routes = asArray(record.routes);
|
|
241
|
+
const platforms = asArray(record.platforms);
|
|
242
|
+
const summary = [
|
|
243
|
+
`Station ${stationName}`,
|
|
244
|
+
`platforms: ${platforms.length}`,
|
|
245
|
+
`routes: ${routes.length}`,
|
|
246
|
+
].join(" | ");
|
|
247
|
+
return textResult(summary, data);
|
|
248
|
+
});
|
|
249
|
+
server.registerTool("get_station_departures", {
|
|
250
|
+
title: "Get station departures",
|
|
251
|
+
description: "Fetch upcoming departures for a station.",
|
|
252
|
+
inputSchema: departuresInput,
|
|
253
|
+
}, async (input) => {
|
|
254
|
+
const data = await fetchTransit(`/api/v1/stations/${encodeURIComponent(input.id)}/departures`, {
|
|
255
|
+
date: input.date,
|
|
256
|
+
time: input.time,
|
|
257
|
+
limit: input.limit,
|
|
258
|
+
});
|
|
259
|
+
return textResult(summarizeDepartures(data), data);
|
|
260
|
+
});
|
|
261
|
+
server.registerTool("list_feeds", {
|
|
262
|
+
title: "List feeds",
|
|
263
|
+
description: "List ingested feeds with attribution and license metadata.",
|
|
264
|
+
inputSchema: z.object({}),
|
|
265
|
+
}, async () => {
|
|
266
|
+
const data = await fetchTransit("/api/v1/feeds");
|
|
267
|
+
const record = asRecord(data);
|
|
268
|
+
const feeds = asArray(record.feeds);
|
|
269
|
+
return textResult(summarizeSimpleList("Feeds", feeds, (item) => {
|
|
270
|
+
const name = typeof item.name === "string" ? item.name : "unknown";
|
|
271
|
+
const feedId = typeof item.feedId === "string" ? item.feedId : "unknown";
|
|
272
|
+
const license = typeof item.license === "string" ? item.license : "";
|
|
273
|
+
return `${name} (${feedId})${license ? ` - ${license}` : ""}`;
|
|
274
|
+
}), data);
|
|
275
|
+
});
|
|
276
|
+
server.registerTool("list_operators", {
|
|
277
|
+
title: "List operators",
|
|
278
|
+
description: "List operator branding marks and data licenses.",
|
|
279
|
+
inputSchema: z.object({}),
|
|
280
|
+
}, async () => {
|
|
281
|
+
const data = await fetchTransit("/api/v1/operators");
|
|
282
|
+
const record = asRecord(data);
|
|
283
|
+
const operators = asArray(record.operators);
|
|
284
|
+
return textResult(summarizeSimpleList("Operators", operators, (item) => {
|
|
285
|
+
const name = typeof item.name === "string" ? item.name : "unknown";
|
|
286
|
+
const operatorId = typeof item.operatorId === "string" ? item.operatorId : "";
|
|
287
|
+
const license = typeof item.license === "string" ? item.license : "";
|
|
288
|
+
return `${name}${operatorId ? ` (${operatorId})` : ""}${license ? ` - ${license}` : ""}`;
|
|
289
|
+
}), data);
|
|
290
|
+
});
|
|
291
|
+
async function main() {
|
|
292
|
+
const transport = new StdioServerTransport();
|
|
293
|
+
await server.connect(transport);
|
|
294
|
+
}
|
|
295
|
+
main().catch((error) => {
|
|
296
|
+
console.error("MCP server failed to start:", error);
|
|
297
|
+
process.exitCode = 1;
|
|
298
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tcliplab/transit-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"build",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"bin": {
|
|
14
|
+
"transit-mcp": "build/index.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"start": "node build/index.js",
|
|
19
|
+
"test:client": "node scripts/test-client.mjs"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
|
|
23
|
+
"zod": "^4.4.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@modelcontextprotocol/client": "^2.0.0-alpha.2",
|
|
27
|
+
"@types/node": "^24.0.0",
|
|
28
|
+
"typescript": "^5.9.0"
|
|
29
|
+
}
|
|
30
|
+
}
|