| Time | Section | Details |
|---|---|---|
| 0–5 min | Get to Know Your Netlighter | Ice breaker with consultant |
| 5–15 min | Motivation & MCP Demo (everyone) | Slides + live demo of finished project |
| 15–25 min | Setup | Docker |
| 25–30 min | Exercise Explanation (everyone) | Code walkthrough |
| 30–60 min | Coding Exercises | Buildings (5-10m) → Transport (10-15m) → Bonus |
| 60–70 min | Debrief + Solution (everyone) | Show solution branch, Q&A |
An MCP (Model Context Protocol) server that gives your AI assistant real-time access to:
- UZH building locations
- Swiss public transport connections
- UZH Mensa menus
Meet your Netlighter consultant! Quick round of introductions to kick off the workshop.
Your presenter will cover:
- What is MCP (Model Context Protocol)?
- The problem: LLMs are isolated from real-world data
- The solution: MCP tools give LLMs access to APIs
- Live demo of the finished project — the AI assistant you'll build today
git clone https://github.com/netlight/uzh_mcp_workshop
cd uzh_mcp_workshopdocker compose up --buildThis starts:
- MCP server on http://localhost:8866
- Open WebUI on http://localhost:3000
Open http://localhost:3000 in your browser. You should see the Open WebUI interface.
- Open http://localhost:3000/admin/settings/integrations
- Find the tool named UZH MCP, toggle it off, then toggle it back on
- Open http://localhost:3000/admin/settings/models
- Select claude-sonnet-4-5 and click the Edit (pen) button
- In the System Prompt field, paste the contents of
prompt_system.md - Under Tools, tick UZH MCP to enable it
- Save
- Open http://localhost:3000 and start a New Chat
- Try asking: "List all UZH buildings"
The AI will automatically call your MCP tools to answer. After each code change, rebuild with docker compose up --build and refresh the page.
Your presenter will walk through the code structure:
src/server.py— FastMCP server instancesrc/__main__.py— entry point where tool modules are registeredsrc/buildings.py,src/transportation.py,src/mensa.py— starter code you'll complete- How the
@mcp.tool()decorator works - Overview of the three APIs you'll use
File: src/buildings.py
API: https://ziplaene-api.uzh.ch/v1/map/buildings
The API returns JSON like:
{
"buildings": [
{"shortName": "KOL", "group": "Zentrum", "lat": 47.37, "lon": 8.55, ...},
...
]
}- Make a GET request to
UZH_BUILDINGS_URLusinghttpx.get() - Call
.raise_for_status()on the response to check for errors - Parse the JSON response:
resp.json()["buildings"] - Loop through buildings, find the one matching
building_name(convert to uppercase first) - Return a dict with:
shortName,lat,lon
- Make a GET request to the same URL
- Return a list of dicts with
shortNameandgroupfor each building
Rebuild & test:
docker compose up --buildFile: src/transportation.py
API: https://transport.opendata.ch/v1/
- Build a params dict with:
from(= origin),to(= destination),limit - If
timeis provided, add"time"to params - If
is_arrival_timeis true, add"isArrivalTime": 1to params - GET request to
TRANSPORT_URL_API + "connections" - Parse
resp.json()["connections"]and return a list of dicts with:departure:conn["from"]["departure"]arrival:conn["to"]["arrival"]duration:conn["duration"]transports:conn["products"]
Rebuild & test:
docker compose up --buildFile: src/mensa.py
API: https://api.zfv.ch/graphql (GraphQL)
The GraphQL queries, the _zfv_query() helper, and step-by-step comments are already provided in the stub.
You need to implement a single get_mensa_menu(date) function that internally fetches all outlets, categories, and dishes.
- Default
dateto today (datetime.date.today().isoformat()) if None - Fetch all outlets: call
_zfv_query()with the outlets query, navigatedata["data"]["client"]["organisationPermissions"], loop through and collectperm["organisation"]["outlets"] - For each outlet, fetch its menu categories: call
_zfv_query()with the categories query, getdata["data"]["outlet"]["menuCategories"] - For each outlet + category, fetch dishes: call
_zfv_query()with the dishes query, navigate todata["data"]["outlet"]["calendar"]["week"]["menuCategory"]["daily"], filter entries wheredate["dateLocal"]starts with the requested date, collect thedishfrom eachmenuItem - Return structured result:
{"date": "...", "outlets": [{"name": "...", "categories": [{"name": "...", "dishes": [...]}]}]} - Only include outlets/categories that have dishes for the requested date
Create your own MCP tool! Ideas:
- Weather API
- UZH events
- Study room finder
Steps:
- Create a new file
src/my_tool.py - Import the mcp server:
from .server import mcp - Add
@mcp.tool()decorated functions - Import your module in
src/__main__.py:from . import my_tool - Rebuild:
docker compose up --build
Your presenter will:
- Walk through the solution branch and completed implementations
- Open the floor for Q&A
- Recap what you built: a real AI-powered campus assistant
So far we've only used tools — functions the AI actively calls with parameters. MCP defines a second primitive called resources: read-only data the AI can read by URI, like opening a file.
| Tool | Resource | |
|---|---|---|
| Direction | AI calls it with parameters | AI reads it by URI |
| Example | get_building_location("KOL") |
uzh://buildings/list |
| Use case | Actions, lookups, side-effects | Static or slowly-changing data |
Here's what a resource looks like using our buildings example:
@mcp.resource("uzh://buildings/list")
def buildings_overview() -> str:
"""A static list of all UZH building codes."""
return "KOL, BIN, SOC, RAA, ..."Resources are useful when you want to expose reference data without requiring the AI to pass parameters — think configuration, documentation, or catalogues.
| Library | Usage |
|---|---|
httpx.get(url, params={...}) |
HTTP GET request with query parameters |
httpx.post(url, json={...}) |
HTTP POST request with JSON body |
resp.raise_for_status() |
Raise an error if the request failed |
resp.json() |
Parse JSON response body |
@mcp.tool() |
Register a function as an MCP tool |
@mcp.resource("uri") |
Expose read-only data the AI can access by URI |