uoft-mcp 0.1.0__tar.gz
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.
- uoft_mcp-0.1.0/.github/workflows/publish.yml +69 -0
- uoft_mcp-0.1.0/.gitignore +26 -0
- uoft_mcp-0.1.0/.python-version +1 -0
- uoft_mcp-0.1.0/EXPLAINED.md +111 -0
- uoft_mcp-0.1.0/LICENSE +21 -0
- uoft_mcp-0.1.0/PKG-INFO +164 -0
- uoft_mcp-0.1.0/README.md +147 -0
- uoft_mcp-0.1.0/pyproject.toml +42 -0
- uoft_mcp-0.1.0/tests/conftest.py +8 -0
- uoft_mcp-0.1.0/tests/test_stdio.py +66 -0
- uoft_mcp-0.1.0/tests/test_tools.py +272 -0
- uoft_mcp-0.1.0/timetable_builder.json +225 -0
- uoft_mcp-0.1.0/uoft_mcp/__init__.py +1 -0
- uoft_mcp-0.1.0/uoft_mcp/__main__.py +6 -0
- uoft_mcp-0.1.0/uoft_mcp/client.py +62 -0
- uoft_mcp-0.1.0/uoft_mcp/server.py +181 -0
- uoft_mcp-0.1.0/uv.lock +736 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: Test and build distributions
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- name: Check out repository
|
|
16
|
+
uses: actions/checkout@v7
|
|
17
|
+
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
20
|
+
with:
|
|
21
|
+
enable-cache: true
|
|
22
|
+
|
|
23
|
+
- name: Install Python
|
|
24
|
+
run: uv python install 3.13
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: uv sync --locked --managed-python
|
|
28
|
+
|
|
29
|
+
- name: Run tests
|
|
30
|
+
run: uv run --locked pytest -q
|
|
31
|
+
|
|
32
|
+
- name: Run lint and format checks
|
|
33
|
+
run: |
|
|
34
|
+
uv run --locked ruff check .
|
|
35
|
+
uv run --locked ruff format --check .
|
|
36
|
+
|
|
37
|
+
- name: Build distributions
|
|
38
|
+
run: uv build
|
|
39
|
+
|
|
40
|
+
- name: Upload distributions
|
|
41
|
+
uses: actions/upload-artifact@v7
|
|
42
|
+
with:
|
|
43
|
+
name: dist
|
|
44
|
+
path: dist/
|
|
45
|
+
if-no-files-found: error
|
|
46
|
+
|
|
47
|
+
publish:
|
|
48
|
+
name: Publish distributions
|
|
49
|
+
needs: build
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
environment:
|
|
52
|
+
name: pypi
|
|
53
|
+
url: https://pypi.org/project/uoft-mcp/
|
|
54
|
+
permissions:
|
|
55
|
+
id-token: write
|
|
56
|
+
steps:
|
|
57
|
+
- name: Install uv
|
|
58
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
59
|
+
with:
|
|
60
|
+
enable-cache: false
|
|
61
|
+
|
|
62
|
+
- name: Download distributions
|
|
63
|
+
uses: actions/download-artifact@v8
|
|
64
|
+
with:
|
|
65
|
+
name: dist
|
|
66
|
+
path: dist/
|
|
67
|
+
|
|
68
|
+
- name: Publish to PyPI
|
|
69
|
+
run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python environments and local dependency caches
|
|
2
|
+
.venv/
|
|
3
|
+
.venv-*/
|
|
4
|
+
.uv-cache/
|
|
5
|
+
.python/
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
|
|
9
|
+
# Test, lint, and build output
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
|
|
18
|
+
# Local configuration and editor state
|
|
19
|
+
.env
|
|
20
|
+
.env.*
|
|
21
|
+
!.env.example
|
|
22
|
+
.vscode/
|
|
23
|
+
.idea/
|
|
24
|
+
*.log
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# How This MCP Wrapper Works
|
|
2
|
+
|
|
3
|
+
## The Basic Idea
|
|
4
|
+
|
|
5
|
+
An MCP client, such as an assistant application, launches this Python process and
|
|
6
|
+
asks it to call named tools. Our tools make ordinary HTTP requests to UofT and send
|
|
7
|
+
the JSON response back to the client. The MCP SDK handles the protocol, tool
|
|
8
|
+
discovery, and argument validation.
|
|
9
|
+
|
|
10
|
+
For example:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
MCP client calls get_course_details(course_code="CSC108H1", section_code="F")
|
|
14
|
+
-> server.py maps the tool arguments to an HTTP request
|
|
15
|
+
-> client.py sends GET /ttb/getCoursesByCodeAndSectionCode/CSC108H1?sectionCode=F
|
|
16
|
+
-> UofT returns JSON containing course and section information
|
|
17
|
+
-> server.py returns that JSON as one MCP text block
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
There is no local course catalog, AI model, or scheduling algorithm in this project.
|
|
21
|
+
The timetable information comes directly from UofT on each call.
|
|
22
|
+
|
|
23
|
+
## Files You Will Work With
|
|
24
|
+
|
|
25
|
+
| File | Responsibility |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `uoft_mcp/server.py` | Defines the seven tools, maps arguments, and runs the MCP server. |
|
|
28
|
+
| `uoft_mcp/client.py` | Sets the API URL, request headers, timeout, and HTTP error handling. |
|
|
29
|
+
| `uoft_mcp/__main__.py` | Makes `python -m uoft_mcp` call the server's `main()` function. |
|
|
30
|
+
| `uoft_mcp/__init__.py` | Marks the directory as an importable package. |
|
|
31
|
+
| `pyproject.toml` | Package metadata, dependencies, command entry point, and test/lint settings. |
|
|
32
|
+
| `uv.lock` | Exact resolved dependencies for reproducible installation; generated by uv. |
|
|
33
|
+
| `.python-version` | Tells uv to use Python 3.13 for this project. |
|
|
34
|
+
| `.gitignore` | Keeps environments, caches, local secrets, and generated files out of Git. |
|
|
35
|
+
| `tests/` | Offline tests of the API mappings and MCP interface, including real process pipes. |
|
|
36
|
+
| `timetable_builder.json` | Your original endpoint reference; not loaded at runtime. |
|
|
37
|
+
|
|
38
|
+
Runtime dependencies are `mcp` (the protocol), `httpx` (HTTP), and `pydantic`
|
|
39
|
+
(argument constraints). `pytest` and `ruff` are development tools. Other packages in
|
|
40
|
+
the lockfile are dependencies of those libraries.
|
|
41
|
+
|
|
42
|
+
## Reading a Tool
|
|
43
|
+
|
|
44
|
+
Look at `search_departments` in `server.py`. Its decorator registers the function as
|
|
45
|
+
an MCP tool. Its docstring describes the tool to clients, and its type annotations
|
|
46
|
+
become the tool's input schema.
|
|
47
|
+
|
|
48
|
+
The `ctx` parameter is injected by the SDK, so users do not supply it. It gives the
|
|
49
|
+
function access to the shared HTTP client. The remaining arguments are the public
|
|
50
|
+
inputs: `term` and `divisions`.
|
|
51
|
+
|
|
52
|
+
The function calls `_request` with the endpoint and query parameters. More involved
|
|
53
|
+
tools follow the same pattern: `search_course_titles` changes `lower_threshold` into
|
|
54
|
+
the API's `lowerThreshold`, while `search_courses` builds a POST body with nested
|
|
55
|
+
`courseCodeAndTitleProps` and the documented filters.
|
|
56
|
+
|
|
57
|
+
`search_courses` uses the live frontend's page-1 and ascending-sort defaults. It also
|
|
58
|
+
includes `departmentProps: []`: omitting that field produced an HTTP 500 during
|
|
59
|
+
verification. The original JSON is kept unchanged so this correction is explicit.
|
|
60
|
+
|
|
61
|
+
## Startup, Responses, and Errors
|
|
62
|
+
|
|
63
|
+
`create_server()` builds the server without making an API request. Its lifespan
|
|
64
|
+
opens one `httpx.AsyncClient` when the server starts, shares its connection pool
|
|
65
|
+
across tool calls, and closes it when the server stops.
|
|
66
|
+
|
|
67
|
+
Every request has a 30-second HTTPX timeout for network operations. Headers include
|
|
68
|
+
JSON acceptance, a browser-like user agent, and the Timetable Builder origin and
|
|
69
|
+
referrer. This public API does not need a token.
|
|
70
|
+
|
|
71
|
+
`request_json()` parses the response but does not convert it into a custom course
|
|
72
|
+
class. `_request()` serializes that data into one JSON text block. This preserves
|
|
73
|
+
top-level arrays and objects uniformly; programmatic MCP clients can use
|
|
74
|
+
`json.loads(result.content[0].text)`. There is no separate structured-output schema,
|
|
75
|
+
because the upstream response shapes are intentionally left open.
|
|
76
|
+
|
|
77
|
+
An HTTP error, timeout, connection failure, or invalid JSON response raises
|
|
78
|
+
`TimetableAPIError`. The MCP layer translates it to `ToolError`, which the client
|
|
79
|
+
receives as a failed tool result. HTTP errors retain the status code, but arbitrary
|
|
80
|
+
upstream error bodies are not exposed. A successful HTTP response's application-level
|
|
81
|
+
`status` is passed through unchanged.
|
|
82
|
+
|
|
83
|
+
Do not add `print()` debugging to the server: stdout is its protocol connection.
|
|
84
|
+
Use Python's `logging` module, which is configured to write to stderr.
|
|
85
|
+
|
|
86
|
+
## Adding Another Endpoint
|
|
87
|
+
|
|
88
|
+
1. Verify the endpoint and required parameters against the reference and live API.
|
|
89
|
+
2. Add a typed, documented function inside `create_server()` with a `@server.tool`
|
|
90
|
+
decorator. For another lookup, follow an existing tool's read-only annotation
|
|
91
|
+
and `structured_output=False` setting.
|
|
92
|
+
3. Map its arguments to a fixed path and query parameters or JSON body, then call
|
|
93
|
+
`_request`. Do not accept arbitrary destination URLs.
|
|
94
|
+
4. Add a mocked MCP call in `tests/test_tools.py` that checks the outgoing request
|
|
95
|
+
and returned data. Update the expected tool count/name set and the README.
|
|
96
|
+
5. Run the README's test and lint commands, then commit the completed change.
|
|
97
|
+
|
|
98
|
+
Tests inject `httpx.MockTransport` through `create_server(transport=...)`. Requests
|
|
99
|
+
still go through the SDK, tool functions, and HTTP client, but the transport returns
|
|
100
|
+
controlled responses without contacting UofT. The stdio tests separately verify that
|
|
101
|
+
an installed package starts correctly and stdout contains protocol JSON only.
|
|
102
|
+
|
|
103
|
+
Catalog dumps, timetable sharing, and schedule generation are intentionally deferred.
|
|
104
|
+
Saving a timetable would need different tool annotations, and generation needs a
|
|
105
|
+
verified request schema. They should be added as explicit features when needed.
|
|
106
|
+
|
|
107
|
+
## Milestones
|
|
108
|
+
|
|
109
|
+
The initial work is split into local commits: project setup, working tools and tests,
|
|
110
|
+
and these explanatory documents. Use `git log --oneline` to see the milestones and
|
|
111
|
+
`git show <commit>` to inspect one. Nothing is pushed to a remote repository.
|
uoft_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SleepyPandas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
uoft_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: uoft-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small MCP wrapper for the UofT Timetable Builder API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/SleepyPandas/UofT-MCP
|
|
6
|
+
Project-URL: Issues, https://github.com/SleepyPandas/UofT-MCP/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/SleepyPandas/UofT-MCP
|
|
8
|
+
Author: SleepyPandas
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: mcp,timetable,university-of-toronto,uoft
|
|
12
|
+
Requires-Python: >=3.13
|
|
13
|
+
Requires-Dist: httpx<1,>=0.28
|
|
14
|
+
Requires-Dist: mcp<3,>=2
|
|
15
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# UofT Timetable Builder MCP
|
|
19
|
+
|
|
20
|
+
A small Python MCP server for the public [UofT Timetable Builder](https://ttb.utoronto.ca/)
|
|
21
|
+
API. It exposes seven course-lookup tools over local stdio using the
|
|
22
|
+
[official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
|
|
23
|
+
|
|
24
|
+
No API key, database, web server, or environment variables are required. This is an
|
|
25
|
+
unofficial wrapper; it does not enroll students, build schedules, or save timetables.
|
|
26
|
+
|
|
27
|
+
## Connect an MCP Client
|
|
28
|
+
|
|
29
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then add this
|
|
30
|
+
configuration to any client that supports `mcpServers`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"uoft-timetable": {
|
|
36
|
+
"command": "uvx",
|
|
37
|
+
"args": ["uoft-mcp@latest"],
|
|
38
|
+
"env": {
|
|
39
|
+
"UV_HTTP_TIMEOUT": "300"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Restart the client after saving its configuration. On Windows, if the client cannot
|
|
47
|
+
find `uvx`, restart it after installing uv or replace `"uvx"` with the absolute path
|
|
48
|
+
reported by `where.exe uvx`.
|
|
49
|
+
|
|
50
|
+
`uvx` downloads the published package into an isolated environment and starts the
|
|
51
|
+
`uoft-mcp` command. No repository clone, virtual environment setup, API key, server
|
|
52
|
+
URL, or listening port is needed. The first start can take longer while uv downloads
|
|
53
|
+
Python and the dependencies; later starts use its cache.
|
|
54
|
+
|
|
55
|
+
To pin a release instead of following the newest release, use
|
|
56
|
+
`"args": ["uoft-mcp==0.1.0"]`.
|
|
57
|
+
|
|
58
|
+
## Local Development
|
|
59
|
+
|
|
60
|
+
From a clone of this repository:
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
uv python install 3.13
|
|
64
|
+
uv sync --locked --managed-python
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`uv sync` creates `.venv`, installs the package and development tools, and uses the
|
|
68
|
+
committed `uv.lock`. `.python-version` selects Python 3.13; the package supports
|
|
69
|
+
Python 3.13 and newer.
|
|
70
|
+
|
|
71
|
+
Run the local checkout with:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
uv run --locked python -m uoft_mcp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The installed `uoft-mcp` command is another entry point. The process waits for an
|
|
78
|
+
MCP client on stdin; a blank terminal is expected. Use Ctrl+C to stop a manual run.
|
|
79
|
+
Stdout carries protocol messages only, and logging goes to stderr.
|
|
80
|
+
|
|
81
|
+
## Tools
|
|
82
|
+
|
|
83
|
+
| Tool | Arguments and purpose |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `get_current_sessions` | No arguments. Get current session IDs; skip entries with `header: true`. |
|
|
86
|
+
| `get_reference_data` | No arguments. Get campus, division, delivery-mode, and sorting values. |
|
|
87
|
+
| `get_divisions` | No arguments. List recognized faculty/division codes. |
|
|
88
|
+
| `search_departments` | Required `term` keyword and `divisions` code. |
|
|
89
|
+
| `search_course_titles` | Required `term`, `divisions`, and `sessions` strings. Optional `lower_threshold=50`, `upper_threshold=200`. |
|
|
90
|
+
| `get_course_details` | Required `course_code`; optional `section_code` of `F`, `S`, or `Y`. |
|
|
91
|
+
| `search_courses` | Optional code/title, section, description, division, session, campus, delivery, and pagination filters. |
|
|
92
|
+
|
|
93
|
+
Each successful tool returns one text block containing the complete upstream JSON.
|
|
94
|
+
The wrapper preserves fields and arrays, including upstream `payload` and `status`
|
|
95
|
+
envelopes. It does not summarize, truncate, or reshape course data.
|
|
96
|
+
|
|
97
|
+
### Example Workflow
|
|
98
|
+
|
|
99
|
+
1. Call `get_current_sessions` with `{}` and select a non-header entry's `value`.
|
|
100
|
+
2. Call `get_divisions` or `get_reference_data` for valid filter codes.
|
|
101
|
+
3. Call `search_course_titles` with these arguments, substituting the session value:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{"term": "CSC108", "divisions": "ARTSC", "sessions": "SESSION_ID_FROM_STEP_1"}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
4. Use the returned exact course code in `get_course_details`:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{"course_code": "CSC108H1", "section_code": "F"}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
5. For filtered, paginated results, call `search_courses`:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"course_code": "CSC108H1",
|
|
118
|
+
"divisions": ["ARTSC"],
|
|
119
|
+
"sessions": ["SESSION_ID_FROM_STEP_1"],
|
|
120
|
+
"page": 1,
|
|
121
|
+
"page_size": 2
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`search_courses` also accepts `course_title`, `course_section_code`,
|
|
126
|
+
`search_course_description`, `campuses`, `delivery_modes`, and `direction` (`asc` or
|
|
127
|
+
`desc`). Pages start at **1**, page size defaults to **20**, and sorting defaults to
|
|
128
|
+
`asc`. Omitted collection filters become empty arrays. Course codes should be exact;
|
|
129
|
+
use autocomplete for prefixes or `course_title` for keyword searches.
|
|
130
|
+
|
|
131
|
+
## Checks
|
|
132
|
+
|
|
133
|
+
```powershell
|
|
134
|
+
uv run --locked pytest -q
|
|
135
|
+
uv run --locked ruff check .
|
|
136
|
+
uv run --locked ruff format --check .
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The tests run offline. They cover all seven tools, request mapping, raw JSON
|
|
140
|
+
preservation, validation, HTTP errors, timeouts, connection errors, invalid JSON,
|
|
141
|
+
shared-client cleanup, MCP discovery, and actual stdio subprocesses.
|
|
142
|
+
|
|
143
|
+
Initial verification: 33 tests passed, and all seven tools returned successful live
|
|
144
|
+
responses from UofT, including an exact-code search with `page_size=2`. Live requests
|
|
145
|
+
are deliberately not part of the test suite, so tests remain reproducible.
|
|
146
|
+
|
|
147
|
+
## API Notes
|
|
148
|
+
|
|
149
|
+
- The supplied [timetable_builder.json](timetable_builder.json) remains the original
|
|
150
|
+
reference. Live checks found two missing details: pagination starts at 1, and
|
|
151
|
+
paginated search requires an empty `departmentProps` array when not filtering by
|
|
152
|
+
department. The wrapper supplies it.
|
|
153
|
+
- Get division codes from the API. For example, the live API uses `ERIN` and `SCAR`
|
|
154
|
+
for Mississauga and Scarborough, rather than the reference's `UTM` and `UTSC` examples.
|
|
155
|
+
- Even one course can have a large response because all its sections are included.
|
|
156
|
+
Choose narrow filters and small page sizes. The wrapper never fetches extra pages.
|
|
157
|
+
- HTTP failures become MCP tool errors containing the endpoint and status code.
|
|
158
|
+
UofT may return HTTP 404 for no matching courses. Timeouts, connection failures,
|
|
159
|
+
and malformed JSON get their own readable errors. No automatic retries occur.
|
|
160
|
+
- This API is not covered by an official support guarantee. Changes upstream may
|
|
161
|
+
require updating the mappings. Successful HTTP responses are preserved as supplied,
|
|
162
|
+
including any application-level status messages inside their JSON.
|
|
163
|
+
|
|
164
|
+
For a walkthrough of the code and how to extend it, read [EXPLAINED.md](EXPLAINED.md).
|
uoft_mcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# UofT Timetable Builder MCP
|
|
2
|
+
|
|
3
|
+
A small Python MCP server for the public [UofT Timetable Builder](https://ttb.utoronto.ca/)
|
|
4
|
+
API. It exposes seven course-lookup tools over local stdio using the
|
|
5
|
+
[official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
|
|
6
|
+
|
|
7
|
+
No API key, database, web server, or environment variables are required. This is an
|
|
8
|
+
unofficial wrapper; it does not enroll students, build schedules, or save timetables.
|
|
9
|
+
|
|
10
|
+
## Connect an MCP Client
|
|
11
|
+
|
|
12
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then add this
|
|
13
|
+
configuration to any client that supports `mcpServers`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"uoft-timetable": {
|
|
19
|
+
"command": "uvx",
|
|
20
|
+
"args": ["uoft-mcp@latest"],
|
|
21
|
+
"env": {
|
|
22
|
+
"UV_HTTP_TIMEOUT": "300"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Restart the client after saving its configuration. On Windows, if the client cannot
|
|
30
|
+
find `uvx`, restart it after installing uv or replace `"uvx"` with the absolute path
|
|
31
|
+
reported by `where.exe uvx`.
|
|
32
|
+
|
|
33
|
+
`uvx` downloads the published package into an isolated environment and starts the
|
|
34
|
+
`uoft-mcp` command. No repository clone, virtual environment setup, API key, server
|
|
35
|
+
URL, or listening port is needed. The first start can take longer while uv downloads
|
|
36
|
+
Python and the dependencies; later starts use its cache.
|
|
37
|
+
|
|
38
|
+
To pin a release instead of following the newest release, use
|
|
39
|
+
`"args": ["uoft-mcp==0.1.0"]`.
|
|
40
|
+
|
|
41
|
+
## Local Development
|
|
42
|
+
|
|
43
|
+
From a clone of this repository:
|
|
44
|
+
|
|
45
|
+
```powershell
|
|
46
|
+
uv python install 3.13
|
|
47
|
+
uv sync --locked --managed-python
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`uv sync` creates `.venv`, installs the package and development tools, and uses the
|
|
51
|
+
committed `uv.lock`. `.python-version` selects Python 3.13; the package supports
|
|
52
|
+
Python 3.13 and newer.
|
|
53
|
+
|
|
54
|
+
Run the local checkout with:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
uv run --locked python -m uoft_mcp
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The installed `uoft-mcp` command is another entry point. The process waits for an
|
|
61
|
+
MCP client on stdin; a blank terminal is expected. Use Ctrl+C to stop a manual run.
|
|
62
|
+
Stdout carries protocol messages only, and logging goes to stderr.
|
|
63
|
+
|
|
64
|
+
## Tools
|
|
65
|
+
|
|
66
|
+
| Tool | Arguments and purpose |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `get_current_sessions` | No arguments. Get current session IDs; skip entries with `header: true`. |
|
|
69
|
+
| `get_reference_data` | No arguments. Get campus, division, delivery-mode, and sorting values. |
|
|
70
|
+
| `get_divisions` | No arguments. List recognized faculty/division codes. |
|
|
71
|
+
| `search_departments` | Required `term` keyword and `divisions` code. |
|
|
72
|
+
| `search_course_titles` | Required `term`, `divisions`, and `sessions` strings. Optional `lower_threshold=50`, `upper_threshold=200`. |
|
|
73
|
+
| `get_course_details` | Required `course_code`; optional `section_code` of `F`, `S`, or `Y`. |
|
|
74
|
+
| `search_courses` | Optional code/title, section, description, division, session, campus, delivery, and pagination filters. |
|
|
75
|
+
|
|
76
|
+
Each successful tool returns one text block containing the complete upstream JSON.
|
|
77
|
+
The wrapper preserves fields and arrays, including upstream `payload` and `status`
|
|
78
|
+
envelopes. It does not summarize, truncate, or reshape course data.
|
|
79
|
+
|
|
80
|
+
### Example Workflow
|
|
81
|
+
|
|
82
|
+
1. Call `get_current_sessions` with `{}` and select a non-header entry's `value`.
|
|
83
|
+
2. Call `get_divisions` or `get_reference_data` for valid filter codes.
|
|
84
|
+
3. Call `search_course_titles` with these arguments, substituting the session value:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{"term": "CSC108", "divisions": "ARTSC", "sessions": "SESSION_ID_FROM_STEP_1"}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
4. Use the returned exact course code in `get_course_details`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{"course_code": "CSC108H1", "section_code": "F"}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
5. For filtered, paginated results, call `search_courses`:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"course_code": "CSC108H1",
|
|
101
|
+
"divisions": ["ARTSC"],
|
|
102
|
+
"sessions": ["SESSION_ID_FROM_STEP_1"],
|
|
103
|
+
"page": 1,
|
|
104
|
+
"page_size": 2
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`search_courses` also accepts `course_title`, `course_section_code`,
|
|
109
|
+
`search_course_description`, `campuses`, `delivery_modes`, and `direction` (`asc` or
|
|
110
|
+
`desc`). Pages start at **1**, page size defaults to **20**, and sorting defaults to
|
|
111
|
+
`asc`. Omitted collection filters become empty arrays. Course codes should be exact;
|
|
112
|
+
use autocomplete for prefixes or `course_title` for keyword searches.
|
|
113
|
+
|
|
114
|
+
## Checks
|
|
115
|
+
|
|
116
|
+
```powershell
|
|
117
|
+
uv run --locked pytest -q
|
|
118
|
+
uv run --locked ruff check .
|
|
119
|
+
uv run --locked ruff format --check .
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The tests run offline. They cover all seven tools, request mapping, raw JSON
|
|
123
|
+
preservation, validation, HTTP errors, timeouts, connection errors, invalid JSON,
|
|
124
|
+
shared-client cleanup, MCP discovery, and actual stdio subprocesses.
|
|
125
|
+
|
|
126
|
+
Initial verification: 33 tests passed, and all seven tools returned successful live
|
|
127
|
+
responses from UofT, including an exact-code search with `page_size=2`. Live requests
|
|
128
|
+
are deliberately not part of the test suite, so tests remain reproducible.
|
|
129
|
+
|
|
130
|
+
## API Notes
|
|
131
|
+
|
|
132
|
+
- The supplied [timetable_builder.json](timetable_builder.json) remains the original
|
|
133
|
+
reference. Live checks found two missing details: pagination starts at 1, and
|
|
134
|
+
paginated search requires an empty `departmentProps` array when not filtering by
|
|
135
|
+
department. The wrapper supplies it.
|
|
136
|
+
- Get division codes from the API. For example, the live API uses `ERIN` and `SCAR`
|
|
137
|
+
for Mississauga and Scarborough, rather than the reference's `UTM` and `UTSC` examples.
|
|
138
|
+
- Even one course can have a large response because all its sections are included.
|
|
139
|
+
Choose narrow filters and small page sizes. The wrapper never fetches extra pages.
|
|
140
|
+
- HTTP failures become MCP tool errors containing the endpoint and status code.
|
|
141
|
+
UofT may return HTTP 404 for no matching courses. Timeouts, connection failures,
|
|
142
|
+
and malformed JSON get their own readable errors. No automatic retries occur.
|
|
143
|
+
- This API is not covered by an official support guarantee. Changes upstream may
|
|
144
|
+
require updating the mappings. Successful HTTP responses are preserved as supplied,
|
|
145
|
+
including any application-level status messages inside their JSON.
|
|
146
|
+
|
|
147
|
+
For a walkthrough of the code and how to extend it, read [EXPLAINED.md](EXPLAINED.md).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uoft-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small MCP wrapper for the UofT Timetable Builder API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "SleepyPandas" }]
|
|
12
|
+
keywords = ["mcp", "uoft", "university-of-toronto", "timetable"]
|
|
13
|
+
requires-python = ">=3.13"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"httpx>=0.28,<1",
|
|
16
|
+
"mcp>=2,<3",
|
|
17
|
+
"pydantic>=2.11,<3",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/SleepyPandas/UofT-MCP"
|
|
22
|
+
Issues = "https://github.com/SleepyPandas/UofT-MCP/issues"
|
|
23
|
+
Repository = "https://github.com/SleepyPandas/UofT-MCP"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
uoft-mcp = "uoft_mcp.server:main"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = ["pytest>=8,<10", "ruff>=0.11,<1"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["uoft_mcp"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
target-version = "py313"
|
|
39
|
+
line-length = 100
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "I", "UP"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Verify the installed module speaks MCP over actual process pipes, without network calls."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import pytest
|
|
10
|
+
from mcp import Client
|
|
11
|
+
from mcp.client.stdio import StdioServerParameters
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.anyio
|
|
15
|
+
async def test_sdk_connects_to_stdio_from_another_directory(tmp_path):
|
|
16
|
+
parameters = StdioServerParameters(
|
|
17
|
+
command=sys.executable, args=["-m", "uoft_mcp"], cwd=str(tmp_path)
|
|
18
|
+
)
|
|
19
|
+
with anyio.fail_after(20):
|
|
20
|
+
async with Client(parameters, mode="legacy") as client:
|
|
21
|
+
assert len((await client.list_tools()).tools) == 7
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.anyio
|
|
25
|
+
async def test_stdout_contains_only_protocol_json():
|
|
26
|
+
messages = [
|
|
27
|
+
{
|
|
28
|
+
"jsonrpc": "2.0",
|
|
29
|
+
"id": 1,
|
|
30
|
+
"method": "initialize",
|
|
31
|
+
"params": {
|
|
32
|
+
"protocolVersion": "2025-11-25",
|
|
33
|
+
"capabilities": {},
|
|
34
|
+
"clientInfo": {"name": "stdio-test", "version": "1"},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
38
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
39
|
+
]
|
|
40
|
+
process = await asyncio.create_subprocess_exec(
|
|
41
|
+
sys.executable,
|
|
42
|
+
"-m",
|
|
43
|
+
"uoft_mcp",
|
|
44
|
+
stdin=asyncio.subprocess.PIPE,
|
|
45
|
+
stdout=asyncio.subprocess.PIPE,
|
|
46
|
+
stderr=asyncio.subprocess.PIPE,
|
|
47
|
+
cwd=Path(__file__).resolve().parents[1],
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
for message in messages:
|
|
51
|
+
process.stdin.write((json.dumps(message) + "\n").encode())
|
|
52
|
+
await process.stdin.drain()
|
|
53
|
+
if "id" in message:
|
|
54
|
+
line = await asyncio.wait_for(process.stdout.readline(), timeout=15)
|
|
55
|
+
response = json.loads(line)
|
|
56
|
+
assert response["jsonrpc"] == "2.0"
|
|
57
|
+
assert response["id"] == message["id"]
|
|
58
|
+
assert "result" in response
|
|
59
|
+
process.stdin.close()
|
|
60
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5)
|
|
61
|
+
assert not stdout
|
|
62
|
+
assert process.returncode == 0, stderr.decode()
|
|
63
|
+
finally:
|
|
64
|
+
if process.returncode is None:
|
|
65
|
+
process.kill()
|
|
66
|
+
await process.wait()
|