Skip to content

feat(mcp): add NUSMods MCP server (mcp.nusmods.com/mcp) - #4457

Open
TheMythologist wants to merge 6 commits into
masterfrom
feat/mcp-server
Open

feat(mcp): add NUSMods MCP server (mcp.nusmods.com/mcp)#4457
TheMythologist wants to merge 6 commits into
masterfrom
feat/mcp-server

Conversation

@TheMythologist

@TheMythologist TheMythologist commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds a new mcp/ pnpm workspace: a Model Context Protocol server that exposes NUS module data to LLM clients (Claude Desktop, Cursor, etc.), intended for deployment at mcp.nusmods.com/mcp. It ships two tools backed entirely by existing public NUSMods infrastructure — no new database or data pipeline. Part of #4173

Why

Students (and their AI assistants) increasingly want to query modules conversationally — "what are the prereqs for CS3230?", "find level-2000 Computing modules with no exam". An MCP server makes NUSMods' open data directly consumable by any MCP-capable LLM, reusing the same CDN JSON and ElasticSearch cluster the website already relies on.

Tools

  • search_modules — keyword search over module code / title / description for the current academic year, with faceted filters mirroring the website's Course Finder: semesters, levels, faculties, departments, gradingBasis, attributes, minCredit/maxCredit, noExam. Filters combine with AND across categories and OR within a category. Returns ranked results with <mark>** highlight snippets.
  • get_module — full details for one module code (case-insensitive, optional acadYear): description, credits, faculty/department, workload, requisites, exam dates, and per-semester timetable.

Both return a human-readable summary in content plus the raw object in structuredContent.

Architecture

  • Transport: Streamable HTTP via @modelcontextprotocol/sdk, in stateless mode (sessionIdGenerator: undefined, enableJsonResponse: true) — a fresh server per request, which suits serverless. Verified empirically that the SDK serves tool calls per-request without a persisted session.
  • Hosting: Vercel serverless function (api/mcp.ts), mirroring the /export deployment model. Rate limiting is handled upstream by Cloudflare, so it is intentionally not implemented here.
  • Data sources (all public, no auth):
    • get_module → v2 JSON CDN (https://api.nusmods.com/v2/{AY}/modules/{CODE}.json)
    • search_modules → the modules_v2 ElasticSearch index (the same cluster the website's Course Finder queries directly from the browser)
  • In-memory TTL cache for static module JSON; vendored module/ES types (with a TODO(shared-types) to extract a shared package later).

Testing

  • 30 Vitest unit tests (offline, fetch mocked): the ES query builder (every filter clause, level normalization, total parsing), URL construction + 404 → ModuleNotFoundError, cache (caching / in-flight de-dup / TTL / no caching of rejections), and the formatters.
  • Live end-to-end validation through a real MCP client against the dev server: initializetools/list → both tools against the real CDN + ES, plus a filter smoke test where every result was programmatically checked (level, faculty, semester, credit range, attributes OR-semantics, noExam).
  • pnpm check (lint + typecheck + test) is green. A new CircleCI mcp job runs lint/typecheck/test on every build.

Not in this PR

  • Deployment — needs a Vercel project bound to mcp.nusmods.com (DNS + env). vercel.json is in place (/mcp → function, / → health check).
  • Future: an ES→moduleInformation.json fallback, venues / faculty-listing / academic-calendar tools, and MCP resources (e.g. nusmods://{ay}/module/{code}).

Maintenance

The only recurring task is bumping the academic year in mcp/src/config.ts; this is documented in the repo-root MAINTENANCE.md alongside the existing app-config.json bump.

TheMythologist and others added 5 commits July 9, 2026 00:34
Add a new `mcp/` pnpm workspace hosting a Model Context Protocol server
for NUS module data, to be deployed at mcp.nusmods.com/mcp.

- Streamable HTTP transport in stateless mode (fresh server per request),
  suited to Vercel serverless (mirrors the /export deployment model).
- `get_module`: full module details from the public v2 JSON CDN.
- `search_modules`: keyword search proxied to the public modules_v2
  ElasticSearch index (the same cluster the website Course Finder uses).
- In-memory TTL cache, vendored module/ES types, human-readable summaries
  plus structuredContent output.
- Local dev server, health check, Vercel + oxlint config.

Rate limiting is handled upstream by Cloudflare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the website's Course Finder facets against the modules_v2
ElasticSearch index:

- semesters (nested semesterData.semester)
- levels (moduleCode.level, thousand form)
- faculties / departments / gradingBasis (.keyword term filters)
- attributes (moduleAttributes.keyword)
- minCredit / maxCredit (numeric moduleCredit range)
- noExam (must_not exists semesterData.examDate)

Filters combine with AND across categories, OR within a category.
Also add ES highlight snippets (rendered as markdown bold) and an
applied-filters summary in the text output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire vitest into the mcp workspace and cover the pure logic:

- cache: caching, in-flight de-dup, TTL expiry, no caching of rejections
- nusmodsApi: URL construction, code upper-casing, 404 -> ModuleNotFoundError
- elastic: query builder (multi_match/match_all, every filter clause, level
  normalisation, total parsing) via a mocked fetch
- format: module summary + search result rendering incl. highlight -> markdown

30 tests, no network. `pnpm check` now runs lint + typecheck + test.
Disable perfectionist/sort-objects for *.test.ts so fixtures can read naturally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the MCP server's `academicYear` update to the existing "PR2" checklist
next to the app-config.json bump: update the default in mcp/src/config.ts
(and mcp/.env.example), and the Vercel override if set. Note it powers
get_module while search_modules is unaffected (modules_v2 is always
current-year). Link the root checklist from mcp/README.md and refresh the
roadmap now that M4 is done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an `mcp` job (mirroring the export job, plus `pnpm test`) that runs
lint, typecheck and the vitest suite, and require it on checkout in the
build_and_test workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
nusmods-export Ignored Ignored Preview Jul 9, 2026 6:16pm
nusmods-website Ignored Ignored Preview Jul 9, 2026 6:16pm

Request Review

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 58.48%. Comparing base (988c6fd) to head (d01e674).
⚠️ Report is 262 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4457      +/-   ##
==========================================
+ Coverage   54.52%   58.48%   +3.96%     
==========================================
  Files         274      316      +42     
  Lines        6076     7294    +1218     
  Branches     1455     1794     +339     
==========================================
+ Hits         3313     4266     +953     
- Misses       2763     3028     +265     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a new mcp/ pnpm workspace implementing a Model Context Protocol server that exposes NUSMods module data to LLM clients via a Vercel serverless function at mcp.nusmods.com/mcp. It reuses the existing public ElasticSearch cluster and CDN JSON API with no new data infrastructure.

  • Two MCP tools (search_modules via ES, get_module via CDN JSON) with an in-memory TTL cache and in-flight deduplication for the module fetch path; both return a human-readable content block plus a structuredContent object.
  • Stateless Streamable HTTP transport (one McpServer + StreamableHTTPServerTransport per request, sessionIdGenerator: undefined, enableJsonResponse: true) suits the serverless deployment model cleanly.
  • 30 Vitest unit tests cover the ES query builder, cache semantics, URL construction, and formatters; a new CircleCI mcp job runs lint/typecheck/test on every build.

Confidence Score: 5/5

Safe to merge — the new workspace is entirely additive, touches no existing app code, and is gated behind a Vercel project that does not yet exist in production.

The core logic (ES query builder, TTL cache, formatters, stateless HTTP handler) is well-tested and straightforward. The two issues found — a missing req.on('error') handler in the dev server and the absence of fetch timeouts on upstream calls — are non-blocking quality concerns that do not affect correctness of the production path.

No files require special attention before merging; the dev server (mcp/src/dev.ts) and fetch calls in mcp/src/data/elastic.ts / mcp/src/data/nusmodsApi.ts have minor hardening opportunities noted in the review comments.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client as MCP Client
    participant Vercel as Vercel Function (api/mcp.ts)
    participant Handler as handleMcpRequest
    participant SDK as MCP SDK (McpServer + Transport)
    participant ES as ElasticSearch (modules_v2)
    participant CDN as NUSMods CDN (api.nusmods.com)
    participant Cache as In-memory TTL cache

    Client->>Vercel: POST /mcp (JSON-RPC)
    Vercel->>Handler: handleMcpRequest(req, res, body)
    Handler->>SDK: createServer() + connect(transport)
    SDK->>Handler: transport.handleRequest(req, res, body)

    alt search_modules tool call
        SDK->>ES: "POST /{index}/_search (bool query + filters)"
        ES-->>SDK: hits + total
        SDK-->>Client: JSON-RPC result (text + structuredContent)
    else get_module tool call
        SDK->>Cache: getCached(url, loader)
        alt cache hit (within TTL)
            Cache-->>SDK: Module JSON
        else cache miss
            Cache->>CDN: "GET /v2/{AY}/modules/{CODE}.json"
            CDN-->>Cache: Module JSON
            Cache-->>SDK: Module JSON
        end
        SDK-->>Client: JSON-RPC result (text + structuredContent)
    end

    Client->>Handler: connection close
    Handler->>SDK: transport.close() + server.close()
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client as MCP Client
    participant Vercel as Vercel Function (api/mcp.ts)
    participant Handler as handleMcpRequest
    participant SDK as MCP SDK (McpServer + Transport)
    participant ES as ElasticSearch (modules_v2)
    participant CDN as NUSMods CDN (api.nusmods.com)
    participant Cache as In-memory TTL cache

    Client->>Vercel: POST /mcp (JSON-RPC)
    Vercel->>Handler: handleMcpRequest(req, res, body)
    Handler->>SDK: createServer() + connect(transport)
    SDK->>Handler: transport.handleRequest(req, res, body)

    alt search_modules tool call
        SDK->>ES: "POST /{index}/_search (bool query + filters)"
        ES-->>SDK: hits + total
        SDK-->>Client: JSON-RPC result (text + structuredContent)
    else get_module tool call
        SDK->>Cache: getCached(url, loader)
        alt cache hit (within TTL)
            Cache-->>SDK: Module JSON
        else cache miss
            Cache->>CDN: "GET /v2/{AY}/modules/{CODE}.json"
            CDN-->>Cache: Module JSON
            Cache-->>SDK: Module JSON
        end
        SDK-->>Client: JSON-RPC result (text + structuredContent)
    end

    Client->>Handler: connection close
    Handler->>SDK: transport.close() + server.close()
Loading

Reviews (2): Last reviewed commit: "test(mcp): clear the module cache betwee..." | Re-trigger Greptile

Comment thread mcp/src/data/nusmodsApi.test.ts Outdated
Comment thread mcp/src/tools/getModule.ts
The in-memory cache is a module-level singleton that persists across
tests; relying on unique module codes per test was fragile. Add a
clearCache() seam and call it in beforeEach so the suite is robust to
test-ordering changes (addresses Greptile review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@TheMythologist

Copy link
Copy Markdown
Contributor Author

The new MCP server is well-structured and tested, but the moduleCredit range filter may silently return wrong results if the ElasticSearch field mapping is string-based rather than numeric — this needs to be confirmed before the filter can be trusted in production.

The moduleCredit range filter sends numeric bounds to a field whose TypeScript type is string; correctness depends entirely on the ES index mapping. If the mapping is keyword, any minCredit/maxCredit query will silently use lexicographic ordering, producing subtly wrong results. The mapping needs to be confirmed before this filter is reliable. The missing outputSchema and test-cache fragility are lower-stakes but real gaps.

The modules_v2 index maps moduleCredit as a numeric short — see scrapers/nus-v2/src/services/io/elastic.ts (moduleCredit: { type: 'short' }) — so the numeric gte/lte bounds compare numerically, not lexicographically. This is the same field the website's Course Finder runs its numeric "Units" range facet on. Verified against the live index too: minCredit: 8 returns only ≥8-MC modules.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant