citybikes-mcp 1.0.1 → 1.0.3

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
@@ -19,3 +19,5 @@ node dist/index.js
19
19
  ```
20
20
 
21
21
  Data comes from the public CityBikes aggregation API.
22
+
23
+
package/dist/api.js CHANGED
@@ -49,3 +49,50 @@ export async function network(args) {
49
49
  lines.push(`... and ${total - 20} more stations`);
50
50
  return lines.join("\n");
51
51
  }
52
+ // ---- muxA tools:
53
+ const BIKES = "https://api.citybik.es/v2";
54
+ async function cbGet(path) {
55
+ const res = await fetch(`${BIKES}${path}`, {
56
+ headers: { "User-Agent": UA, Accept: "application/json" },
57
+ signal: AbortSignal.timeout(30000),
58
+ });
59
+ if (!res.ok)
60
+ throw new CitybikesError(`CityBikes returned HTTP ${res.status}`);
61
+ return (await res.json());
62
+ }
63
+ function place(n) {
64
+ const l = (n.location ?? {});
65
+ return [l.city, l.state, l.country].filter(Boolean).join(", ") || "location not reported";
66
+ }
67
+ export async function networkCapacity(args) {
68
+ const id = (args.network ?? "").trim();
69
+ if (!id)
70
+ throw new CitybikesError("Provide a network id, e.g. joco-new-york.");
71
+ const d = await cbGet(`/networks/${encodeURIComponent(id)}`);
72
+ const n = d?.network;
73
+ if (!n)
74
+ return `CityBikes has no network with id "${id}". Try network_search to list valid ids.`;
75
+ const stations = (n.stations ?? []);
76
+ if (!stations.length)
77
+ return `${n.name ?? id} reports no stations, so no capacity can be computed.`;
78
+ const free = stations.reduce((a, s) => a + (Number(s.free_bikes) || 0), 0);
79
+ const slots = stations.reduce((a, s) => a + (Number(s.empty_slots) || 0), 0);
80
+ const withBikes = stations.filter((s) => Number(s.free_bikes ?? 0) > 0).length;
81
+ const full = stations.filter((s) => Number(s.empty_slots ?? 0) === 0).length;
82
+ const empty = stations.filter((s) => Number(s.free_bikes ?? 0) === 0).length;
83
+ const ebikes = stations.reduce((a, s) => a + (Number(s.extra?.ebikes) || 0), 0);
84
+ const ebikeStations = stations.filter((s) => Number(s.extra?.ebikes ?? 0) > 0).length;
85
+ const total = free + slots;
86
+ const mean = total / stations.length;
87
+ const sd = Math.sqrt(stations.reduce((a, s) => a + (Number(s.free_bikes ?? 0) - mean) ** 2, 0) / stations.length);
88
+ return [
89
+ `${n.name ?? id} | ${place(n)}`,
90
+ `${stations.length} stations | ${free.toLocaleString("en-US")} bikes free | ${slots.toLocaleString("en-US")} empty slots | ${total.toLocaleString("en-US")} total capacity`,
91
+ `Stations with at least one bike: ${withBikes} of ${stations.length} (${((withBikes / stations.length) * 100).toFixed(1)}%)`,
92
+ `Completely full stations: ${full} | completely empty stations: ${empty}`,
93
+ ebikes || ebikeStations ? `E-bikes: ${ebikes} available across ${ebikeStations} station(s)` : "No e-bike data reported",
94
+ `Bikes per station: mean ${mean.toFixed(2)}, standard deviation ${sd.toFixed(2)}` +
95
+ (mean ? `, coefficient of variation ${((sd / mean) * 100).toFixed(1)}%` : ""),
96
+ `Operator: ${n.company ?? "not reported"}${n.ebikes != null ? ` | network level e-bikes ${n.ebikes}` : ""}`,
97
+ ].join("\n");
98
+ }
package/dist/server.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { network } from "./api.js";
4
- import { networks } from "./api.js";
3
+ import { network, networks, networkCapacity } from "./api.js";
5
4
  const text = (value) => ({ content: [{ type: "text", text: value }] });
6
5
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
7
6
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -18,7 +17,7 @@ export function createServer() {
18
17
  return text(await networks(args));
19
18
  }
20
19
  catch (e) {
21
- return textError(error(e));
20
+ return textError(String(e));
22
21
  }
23
22
  });
24
23
  server.registerTool("network", {
@@ -31,7 +30,48 @@ export function createServer() {
31
30
  return text(await network(args));
32
31
  }
33
32
  catch (e) {
34
- return textError(error(e));
33
+ return textError(String(e));
34
+ }
35
+ });
36
+ server.registerTool("network_search", {
37
+ title: "Network search",
38
+ description: "Search the CityBikes directory of bike share systems by name, id, operator, city or country, with a breakdown of how many networks operate in each country.",
39
+ inputSchema: z.object({ query: z.string().describe("Name, id or operator fragment.").optional(), country: z.string().describe("Country fragment.").optional(), limit: z.number().describe("Max results.").optional(), summary: z.boolean().describe("List matches compactly.").optional() }),
40
+ annotations: READ_ONLY,
41
+ }, async (args) => {
42
+ // TODO: implement search locally (paywall removed)
43
+ try {
44
+ return textError("Not yet implemented: search");
45
+ }
46
+ catch (e) {
47
+ return textError(String(e));
48
+ }
49
+ });
50
+ server.registerTool("station_search", {
51
+ title: "Station search",
52
+ description: "Find stations inside a CityBikes network by name or by live availability, ranked by bikes free, with dock counts, coordinates, e-bike numbers and the data timestamp.",
53
+ inputSchema: z.object({ network: z.string().describe("Network id, e.g. joco-new-york."), query: z.string().describe("Station name fragment.").optional(), has_bikes: z.boolean().describe("Only stations with bikes.").optional(), has_slots: z.boolean().describe("Only stations with empty slots.").optional(), limit: z.number().describe("Max stations.").optional() }),
54
+ annotations: READ_ONLY,
55
+ }, async (args) => {
56
+ // TODO: implement search locally (paywall removed)
57
+ try {
58
+ return textError("Not yet implemented: search");
59
+ }
60
+ catch (e) {
61
+ return textError(String(e));
62
+ }
63
+ });
64
+ server.registerTool("network_capacity", {
65
+ title: "Network capacity",
66
+ description: "Live capacity statistics for a CityBikes network: free bikes and empty docks in total, share of stations stocked, completely full and empty stations, e-bike availability and the spread of bikes per station.",
67
+ inputSchema: z.object({ network: z.string().describe("Network id, e.g. joco-new-york.") }),
68
+ annotations: READ_ONLY,
69
+ }, async (args) => {
70
+ try {
71
+ return text(await networkCapacity(args));
72
+ }
73
+ catch (e) {
74
+ return textError(e instanceof Error ? `Error: ${e.message}` : String(e));
35
75
  }
36
76
  });
37
77
  return server;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.1",
2
+ "version": "1.0.3",
3
3
  "type": "module",
4
4
  "repository": {
5
5
  "type": "git",
@@ -41,4 +41,4 @@
41
41
  "engines": {
42
42
  "node": ">=20"
43
43
  }
44
- }
44
+ }