Teaching an LLM to ride the bus (Coruña edition)

Teaching an LLM to ride the bus (Coruña edition)

Table of Contents

I’ve been playing around with the Model Context Protocol (MCP) for a few weeks now. There’s already a lot of material out there, but I wanted to try it with a simple, real-world case: the buses in A Coruña.

While learning, I was thinking of a small project to tinker with, and the classic “when is my bus arriving?” came to mind. It’s not the most revolutionary idea, but it’s perfect for experimenting with a real-time data system that a LLM can use. And of course, why not integrate Bus Coruña directly into an MCP environment so a LLM can query it?

In this example, the model is asked:

“When is the next bus leaving from Abente y Lago?”

And it returns the arrival times directly from the Bus Coruña backend.

Conversation in Claude

Screenshot from Claude Desktop app

In this example, the model is asked:

“I’m at Riazor, how do I get to San Andrés?”

What’s most interesting is that I originally set it up just to provide data for a single stop, but the AI is able to generate full itineraries without me having programmed that behavior — it automatically finds the origin and destination, invoking the tools multiple times to calculate the route.

Conversation in Claude

Screenshot from Claude Desktop app

Technical details

  • The MCP server is built in Python, using fastmcp.
from fastmcp import FastMCP, Context
import os
import json
import httpx
import difflib
import copy
import time

# Create an MCP server
mcp = FastMCP("bus-finder")

@mcp.tool()
def get_bus_timetable(stop: int) -> dict:
    """Get a bus timetable for a given stop number"""
    return {}

@mcp.tool()
def get_stop_code_by_location(location: str) -> dict:
    """Return stop code(s) given a location by searching all JSON files in the stops directory. Uses fuzzy matching for similar names."""
    return {}

if __name__ == "__main__":
    print("Starting MCP server...")
    mcp.run()
  • Tool to get stop code get_stop_code_by_location.
@mcp.tool()
def get_stop_code_by_location(location: str) -> dict:
    """Return stop code(s) given a location by searching all JSON files in the stops directory. Uses fuzzy matching for similar names."""
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    stops_dir = os.path.join(BASE_DIR, "stops")
    results = []
    threshold = 0.7  # Similarity threshold for fuzzy matching
    location_lower = location.lower()
    for filename in os.listdir(stops_dir):
        if filename.endswith('.json'):
            filepath = os.path.join(stops_dir, filename)
            try:
                with open(filepath, 'r') as f:
                    data = json.load(f)
                for direction in data.get('directions', []):
                    for stop in direction.get('stops', []):
                        stop_name_lower = stop['name'].lower()
                        # Substring match (legacy behavior)
                        if location_lower in stop_name_lower:
                            results.append({
                                'code': stop['code'],
                                'name': stop['name'],
                                'file': filename,
                                'match_type': 'substring',
                                'similarity': 1.0
                            })
                        else:
                            # Fuzzy match
                            similarity = difflib.SequenceMatcher(None, location_lower, stop_name_lower).ratio()
                            if similarity >= threshold:
                                results.append({
                                    'code': stop['code'],
                                    'name': stop['name'],
                                    'file': filename,
                                    'match_type': 'fuzzy',
                                    'similarity': similarity
                                })
            except Exception as e:
                continue
    if not results:
        return {"error": f"No stop found for location: {location}"}
    # Optionally, sort by similarity (descending)
    results.sort(key=lambda x: x['similarity'], reverse=True)
    return {"matches": results}
  • Tool to get arrival times get_bus_timetable.
@mcp.tool()
def get_bus_timetable(stop: int) -> dict:
    """Get a bus timetable for a given stop number"""
    try:
        response = httpx.get(
            f"https://busapi/dato={stop}&func=0&_={int(time.time() * 1000)}",
            timeout=60
        )
        response.raise_for_status()
        try:
            timetable = response.json()
        except Exception as e:
            timetable = {"error": f"Error parsing JSON: {e}", "raw": response.text}
    except Exception as e:
        timetable = {"error": f"Error during API analysis: {e}"}
    return map_line_numbers_to_friendly_names(timetable)
  • Example of bus line content
{
  "line": "1",
  "directions": [
    {
      "from": "Abente y Lago",
      "to": "Castrillón",
      "stops": [
        { "code": 523, "name": "Abente y Lago" },
        { "code": 598, "name": "Avenida Porto, A Terraza" },
        { "code": 5, "name": "Pza. de Ourense" },
        { "code": 6, "name": "Linares Rivas, 26" },
        { "code": 7, "name": "Primo de Rivera, 1" },
        { "code": 270, "name": "Pza. da Palloza" },
        { "code": 271, "name": "Av. do Exército, Casa do Mar" },
        { "code": 272, "name": "Av. do Exército, 16" },
        { "code": 416, "name": "Av. do Exército, 44" },
        { "code": 524, "name": "Av. do Exército, 68" },
        { "code": 525, "name": "Os Castros, Av. da Concordia" },
        { "code": 64, "name": "Av. da Concordia, 14" },
        { "code": 65, "name": "Av. da Concordia, 50" },
        { "code": 66, "name": "Av. da Concordia, 72" },
        { "code": 67, "name": "Abegondo, 3" },
        { "code": 68, "name": "Pza. de Pablo Iglesias" }
      ]
    },
    {
      "from": "Castrillón",
      "to": "Abente y Lago",
      "stops": [
        { "code": 68, "name": "Pza. de Pablo Iglesias" },
        { "code": 69, "name": "Av. da Concordia, 188" },
        { "code": 70, "name": "Av. de Monelos, 141" },
        { "code": 71, "name": "Av. de Monelos, 103" },
        { "code": 72, "name": "Av. de Monelos, 45" },
        { "code": 73, "name": "Cabaleiros, 33" },
        { "code": 74, "name": "Cabaleiros, Estación Autobuses" },
        { "code": 75, "name": "Concepción Arenal, 21" },
        { "code": 41, "name": "Costa da Palloza, 5" },
        { "code": 23, "name": "Primo de Rivera, A Palloza" },
        { "code": 24, "name": "Primo de Rivera, viaduto" },
        { "code": 25, "name": "Pza. de Ourense" },
        { "code": 597, "name": "Avenida Porto, entrada parking" },
        { "code": 523, "name": "Abente y Lago" }
      ]
    }
  ]
}

The power of equipping your LLMs with tools

What started as a small experiment to check bus arrival times at a specific stop in A Coruña ended up revealing something much more interesting.

The most surprising part was seeing how the model, without being explicitly programmed for it, is able to deduce complete routes by automatically finding the origin and destination. This shows the true potential of combining well-defined tools with language models: there’s no need to hardcode every possible interaction if inputs and outputs are well structured.

Additionally:

  • MCP makes it easy to connect the model to external APIs in a clean and structured way.
  • The AI can leverage that structure to make decisions and reason in an emergent manner.
  • With very little code, you can build a useful assistant with access to real-time data.

GitHub repository: Github

Related Posts

How I created AI-generated trivia questions

How I created AI-generated trivia questions

Just last week, an old teammate hit me with the question: “How can I use AI to generate random trivia questions?” At the same time, I was prepping a presentation for my colleagues at DEUS, so I thought—why not turn this into a real example? And boom! The result? An AI-powered trivia generator that effortlessly creates engaging, dynamic questions! What started as a simple inquiry became a full-blown project—challenge accepted, mission accomplished!

Read More
How to build a dashboard in Azure Cloud using App Insights queries with KQL generated by LLM

How to build a dashboard in Azure Cloud using App Insights queries with KQL generated by LLM

Building a robust and insightful dashboard in Azure Application Insights with KQL (Kusto Query Language) allows teams to monitor and analyze their application’s performance and user behavior. This guide will walk you through creating such a dashboard with examples of key performance indicators (KPIs) and corresponding charts. I don´t know nothing about KQL but I will use an LLM to generate the queries I need.

Read More
Introducing ‘Idealisto’: Your AI Chatbot for the Spanish Real Estate Market

Introducing ‘Idealisto’: Your AI Chatbot for the Spanish Real Estate Market

Navigating the Spanish real estate market just got easier with Idealisto! Whether you’re a savvy investor, a first-time buyer, or a real estate professional, this cutting-edge AI chatbot is here to help you uncover trends, get legal advice, and spot market opportunities with unprecedented precision.

Read More