Skip to main content
Glama

sg-mobility-mcp

A grounded Singapore travel-planning brain for your AI personal assistant.

sg-mobility-mcp is a Model Context Protocol (MCP) server that turns any MCP-compatible agent — Claude Desktop, Claude Code, LangGraph pipelines, custom Claude Agent SDK assistants, Cursor, Cline, and more — into an assistant that can actually plan a day around Singapore using live transport data, not guesses.

Plug it into your agent and say things like:

"I have the dentist at Tampines at 10, lunch in Macpherson at 12, a hospital visit at Napier Road at 2:30, and a class in Marine Parade at 4. Plan my public-transport day."

The agent chains the eight tools (geocoding → reverse geocoding → stop search → live arrivals → disruption check → carpark lookup → one-shot location context → multimodal "best route A to B" routing) and returns a real itinerary: walking + bus + MRT/LRT legs with per-leg duration, fare, transfer count, live ETAs, correct service numbers, destination terminals so it picks the right direction, and walking distances sanity-checked against actual coordinates. No more hallucinated bus 58 to "Tampines MRT" when bus 58 actually terminates at Bishan.

v0.2 additions over v0.1.0: standardized output envelope across all tools, reverse_geocode (coordinates → addresses), get_location_context (one-shot "what's near here?"), 2-transfer bus routing with cost-promising enumeration (internal, used as find_route's fallback path), find_route (multimodal public transport routing via OneMap — walking + bus + MRT/LRT legs in time- ranked itineraries), LTA 429 backoff, and cache concurrency locks. See CHANGELOG.md for the full cycle summary.

Why it exists

General-purpose LLMs are confident-but-wrong about Singapore transit: they'll invent bus numbers, get directions reversed, or miss a nearby stop entirely. This server hands the agent deterministic, live data from two official sources so the agent can reason, but not fabricate:

  • LTA DataMall — live bus arrivals, bus routes, MRT/LRT alerts, and carpark availability.

  • OneMap — Singapore government geocoding (turn a place name or address into coordinates).

The sweet spot is agentic, multi-step travel planning — where a single user request triggers a chain of tool calls across an entire day or trip. You can absolutely use it in an interactive chat too ("when's the next bus at VivoCity?"), but the real leverage is letting a personal assistant orchestrate it end to end on your behalf.


Table of contents

  1. At a glance

  2. How it works in Claude Desktop

  3. Prerequisites

  4. Getting your API keys

  5. Installation

  6. Configuration

  7. Running standalone

  8. Connecting to Claude Desktop

  9. Using from other MCP clients

  10. Tool reference

  11. Use cases

  12. Troubleshooting

  13. Architecture

  14. Limitations

  15. Security

  16. Project layout

  17. Updating and maintenance

  18. License


Related MCP server: Singapore Location Intelligence MCP

At a glance

Language

Python 3.10+

MCP SDK

mcp[cli]

HTTP client

httpx (async)

Transport

stdio (default for Claude Desktop)

Data sources

LTA DataMall, OneMap, bundled MRT/LRT station catalog

Runtime deps

4 packages (mcp[cli], httpx, pydantic, python-dotenv)

Eight tools registered:

Tool

What it does

resolve_location

Geocode a place name / address / landmark → coordinates

reverse_geocode

Coordinates → up to 3 nearby building names and full addresses

search_bus_stops

Find stops by name, road, or proximity to coordinates

get_bus_arrivals

Live bus ETAs, load, type, accessibility, destination terminal

get_train_alerts

MRT/LRT service disruptions, optionally filtered by line

get_carpark_availability

Real-time carpark lots across HDB, URA, LTA

get_location_context

One-shot summary of nearby bus stops, carparks, MRT/LRT stations, and line status for a coordinate

find_route

Multimodal "best route A → B" routing via OneMap PT. Returns up to 3 time-ranked itineraries mixing walking, bus, and MRT/LRT, with per-leg duration, fare, transfer count. Falls back to bus-only (2-transfer search) if OneMap is unavailable.

2-transfer bus routing still exists as find_bus_route_impl inside tools/routing.py, but it is no longer a registered MCP tool — it is reachable only as find_route's fallback path per FR-7.4.


How it works in Claude Desktop

You ask Claude a question in natural language. If the question touches Singapore transport, Claude picks the right tool(s) and chains them:

You:    When's the next bus 10 at VivoCity?

Claude  → resolve_location("VivoCity")
         ← VIVO CITY, 1 HarbourFront Walk, 1.26420, 103.82220
        → search_bus_stops(latitude=1.2642, longitude=103.8222)
         ← Top stops: 14131 (VivoCity), 14141 (Opp VivoCity), ...
        → get_bus_arrivals(bus_stop_code="14131", service_no="10")
         ← Service 10 (SBST) → Tanah Merah Int
             Next : 4 min (GPS)  — Seats available · Double deck ♿
             2nd  : 11 min (GPS) — Standing · Single deck

The server does the fetching and formatting; Claude does the conversation and reasoning.


Prerequisites

You need all five:

  1. Python 3.10 or later (check with python3 --version)

  2. pip (bundled with Python) or uv

  3. Claude Desktop installed (claude.ai/download)

  4. LTA DataMall AccountKey — free, takes 1–2 business days (see below)

  5. OneMap account — free, immediate (see below)


Getting your API keys

LTA DataMall (1–2 business days)

  1. Go to the registration page: https://datamall.lta.gov.sg/content/datamall/en/request-for-api.html

  2. Fill in the form (name, email, organisation, intended use). "Personal project" is an acceptable purpose.

  3. Wait for an approval email from LTA — usually within 1–2 business days.

  4. The email contains your AccountKey (a ~32-character string). Copy it.

  5. Treat it like a password. Never share it or commit it to git.

OneMap (immediate)

  1. Go to the OneMap developer portal: https://www.onemap.gov.sg/apidocs/

  2. Click Register. Provide an email and choose a password.

  3. Verify your email.

  4. Your email + password are your credentials. This server generates and automatically refreshes the JWT access token in the background — you do not manage tokens manually.

You do not need a OneMap API key separate from your login. The server authenticates with email + password via OneMap's /auth/post/getToken endpoint and caches the token until near expiry.


Installation

git clone https://github.com/jimmytbc/sg-mobility-mcp.git
cd sg-mobility-mcp

# Create and activate a virtual environment
python3 -m venv .venv
source .venv/bin/activate            # Windows: .venv\Scripts\activate

# Install dependencies
pip install -r requirements.txt

Configuration

Copy the environment template and fill in your three credentials:

cp .env.example .env

Open .env in your editor and replace the placeholders:

LTA_ACCOUNT_KEY=your_actual_lta_key_here
ONEMAP_EMAIL=your_onemap_email@example.com
ONEMAP_PASSWORD=your_onemap_password

.env is gitignored by default. Never remove the gitignore rule or commit this file.

Alternative: inject via Claude Desktop's env block

If you prefer not to keep a .env file at all, skip the step above and supply the three variables directly in the Claude Desktop config env block (covered in Connecting to Claude Desktop).


Running standalone

Before wiring into Claude Desktop, verify the server starts cleanly:

python server.py

Expected output on stderr:

sg-mobility-mcp: starting on stdio transport

The process then waits for MCP stdio input — this is normal. Press Ctrl-C to exit. If any env var is missing, you'll see a clear error listing exactly which variables are unset:

RuntimeError: Missing required environment variables: ONEMAP_PASSWORD.
See .env.example and README.md for setup.

Connecting to Claude Desktop

1. Find your config file

OS

Path

macOS

~/Library/Application Support/Claude/claude_desktop_config.json

Windows

%APPDATA%\Claude\claude_desktop_config.json

If the file doesn't exist, create it with {"mcpServers": {}}.

2. Add this entry under mcpServers

Replace <path/to/sg-mobility-mcp> with the absolute path to your clone.

{
  "mcpServers": {
    "sg-mobility-mcp": {
      "command": "<path/to/sg-mobility-mcp>/.venv/bin/python",
      "args": ["<path/to/sg-mobility-mcp>/server.py"]
    }
  }
}

On Windows, use \\ separators and .venv\\Scripts\\python.exe.

If you have other MCP servers, merge the sg-mobility-mcp entry into your existing mcpServers object — do not overwrite.

3. Point at the venv Python

Using the venv's Python (.venv/bin/python) ensures Claude Desktop finds the installed mcp and httpx packages. Using your system python will almost certainly fail.

4. Optionally pass credentials via env

If you're skipping .env, add an env block:

"sg-mobility-mcp": {
  "command": "<path>/.venv/bin/python",
  "args": ["<path>/server.py"],
  "env": {
    "LTA_ACCOUNT_KEY": "your_key",
    "ONEMAP_EMAIL": "you@example.com",
    "ONEMAP_PASSWORD": "your_password"
  }
}

Values in env take precedence over .env file values.

5. Restart Claude Desktop

Fully quit (⌘Q on macOS, or right-click tray icon → Quit on Windows), then reopen. MCP server tool schemas are cached at startup — just closing the window does not reload them.

6. Verify

In a new chat, ask:

Are there any MRT disruptions right now?

Claude should call get_train_alerts and reply with the live status. If nothing fires, see Troubleshooting.


Using from other MCP clients

The server speaks stdio MCP — the same protocol Claude Desktop uses — so any MCP-compatible client can drive it. Three common ways below.

From Claude Code (CLI)

Claude Code is Anthropic's CLI agent. Register the server once, then every claude session in the project (or across projects, depending on scope) has all eight tools available.

Option A — .mcp.json at your project root (shareable with a team, safe to commit as long as you leave the env values as placeholders):

{
  "mcpServers": {
    "sg-mobility-mcp": {
      "type": "stdio",
      "command": "<path/to/sg-mobility-mcp>/.venv/bin/python",
      "args": ["<path/to/sg-mobility-mcp>/server.py"],
      "env": {
        "LTA_ACCOUNT_KEY": "your_key",
        "ONEMAP_EMAIL": "you@example.com",
        "ONEMAP_PASSWORD": "your_password"
      }
    }
  }
}

Option B — register via CLI (scoped to the current project):

claude mcp add \
  --transport stdio \
  --scope project \
  --env LTA_ACCOUNT_KEY=your_key \
  --env ONEMAP_EMAIL=you@example.com \
  --env ONEMAP_PASSWORD=your_password \
  sg-mobility-mcp \
  -- <path/to/sg-mobility-mcp>/.venv/bin/python <path/to/sg-mobility-mcp>/server.py

Scope options:

  • --scope local (default) — you only, current project, stored in ~/.claude.json

  • --scope project — shared via .mcp.json (checked into git)

  • --scope user — you only, all projects

Verifying and using tools in a session:

claude       # start a session in the project
> /mcp       # list registered MCP servers and their status
> Are there any MRT disruptions right now?

Claude Code discovers the tools automatically — you don't need to name them in the prompt. If it doesn't fire a tool, see Troubleshooting.

From LangGraph (Python)

Via langchain-mcp-adapters, which bridges stdio MCP servers into LangChain-compatible tools.

Install:

pip install "langchain-mcp-adapters>=0.2" langgraph "langchain[openai]"

Minimal example (run_agent.py):

import asyncio
import os

from langchain.chat_models import init_chat_model
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent

SERVER_PY = "/absolute/path/to/sg-mobility-mcp/server.py"
VENV_PY   = "/absolute/path/to/sg-mobility-mcp/.venv/bin/python"


async def main():
    client = MultiServerMCPClient({
        "sg_mobility_mcp": {
            "transport": "stdio",
            "command": VENV_PY,
            "args": [SERVER_PY],
            "env": {
                "LTA_ACCOUNT_KEY": os.environ["LTA_ACCOUNT_KEY"],
                "ONEMAP_EMAIL":    os.environ["ONEMAP_EMAIL"],
                "ONEMAP_PASSWORD": os.environ["ONEMAP_PASSWORD"],
            },
        }
    })

    tools = await client.get_tools()

    # Swap for whatever provider you have keys for, e.g.
    # "anthropic:claude-sonnet-4-5" (requires `pip install "langchain[anthropic]"`)
    model = init_chat_model("openai:gpt-4.1")

    agent = create_react_agent(model, tools)

    result = await agent.ainvoke(
        {"messages": "Are there any MRT disruptions in Singapore right now?"}
    )
    print(result["messages"][-1].content)


if __name__ == "__main__":
    asyncio.run(main())

Run it with your chosen LLM provider's key plus the three sg-mobility-mcp env vars exported in the shell, or loaded from a .env.

If get_tools() behaves as if env vars aren't reaching the subprocess, fall back to exporting them in the parent shell before MultiServerMCPClient(...) is constructed — they'll be inherited.

From the Claude Agent SDK (Python)

Anthropic's Claude Agent SDK has first-class support for stdio MCP servers via ClaudeAgentOptions.mcp_servers.

Install:

pip install claude-agent-sdk
export ANTHROPIC_API_KEY=sk-ant-...

Minimal example (run_agent_sdk.py):

import os

import anyio
from claude_agent_sdk import (
    AssistantMessage,
    ClaudeAgentOptions,
    ClaudeSDKClient,
    TextBlock,
)

SERVER_PY = "/absolute/path/to/sg-mobility-mcp/server.py"
VENV_PY   = "/absolute/path/to/sg-mobility-mcp/.venv/bin/python"

options = ClaudeAgentOptions(
    model="opus",  # alias — resolves to the latest Opus. Or pass a full ID.
    mcp_servers={
        "sg_mobility_mcp": {
            "type": "stdio",
            "command": VENV_PY,
            "args": [SERVER_PY],
            "env": {
                "LTA_ACCOUNT_KEY": os.environ["LTA_ACCOUNT_KEY"],
                "ONEMAP_EMAIL":    os.environ["ONEMAP_EMAIL"],
                "ONEMAP_PASSWORD": os.environ["ONEMAP_PASSWORD"],
            },
        }
    },
    # Auto-approve every tool from this server (prefix = "mcp__<server_key>")
    allowed_tools=["mcp__sg_mobility_mcp"],
)


async def main():
    async with ClaudeSDKClient(options=options) as client:
        await client.query(
            "Are there any MRT disruptions in Singapore right now?"
        )
        async for msg in client.receive_response():
            if isinstance(msg, AssistantMessage):
                for block in msg.content:
                    if isinstance(block, TextBlock):
                        print(block.text)


anyio.run(main)

The async with block manages the subprocess — the stdio MCP server is spawned on entry and terminated cleanly on exit.

Model IDs: the SDK accepts short aliases ("opus", "sonnet", "haiku") or full published IDs like "claude-opus-4-5". If you want a specific version, check docs.claude.com for the current published ID before hardcoding it.

Auth: standard ANTHROPIC_API_KEY env var. AWS Bedrock and GCP Vertex credentials are also honored if configured at the system level.

For the one-shot, non-interactive case, the SDK also exposes a simpler query(prompt, options=...) function that returns an async iterator of messages — see the SDK docs for details.

Any other MCP client

Cursor, Continue, Cline, Zed AI, and other MCP hosts all accept the same shape:

Field

Value

transport

stdio

command

<path>/.venv/bin/python

args

["<path>/server.py"]

env

LTA_ACCOUNT_KEY, ONEMAP_EMAIL, ONEMAP_PASSWORD

Consult each client's MCP-config docs for the exact file location and schema — the values are identical.


Tool reference

Each tool returns a pre-formatted string optimised for LLM consumption.

resolve_location(query: str) -> str

Geocode a Singapore place name, address, or landmark via OneMap.

Example prompt to Claude: "Where is Gleneagles Hospital?"

Returns up to 3 matching results with building name, full address, and coordinates. If nothing matches, returns a clear "no results" message.


reverse_geocode(latitude: float, longitude: float) -> str

The inverse of resolve_location: given coordinates (e.g. from a map pin, a GPS fix, or another tool's output), return nearby named buildings and addresses.

Example prompts:

  • "What's at 1.39142, 103.89515?"

  • "I dropped a pin at these coordinates — what's around it?"

Returns up to 3 nearby addresses with building name (where present), full address, and postal code. Coordinates outside Singapore are rejected with ERR_COORDINATES_OUT_OF_BOUNDS.


search_bus_stops(query, latitude, longitude, radius_m=500, limit=10) -> str

Find bus stops by text match or by proximity to coordinates. Geo mode takes precedence if both are provided.

Example prompts:

  • "Find bus stops near VivoCity" (Claude resolves VivoCity first, then calls this)

  • "Bus stops on Bedok North Road" (text mode)

Returns stop code, description, road name, and — in geo mode — walking distance in metres, sorted ascending.


get_bus_arrivals(bus_stop_code: str, service_no: str | None) -> str

Live arrival times at a specific stop. Returns up to 3 buses per service with ETA, GPS/scheduled indicator, load, bus type, wheelchair access, and destination terminal (the end stop of the route).

Example prompts:

  • "When's the next bus at stop 14131?"

  • "What time is the next 10 bus at Vivocity?"


find_bus_route — deregistered in Phase 5

As of v0.2-phase-5 the standalone bus-only routing tool is no longer registered on the MCP surface. The underlying 2-transfer search (find_bus_route_impl) is retained in tools/routing.py and is invoked automatically as find_route's fallback path whenever OneMap PT routing is unavailable (5xx, rate-limit exhaustion, or a pair with no public transport route).

Agents that previously called find_bus_route directly should call find_route instead — it returns multimodal itineraries that include bus legs wherever OneMap ranks them competitively, and it falls back to bus-only output when needed.

The legacy documentation below describes the internal function's behaviour for completeness.

Internal: find_bus_route_impl(from_latitude, from_longitude, to_latitude, to_longitude, max_walk_m=600, max_transfer_walk_m=200, max_total_min=120, limit=3) -> str

Given origin and destination coordinates, this finds the best bus journeys — direct, 1-transfer, or 2-transfer — scored server-side by total time. It evaluates every candidate origin and destination stop within max_walk_m, looks for:

  • Direct services that serve both origin and destination stops in the correct direction.

  • 1-transfer service pairs that share an interchange stop reachable within max_transfer_walk_m.

  • 2-transfer service triples (bus A → walk → bus B → walk → bus C), where each transfer walk is within max_transfer_walk_m. Added in v0.2 for cross-island pairs where no good direct or single-transfer option exists.

For each candidate it fetches live ETA at the origin, estimates in-vehicle and transfer times, and ranks the top limit options by total walk + wait + ride time.

Example prompts:

  • "What's the best bus from Bedok Mall to Gleneagles Hospital?" (likely 1-transfer)

  • "How do I get from Sengkang to Tuas Link by bus?" (likely 2-transfer — cross-island)

Returns ranked options with: origin stop code + walk distance, live ETA, ride stop count, alight stop (for transfers, board stop + transfer walk), terminus for each leg, and estimated total journey time. 2-transfer options use a distinct OPTION N — 2-TRANSFER — M min total header to signal the leg count; direct and 1-transfer keep the original {i}. Bus X → ... header shape for backward compatibility.

Bounds:

  • Direct + 1-transfer options are capped at 90 min total time regardless of max_total_min (preserving v0.1.0 behaviour).

  • 2-transfer options use max_total_min (default 120 min, raised in v0.2 from 90 to accommodate the extra transfer wait). The caller can override; passing a tighter max_total_min applies to all kinds.

  • 2-transfer enumeration is capped at 500 candidate triples per call to prevent combinatorial blow-up. If hit, the response appends Note: 2-transfer evaluation truncated at 500 candidates; results are best-found-so-far. — only when a 2-transfer option actually surfaced in the ranked output. Enumeration is ordered by cost-promising heuristics (shortest middle-leg ride first, shortest origin/destination walk first) so early termination still yields good results.

If no direct, 1-transfer, or 2-transfer option is found within the constraints, the tool returns ERR_NO_BUS_ROUTE — fall back to MRT or loosen the walk radii.

Honest limits: transfer wait time is a fixed 10-minute assumption per transfer point (we don't have scheduled intervals). In-vehicle time is a flat ~1.8 min/stop estimate. Totals are flagged as estimates in the output. No MRT routing — see Limitations.


get_train_alerts(line: str | None) -> str

Current MRT/LRT disruption alerts. If all lines are operating normally, returns that as a single clean line. Filter by line code: NSL, EWL, CCL, DTL, TEL, NEL, BPLRT, SKLRT, PGLRT.

Example prompts:

  • "Are there any MRT disruptions?"

  • "Any issues on the East-West Line?"


get_carpark_availability(area, latitude, longitude, radius_m=500, lot_type="C", min_lots=0) -> str

Live carpark availability across HDB, URA, and LTA carparks. Text search (on area / development) or geo search. lot_type: C (car, default), Y (motorcycle), H (heavy vehicle).

Example prompts:

  • "Find parking near Marina Bay Sands with at least 100 spots"

  • "Motorcycle parking near Suntec City"

Results are capped at 20 rows and sorted by distance (geo mode) or lot count descending (text mode).


get_location_context(latitude: float, longitude: float, radius_m=500) -> str

One-shot "what's near here?" Given Singapore coordinates and a search radius, returns an aggregated snapshot of nearby transport infrastructure so your agent doesn't have to chain four tool calls.

For the given radius the tool returns, in a single response:

  • Up to 5 nearest bus stops (code, name, road, walking distance).

  • Up to 5 nearest carparks with at least 1 available car lot (name, lots free, distance).

  • Up to 3 nearest MRT/LRT stations with their codes, lines, and distance — sourced from a bundled catalog of all 181 operational stations across the 9 rail lines (NSL, EWL, CCL, DTL, TEL, NEL, BPLRT, SKLRT, PGLRT).

  • Current alert status for each line among those nearby stations, using the same LTA feed get_train_alerts reads.

radius_m silently clamps at 5000m (5 km) to keep the scan bounded. If nothing is within radius, you get a clean "No transport infrastructure within m …" message — not an error, not an empty body.

Example prompts:

  • "What's near Sengkang MRT?" (the agent resolves Sengkang first, then calls this)

  • "I'm at 1.29349, 103.85583 — what are my travel options?"

  • "What's around Marina Bay Sands?"

Graceful degradation: if the MRT alerts feed is slow or unreachable, the LINE STATUS section degrades to a single unavailable line; the rest of the response still renders. The tool never fails outright because of alerts.

When to use vs. chaining: prefer get_location_context over the manual chain of search_bus_stops + get_carpark_availability + get_train_alerts when the user's question is "what's near X?". It's one call, already scoped to the radius, and already joined against line alerts for you.


find_route(from_latitude, from_longitude, to_latitude, to_longitude, origin=None, destination=None) -> str

Multimodal "best route from A to B" routing. Thin orchestrator over OneMap's Public Transport routing endpoint. Returns up to 3 time-ranked itineraries mixing walking, bus, and MRT/LRT legs in a single call.

Each itinerary includes:

  • Total duration, total fare (SGD, sourced from OneMap), and transfer count.

  • Per-leg detail: mode (WALK / BUS / SUBWAY), duration, and the from-stop → to-stop pair. WALK legs include the metric distance; BUS legs show the service number (e.g., 199); SUBWAY legs show the line code (e.g., NE for North East Line).

  • Lean intermediate-stop summary on transit legs: count plus the first and last intermediate stop names. The full stop list is intentionally omitted — look up any specific stop via get_bus_arrivals if needed.

Ranking: itineraries are returned in the order OneMap emits them (already sorted by estimated total time). The summary line names the fastest duration and the cheapest fare across the returned set.

Fallback path: when OneMap returns 5xx (service down), 429 after exhausting the retry budget, or zero itineraries (no PT route exists), find_route falls back to the internal 2-transfer bus search (find_bus_route_impl) and wraps the bus-only result with a Note: footer explaining which routing condition triggered the fallback. If the fallback also fails, the tool returns a terminal ERR_* prefix.

Place-name handling: pass origin and destination strings to have the envelope header display the user's place names verbatim. When omitted, the header falls back to the formatted coordinate pair.

Example prompts:

  • "Best route from Compass One to Outram Park?"

  • "How do I get from Tampines to Macpherson?"

  • "What's the fastest way from NTU to Raffles Place?"

When to use vs. chaining: this is the recommended starting point for "best route from A to B" questions — no need to chain bus or train tools. get_train_alerts is a deliberate separate call; find_route never calls it so the agent must query alerts separately if needed (live arrivals on a specific bus stop remain get_bus_arrivals's job).


Use cases

Patterns that work well in practice:

1. "When's my bus?"

When's the next bus 15 at Bedok MRT?

Claude calls resolve_locationsearch_bus_stops (geo) → get_bus_arrivals(service_no="15"). Three tool calls, one clean answer.

2. Best route between two landmarks

What's the fastest route from Compass One to Outram Park?

Claude calls resolve_location twice then find_route. The tool calls OneMap's multimodal PT routing and returns up to 3 time-ranked itineraries mixing walking, bus, and MRT/LRT legs — per-leg duration, fare, transfer count, all in a single response.

This is the entry point for "best route A → B" queries. Chaining is no longer necessary: find_route covers bus + MRT/LRT + walking in one call.

When OneMap is unavailable (5xx, rate-limit exhaustion) or no PT route exists between the endpoints, find_route automatically falls back to bus-only search (direct / 1-transfer / 2-transfer via the internal find_bus_route_impl) and surfaces a Note: footer naming the condition. Cross-island pairs like Sengkang → Tuas Link that previously relied on v0.2-phase-3's 2-transfer search still work — OneMap usually finds a rail-inclusive itinerary; the 2-transfer bus-only fallback remains available when needed.

3. Find parking near a destination

I'm driving to Marina Bay Sands. Where can I park with at least 80 spots?

Claude calls resolve_locationget_carpark_availability(latitude=..., longitude=..., min_lots=80). You get a live lots count per carpark with distances.

4. Full-day itinerary — the main event

This is where the server earns its keep. Feed your assistant a list of appointments and let it plan the whole day:

I have the dentist at Tampines Mall at 10, lunch at 68 Circuit Road at 12, Gleneagles Hospital at 2:30, class at Marine Parade at 4, then home to Tampines in the evening. Plan the public-transport route, optimised for time.

Behind the scenes your agent will:

  1. resolve_location each venue (5–6 calls) to get coordinates.

  2. find_route for each leg — OneMap PT returns multimodal itineraries (walk + bus + MRT/LRT), already time-ranked, with per-leg duration, fare, and transfer count. No per-mode chaining.

  3. Optionally get_train_alerts to check for disruptions before committing to an MRT-heavy itinerary.

  4. Assemble a timed plan with leave-by times and buffers.

This is the workload this server was built for.

5. Check disruptions before heading out

Are any trains delayed right now?

Single get_train_alerts call. If everything is normal you get a one-line confirmation; if anything is disrupted you get affected lines, stations, messages, and any free-bus bridging service.

6. "What's near X?" in one call

What's near Sengkang MRT?

The agent calls resolve_location("Sengkang MRT")get_location_context(lat, lng). Two calls, one complete answer: nearest bus stops, carparks with free lots, MRT/LRT stations, and current alert status for those lines — all joined and scoped to a single radius. The alternative (chaining search_bus_stops + get_carpark_availability + get_train_alerts yourself) is four tool calls and no cross-joining.


Troubleshooting

RuntimeError: Missing required environment variables: ...

Your env vars aren't reaching the process. Check:

  • .env exists in the project root (not in a parent directory).

  • You haven't typo'd the variable names.

  • For Claude Desktop: the env block is inside the correct server entry, and Claude Desktop has been fully restarted (⌘Q, not close window).

  • For local runs: echo $LTA_ACCOUNT_KEY in the same shell returns your key.

OneMap auth failed (401)

The email or password is wrong, or your OneMap account isn't active. Log into https://www.onemap.gov.sg/apidocs/ in a browser to verify.

LTA /BusStops returned 401/403

Your LTA AccountKey is invalid or still pending approval. Check your email for the approval message — LTA issues keys within 1–2 business days.

The first search_bus_stops or find_route fallback call is slow

Expected. The server lazy-loads ~5,200 bus stops (and ~26,700 bus route rows the first time the bus-only fallback path is triggered) from LTA on first use, then caches them in memory for 24 hours. Subsequent calls are instant. The primary find_route path (OneMap PT) doesn't depend on these caches — only the bus-only fallback does.

Claude doesn't call my tools

  • Check ~/Library/Logs/Claude/mcp-server-sg-mobility-mcp.log (macOS) for startup errors.

  • Verify Claude Desktop sees the server: in a new chat, ask "What tools do you have from sg-mobility-mcp?" — the agent should list all eight.

  • If Claude answers without calling any tool (e.g. "I don't have real-time transport data"), ask more directly: "Please call the get_train_alerts tool."

Tool returns "No buses currently arriving at stop X"

Real data. Outside operating hours, or the stop code is wrong. Use search_bus_stops to verify the code.


Architecture

Small, flat, explicit — no framework, no database, no background workers.

  • Transport: stdio. Claude Desktop spawns the server as a child process and communicates over stdin/stdout using the MCP protocol. All logs go to stderr to avoid corrupting the protocol stream.

  • Clients: one long-lived httpx.AsyncClient per API (LTA, OneMap). LTA requests inject the AccountKey header once, at client creation. OneMap requests attach a Bearer token per call.

  • OneMap auth: the access_token is a JWT. The server decodes the exp claim on receipt and refreshes 5 minutes before expiry, guarded by an asyncio.Lock so concurrent tool calls don't double-fetch.

  • Caches (cache.py):

    • bus_stops list — warmed lazily on first search_bus_stops, get_bus_arrivals, or get_location_context call. Used for code → name resolution and proximity search. 24h TTL.

    • routes_by_service / routes_by_stop — warmed lazily on the first invocation of find_bus_route_impl (reachable only via find_route's bus-only fallback path since Phase 5). Indexed two ways so both "what services pass this stop?" and "what's the stop sequence on this route?" are O(1). 24h TTL.

  • Static MRT/LRT catalog (data/mrt_stations.json): a hand-curated JSON array of all 181 operational stations across the 9 rail lines, loaded once at server import and held in memory. Enables get_location_context (and future tools) to resolve "nearby stations" without calling any upstream API. See data/README.md for the schema and the quarterly-review process.

  • Fail-fast: server.py validates all three env vars and the MRT station catalog at import time. Missing env vars or a malformed / missing data/mrt_stations.json produce a clear error pointing back at this README, rather than a cryptic failure on first tool call.

  • Tool registration: each tools/*.py file exports a register_X_tools(mcp, *deps) function. server.py calls each explicitly — no wildcard imports, no import-order fragility.


Limitations

Be honest with users about what this server does not do:

  1. find_route is schedule-based, not live. OneMap's PT routing returns itineraries computed against the published schedule; it does not consult LTA's live bus arrivals. Per-leg durations can differ from what the user actually experiences (especially during bus-service disruption or peak-hour traffic). Live ETAs remain available via get_bus_arrivals for any specific stop.

  2. Bus-only fallback is bounded. When OneMap is unavailable, find_route falls back to the internal 2-transfer bus search (find_bus_route_impl). Three-or-more-transfer journeys are not planned on the fallback path; if the best bus-only route needs more hops, the tool returns no route and surfaces the appropriate terminal error string.

  3. In-vehicle time, walking speed, and transfer wait on the fallback path are estimates. find_bus_route_impl uses a flat ~1.8 minutes per stop, 80 m/min walking, and a fixed 10-minute wait at any transfer point (we don't have scheduled intervals for bus- only fallback output). Real bus rides vary with traffic, express-vs-local service patterns, and time of day. OneMap-sourced itineraries on the primary path use the published schedule and are not subject to these estimates.

  4. No walking-directions planner. find_route surfaces WALK legs (distance and duration) as part of itineraries, but does not compute turn-by-turn walking directions.

  5. Carpark data comes from LTA's feed — not every carpark in Singapore is included (e.g. some private ones).

  6. Singapore only. The data sources are Singapore-specific; this server is not useful outside SG.


Security

  • This server is designed for single-tenant local use. Do not host this server on a shared multi-tenant endpoint without first adding authentication, quota, and audit — none of which v0.2 provides.

  • .env is in .gitignore. So are .env.* (except .env.example), *.pem, *.key, *.p12, and .DS_Store. Do not remove these rules.

  • The server never logs credentials. Stderr output is limited to cache warm notifications and HTTP request info (URLs only, not headers).

  • If a key leaks (e.g. accidentally committed):

    • LTA: log into the DataMall portal and request a key rotation.

    • OneMap: change your password at onemap.gov.sg immediately.

    • Rewrite git history with git filter-repo or similar, and force-push only if the repo is yours alone.

  • Before pushing to GitHub, always run git status.env should never appear in the tracked list.


Project layout

sg-mobility-mcp/
├── server.py              ← entry point + tool registration + env fail-fast + MRT catalog load
├── api/
│   ├── __init__.py
│   ├── lta.py             ← LTA DataMall client (paginated GET + typed methods)
│   └── onemap.py          ← OneMap client with JWT auto-refresh + lock
├── tools/
│   ├── __init__.py
│   ├── _format.py         ← shared envelope / error-string helpers
│   ├── _pt_routing.py     ← OneMap PT response parser + A1-envelope formatter
│   ├── bus.py             ← search_bus_stops, get_bus_arrivals
│   ├── train.py           ← get_train_alerts
│   ├── carpark.py         ← get_carpark_availability
│   ├── location.py        ← resolve_location, reverse_geocode
│   ├── routing.py         ← find_bus_route_impl (internal, fallback path only)
│   ├── context.py         ← get_location_context (aggregation)
│   └── discovery.py       ← find_route (OneMap PT orchestrator)
├── data/
│   ├── mrt_stations.json  ← hand-curated MRT/LRT station catalog (loaded at startup)
│   └── README.md          ← schema, update sources, quarterly-review process
├── cache.py               ← lazy-warmed bus stops + bus routes (24h TTL each)
├── requirements.txt
├── .env.example           ← template with placeholders (safe to commit)
├── .gitignore
└── README.md

Updating and maintenance

  • Cache refresh. Both caches have a 24-hour TTL. Restarting the server forces a full re-warm on next use. There is no manual cache-invalidation tool by design — LTA data is stable enough that 24h works.

  • LTA changes an endpoint path. If an LTA endpoint moves (e.g. /CarParkAvailabilityv2v3), the affected tool will return a clear "LTA <path> returned 404: ..." error. Update the path in api/lta.py and restart.

  • OneMap changes auth. If OneMap rotates their token scheme, update api/onemap.py. The JWT expiry-parsing logic is the most coupled to their current format.

  • Adding a new tool. Create a new file under tools/ with a register_*_tools(mcp, ...) function. Register it in server.py. Follow the existing pattern: return a formatted string via the helpers in tools/_format.py, catch upstream exceptions and convert to a user-facing ERR_* message, do not let raw exceptions reach the MCP layer.

  • MRT/LRT station catalog. data/mrt_stations.json is a static, hand-curated file loaded once at startup. When a new station opens (roughly every 18–24 months — e.g. TEL5, JRL, CRL extensions):

    1. Add the entry to data/mrt_stations.json. Schema and sources are documented in data/README.md.

    2. Commit the change and restart the server. The file is only read at import time; a running server will not pick up new stations.

    There is no automated refresh pipeline by design — the static-file approach keeps startup network-independent and the catalog audit-friendly.


License

MIT © 2026 Jimmy Tong.

A
license - permissive license
-
quality - not tested
A
maintenance

Maintenance

Maintainers
Response time
Release cycle
1Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jimmytbc/sg-mobility-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server