University Course Catalog MCP Server
README.md
# University Course Catalog MCP Server
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that exposes a
university's course catalog to LLM assistants. It gives AI agents the ability to search
courses, inspect prerequisites, build prerequisite dependency graphs, and look up
instructors — backed by a local SQLite database and fully containerized with Docker.
This is the backend for an **AI-powered academic advisor**: a model can query the server
in real time to help students plan schedules, understand course dependencies, and find
the right instructor.
---
## Features
- **MCP Tools** — four validated, LLM-callable functions:
- `search_courses` — keyword search across titles, descriptions and codes, optionally
filtered by department code.
- `get_prerequisites` — the direct prerequisites of a course.
- `lookup_instructor` — instructor contact details by name.
- `get_prerequisite_graph` — the full transitive prerequisite dependency graph
(computed with NetworkX) as an adjacency list.
- **MCP Resources** — contextual text bodies the model can load:
- `course_descriptions` — a formatted list of every course and its description.
- `department_directory` — the full department list with their codes.
- **MCP Prompt Templates**:
- `course_comparison_template` — a reusable template (`{{course_code_1}}`,
`{{course_code_2}}`) that guides structured course comparisons.
- **Data integrity** — every tool input/output is validated with **Pydantic** schemas;
data access uses **SQLAlchemy** (an ORM, which prevents SQL injection).
- **Persistence** — SQLite database stored in `./data/catalog.db`, mounted as a volume.
- **Containerized** — one command: `docker compose up`.
---
## Project Structure
```
.
├── data/
│ ├── catalog.db # Seeded SQLite database
│ └── seed_script/
│ └── seed.py # Idempotent seeding script
├── src/
│ ├── __init__.py
│ ├── config.py # Environment configuration
│ ├── database.py # Engine + session helpers
│ ├── models.py # SQLAlchemy ORM models
│ ├── schemas.py # Pydantic validation contracts
│ ├── seed.py # Shared seeding logic + seed data
│ ├── server.py # MCP server: tools, resources, prompts
│ └── main.py # Entry point (seeds + serves HTTP)
├── .env.example # Documented environment variables
├── .gitignore
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── README.md
```
---
## Quick Start with Docker (recommended)
Prerequisites: Docker with the Compose plugin.
```bash
# From the repository root
docker compose up --build
```
The service builds the image, maps port `8080`, mounts `./data` so the database
persists, seeds the catalog on first start, and runs a health check.
- Health check: <http://localhost:8080/health>
- MCP endpoint: <http://localhost:8080/mcp>
- Stop the server: `docker compose down`
To confirm the container is healthy:
```bash
docker compose ps
```
You should see `mcp-server` with a `healthy` status within about a minute.
---
## Running Locally (without Docker)
Requires Python 3.11+.
```bash
# 1. Create and activate a virtual environment
python -m venv .venv
# Windows: .venv\Scripts\activate | macOS/Linux: source .venv/bin/activate
# 2. Install dependencies
pip install -r requirements.txt
# 3. (Optional) configure environment
# Copy .env.example to .env and adjust if needed.
# Default: DATABASE_URL=sqlite:///./data/catalog.db
# 4. Seed the database (idempotent — safe to run repeatedly)
python data/seed_script/seed.py
# 5. Start the server
python -m src.main
```
The server listens on `http://localhost:8080`.
---
## Connecting an MCP Client
Point any MCP client at the Streamable HTTP endpoint:
```
http://localhost:8080/mcp
```
Example using the **MCP Inspector**:
```bash
npx @modelcontextprotocol/inspector
# URL: http://localhost:8080/mcp
```
You can also connect programmatically with the official `mcp` Python SDK:
```python
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async def main():
async with streamablehttp_client("http://localhost:8080/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("get_prerequisites", {"course_code": "CS201"})
print(result)
asyncio.run(main())
```
---
## Tools
All tool inputs and outputs are validated with Pydantic. On unknown input the tools
return a structured error, e.g. `{"error": "Course not found"}`.
### `search_courses`
Searches the catalog by keyword (case-insensitive match against title, description and
course code), optionally restricted to a department code.
| Parameter | Type | Required | Description |
| ---------------- | ------ | -------- | ---------------------------------------------- |
| `query` | string | yes | Keyword to search for. |
| `department_code`| string | no | Restrict results to a department (e.g. `CS`). |
Output (success):
```json
[{ "course_code": "CS101", "title": "Introduction to Programming", "credits": 3 }]
```
Returns `[]` when nothing matches.
### `get_prerequisites`
Returns the **direct** prerequisites of a course.
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | --------------------------------- |
| `course_code`| string | yes | E.g. `CS201`. |
Output (success):
```json
{
"course_code": "CS201",
"prerequisites": [
{ "course_code": "CS102", "title": "Data Structures and Algorithms" }
]
}
```
Empty list when the course has no prerequisites; `{"error": "Course not found"}` for an
unknown code.
### `lookup_instructor`
Finds an instructor by full or partial name.
| Parameter | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------- |
| `instructor_name` | string | yes | E.g. `Grace Hopper`. |
Output (success):
```json
{
"name": "Dr. Grace Hopper",
"email": "grace.hopper@university.edu",
"department_name": "Computer Science"
}
```
`{"error": "Instructor not found"}` when no match exists.
### `get_prerequisite_graph`
Returns the full prerequisite dependency graph for a course — the course itself plus
every course in its transitive prerequisite chain — as an adjacency list. The graph is
built with **NetworkX** (`source` is a prerequisite for `target`).
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------ |
| `course_code`| string | yes | E.g. `CS401`. |
Output (success):
```json
{
"nodes": [{ "id": "CS401" }, { "id": "CS201" }, { "id": "CS102" }, { "id": "CS101" }],
"edges": [
{ "source": "CS101", "target": "CS102" },
{ "source": "CS102", "target": "CS201" },
{ "source": "CS201", "target": "CS401" }
]
}
```
---
## Resources
### `course_descriptions`
`catalog://course_descriptions` — a single plain-text body listing every course:
```
[CS101] Introduction to Programming: A foundational course on programming principles...
[CS102] Data Structures and Algorithms: ...
```
### `department_directory`
`catalog://department_directory` — a directory of all departments:
```
Computer Science (CS)
Mathematics (MATH)
Physics (PHYS)
```
---
## Prompt Template
### `course_comparison_template`
A reusable template that guides the model to produce a structured comparison of two
courses:
> Create a table comparing the following two courses: `{{course_code_1}}` and
> `{{course_code_2}}`. Include columns for Course Code, Title, Credits, Description,
> and Prerequisites. ...
---
## Example Natural Language Queries
Once connected to an assistant, the model can answer questions like:
- "Which courses are about machine learning?"
- "What do I need to take before CS401, and is there a chain of prerequisites?"
- "Does MATH101 have any prerequisites?"
- "Who teaches Database Systems and what is their email?"
- "Compare CS301 and CS401 side by side."
- "List all courses offered by the Physics department."
The model resolves these by calling the tools above and reading the resources.
---
## Database
SQLite file: `./data/catalog.db`. Schema:
| Table | Columns |
| -------------- | ----------------------------------------------------------------------- |
| `departments` | `id` (PK), `name`, `code` (UNIQUE) |
| `instructors` | `id` (PK), `name`, `email`, `office`, `department_id` (FK) |
| `courses` | `id` (PK), `course_code` (UNIQUE), `title`, `description`, `credits`, `instructor_id` (FK), `department_id` (FK) |
| `prerequisites`| `course_id` (FK), `prerequisite_id` (FK) — many-to-many mapping |
Seed data: **3 departments**, **5 instructors**, **10 courses** (8 with prerequisites,
including multi-level chains such as `CS101 → CS102 → CS201 → CS401`).
Re-seeding is automatic and idempotent — the server checks whether the catalog is empty
before seeding, and the standalone script can be run anytime:
```bash
python data/seed_script/seed.py
```
---
## Environment Variables
| Variable | Default | Description |
| ------------- | -------------------------------- | ----------------------------------------------- |
| `DATABASE_URL`| `sqlite:///./data/catalog.db` | SQLite connection string (path inside container)|
| `HOST` | `0.0.0.0` | Interface the HTTP server binds to. |
| `PORT` | `8080` | Port the HTTP server listens on. |
| `SERVER_NAME` | `University Course Catalog MCP Server` | Name advertised during MCP initialize. |
All variables are documented in [`.env.example`](.env.example).
---
## Verification Checklist
- [x] `search_courses`, `get_prerequisites`, `lookup_instructor`, `get_prerequisite_graph` tools
- [x] `course_descriptions`, `department_directory` resources
- [x] `course_comparison_template` prompt (`{{course_code_1}}`, `{{course_code_2}}`)
- [x] Pydantic-validated inputs/outputs and consistent `{"error": ...}` responses
- [x] Seeded `data/catalog.db` with required schema
- [x] `Dockerfile`, `docker-compose.yml`, `.env.example`, `README.md`
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues