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.
@@ -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.
@@ -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).
@@ -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,8 @@
1
+ """Run async tests with asyncio, matching the stdio server's runtime."""
2
+
3
+ import pytest
4
+
5
+
6
+ @pytest.fixture
7
+ def anyio_backend():
8
+ return "asyncio"
@@ -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()