From b9dce61bce5753e7aab57ec47dc196711757f9a1 Mon Sep 17 00:00:00 2001 From: Apryle Wu Date: Sat, 29 Aug 2026 15:53:32 +0800 Subject: [PATCH 1/2] feat: release sustech-cli 0.10.0 --- CHANGELOG.md | 24 ++ NOTICE.md | 11 + README.md | 22 +- docs/ARCHITECTURE.md | 11 +- docs/MCP.md | 186 +++++++++++ docs/MIGRATION.md | 2 +- docs/ONLINE.md | 69 +++++ docs/SERVICES.md | 1 + package-lock.json | 179 ++++++++++- package.json | 16 +- skills/sustech-cli/SKILL.md | 28 +- src/cli.ts | 130 ++++++++ src/core/argv.ts | 1 + src/core/capabilities.ts | 7 + src/core/command-metadata.ts | 7 + src/core/version.ts | 2 +- src/mcp/prompts.ts | 124 ++++++++ src/mcp/public-tool-names.ts | 27 ++ src/mcp/public-tools.ts | 423 ++++++++++++++++++++++++++ src/mcp/registry.ts | 26 ++ src/mcp/resources.ts | 276 +++++++++++++++++ src/mcp/runner.ts | 224 ++++++++++++++ src/mcp/server.ts | 304 ++++++++++++++++++ src/online/contact-text.ts | 34 +++ src/online/contact.ts | 344 +++++++++++++++++++++ src/online/index.ts | 7 + src/online/search.ts | 69 +++++ src/online/shared.ts | 404 ++++++++++++++++++++++++ src/online/talks-text.ts | 46 +++ src/online/talks.ts | 379 +++++++++++++++++++++++ src/online/types.ts | 70 +++++ src/services/index.ts | 3 + src/services/sustech-online.ts | 31 ++ src/test/argv.test.ts | 3 + src/test/cli.test.ts | 26 +- src/test/dashboard.test.ts | 10 +- src/test/fixtures/empty-cli.ts | 3 + src/test/fixtures/invalid-json-cli.ts | 3 + src/test/fixtures/oversized-cli.ts | 8 + src/test/fixtures/slow-cli.ts | 13 + src/test/mcp-stdio.test.ts | 95 ++++++ src/test/mcp.test.ts | 241 +++++++++++++++ src/test/online-contact.test.ts | 151 +++++++++ src/test/online-talks.test.ts | 255 ++++++++++++++++ 44 files changed, 4267 insertions(+), 28 deletions(-) create mode 100644 docs/MCP.md create mode 100644 docs/ONLINE.md create mode 100644 src/mcp/prompts.ts create mode 100644 src/mcp/public-tool-names.ts create mode 100644 src/mcp/public-tools.ts create mode 100644 src/mcp/registry.ts create mode 100644 src/mcp/resources.ts create mode 100644 src/mcp/runner.ts create mode 100644 src/mcp/server.ts create mode 100644 src/online/contact-text.ts create mode 100644 src/online/contact.ts create mode 100644 src/online/index.ts create mode 100644 src/online/search.ts create mode 100644 src/online/shared.ts create mode 100644 src/online/talks-text.ts create mode 100644 src/online/talks.ts create mode 100644 src/online/types.ts create mode 100644 src/services/sustech-online.ts create mode 100644 src/test/fixtures/empty-cli.ts create mode 100644 src/test/fixtures/invalid-json-cli.ts create mode 100644 src/test/fixtures/oversized-cli.ts create mode 100644 src/test/fixtures/slow-cli.ts create mode 100644 src/test/mcp-stdio.test.ts create mode 100644 src/test/mcp.test.ts create mode 100644 src/test/online-contact.test.ts create mode 100644 src/test/online-talks.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd74c9..46dd40a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +## [0.10.0] - 2026-08-29 + +### Added + +- Added public `online talks` and institutional `online contact` reads from + selected SUSTech Online pages, retaining community authority, source URL, + source update/fetch time, and freshness advisories in structured output. +- Added a local `sustech-mcp` `stdio` server with `33` typed public/local + read-only tools, `5` static JSON resources, `5` JSON resource templates, and + `4` prompts; it requires no hosted service, supports `--help` and + `--version`, and reuses CLI JSON output. + +### Security + +- MCP uses a typed command allowlist and rejects remote mutations, persistent + local TIS-plan edits, authenticated personal data, local private state, + browser/interactive flows, confirmation/output overrides, stdin secrets, + explicit credential files, secret reveal, command changes, timeouts, and + oversized inputs or outputs. Client cancellation now terminates the + underlying CLI subprocess. +- MCP resource templates now validate command names and identifier/path + variables before the CLI subprocess starts, instead of leaving malformed + template values to fail later inside downstream handlers. + ## [0.9.0] - 2026-08-28 ### Added diff --git a/NOTICE.md b/NOTICE.md index c811043..a5b72a2 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -11,3 +11,14 @@ Required Notice: Copyright dumixthestpd (https://github.com/dumixthestpd/sustech The upstream project and this derivative work are distributed under the PolyForm Noncommercial License 1.0.0. No affiliation with or endorsement by Southern University of Science and Technology is implied. + +The optional `online` commands retrieve selected public material from the +community-maintained SUSTech Online project: + +- `SUSTech-CRA/sustech-online-ng` +- + +That upstream material is licensed under Creative Commons Attribution- +ShareAlike 4.0 International. Runtime output retains source links, community +authority labels, and license metadata. No affiliation with or endorsement by +SUSTech CRA is implied. diff --git a/README.md b/README.md index d7a14f1..89698a6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Public data does not require an account: ```bash sustech calendar day 2026-09-01 sustech faculty search "computer vision" +sustech online talks list --limit 10 +sustech online contact search "教学" sustech transit lines sustech library search "graph neural networks" --limit 5 ``` @@ -111,10 +113,15 @@ For an agent without Skill support, provide this short instruction: > secrets, and never add `--confirm` without approval for the exact target. A Skill is the onboarding layer; the CLI remains the executable source of -truth. An MCP server may be useful later for native tool registration or remote -execution, but wrapping every command in MCP now would duplicate the existing -JSON interface. A repository-level `AGENTS.md` alone would only help agents -that cloned the source. +truth. The package also ships a local `stdio` MCP entrypoint, `sustech-mcp`, for +clients that support native tools. It needs no hosted server and exposes `33` +typed public/local read-only tools plus JSON resources, resource templates, and +prompts for discovery, public campus data, library, faculty, transit, NCES, +papers, and selected SUSTech Online reads. Authenticated data, browser flows, +local writes, and remote mutations remain unavailable through MCP. See +[docs/MCP.md](docs/MCP.md) for configuration and the complete boundary. +A repository-level `AGENTS.md` alone would only help agents that cloned the +source. ## What it covers @@ -128,8 +135,9 @@ version's exact command, authentication, network, and confirmation metadata. | TIS | catalog, schedule, grades, exams, TIS-reported degree progress, conservative missing-course report, persistent planning, `tis plan solve/explain/recommend`, local degree audit, live classrooms, iCalendar | CAS login; selection/enrollment writes are confirm-gated | | Blackboard | courses, deadlines, calendar reads, native calendar-link workflow, search, attachment download/sync, attempts, submission | CAS login for REST reads; the native calendar link is a separate stored secret and local writes are guarded | | Library and campus services | Primo catalog search/detail, WS programs, eHall booking, library booking, PMS jobs and usage | Public catalog reads plus authenticated reads; booking and queue writes are confirm-gated | -| Research and courses | Crossref/OA papers, NCES browse and search | Public; OA downloads use guarded local paths; NCES remains community reference only | +| Research and courses | Crossref/OA papers, NCES browse and search, SUSTech Online talks | Public; OA downloads use guarded local paths; NCES and SUSTech Online remain community references only | | Campus and device context | faculty, resources, transit, Wi-Fi status/events | Public or local | +| Community directory | Selected institutional SUSTech Online contacts with provenance and freshness advisories | Public community source; emergency, financial, personal, dining/chat, and professor-list sections are excluded | For the structured TIS-reported `tis degree progress` response, the derived `tis degree missing` report, and how both differ from local JSON @@ -139,6 +147,8 @@ snapshot save/diff/change/watch workflow, see [docs/ACADEMIC_SNAPSHOTS.md](docs/ACADEMIC_SNAPSHOTS.md). For the `tis degree audit` requirements-file format, matching semantics, and current runtime limits, see [docs/DEGREE_AUDIT.md](docs/DEGREE_AUDIT.md). +For the selected SUSTech Online source scope, provenance fields, freshness +labels, and contact exclusions, see [docs/ONLINE.md](docs/ONLINE.md). Remote-state mutations are deliberately limited to these apply commands, all of which require an exact target plus `--confirm`: @@ -178,7 +188,7 @@ review. A successful envelope looks like this: "ok": true, "command": "version", "data": { - "version": "0.9.0", + "version": "0.10.0", "runtime": "node v22.19.0" } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fbbbf85..53b2e3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,8 +33,10 @@ output renderer text | versioned JSON | streaming JSONL missing-course classification, local degree audit, live classroom/context helpers, multi-source ICS export, read-only plan explain/recommend enrichment, and guarded enroll/cart/drop/bid write paths. -- `src/calendar`, `src/faculty`, `src/transit`, `src/resources`, and - `src/wifi` own public or local-only data sources. +- `src/calendar`, `src/faculty`, `src/online`, `src/transit`, `src/resources`, + and `src/wifi` own public or local-only data sources. `src/online` uses exact + path allowlists and retains community provenance, freshness, and license + metadata instead of presenting the source as official. - `src/context` composes a truthful snapshot from whichever sources are available, exposes Context v2 `level`/`live` enrichments, and marks missing or partial inputs explicitly. @@ -53,6 +55,11 @@ output renderer text | versioned JSON | streaming JSONL masking, safe same-origin fetch, and bounded ICS parsing for the stored Blackboard calendar subscription workflow. - `src/core/capabilities.ts` is the machine-discoverable safety registry. +- `src/mcp` exposes a local `stdio` MCP adapter over that registry. It uses a + typed tool allowlist plus JSON resources and prompts, launches the packaged + CLI without a shell, propagates MCP cancellation to the child CLI process, + and rejects authenticated personal data, remote mutations, browser-assisted + flows, local private state, and known local state writes. - Services must not write to stdout or stderr. - Machine-readable output is versioned by `schemaVersion`. - Text is the default; agents opt into `--json` or `--jsonl` explicitly. diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..3c104a9 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,186 @@ +# Local MCP server + +`sustech-cli` ships a local Model Context Protocol entrypoint named +`sustech-mcp`. It is `stdio` only: the client launches a local process and +speaks MCP over standard input and output. There is no hosted endpoint, open +port, background daemon, or shared multi-user server in this repo. + +## Launch + +After a global install, configure the client to launch: + +```text +sustech-mcp +``` + +A typical local-command configuration looks like: + +```json +{ + "mcpServers": { + "sustech": { + "command": "sustech-mcp" + } + } +} +``` + +Without a global install, a client that accepts `command` plus `args` can run +the published package through npm: + +```json +{ + "command": "npm", + "args": ["exec", "--yes", "--package=sustech-cli", "--", "sustech-mcp"] +} +``` + +For a source checkout, run `npm run build` first and point the client at the +absolute path to `dist/mcp/server.js`. + +The entrypoint behavior is intentionally narrow: + +- `sustech-mcp` starts the MCP `stdio` server. +- `sustech-mcp --help` prints local usage text and exits. +- `sustech-mcp --version` prints the installed `sustech-cli` version and exits. +- Any other argument is rejected on stderr with exit code `2`. + +## Tool surface + +The server exposes `33` typed public/local read-only tools. It does not expose +a generic string runner such as `sustech_run`. + +Core metadata: + +- `sustech_discover` +- `sustech_describe` +- `sustech_version` +- `sustech_calendar_day` +- `sustech_consequences` +- `sustech_calendar_terms` +- `sustech_resources_list` +- `sustech_resources_search` +- `sustech_services_status` + +Public research and catalog data: + +- `sustech_papers_search` +- `sustech_nces_browse` +- `sustech_nces_search` +- `sustech_nces_course` +- `sustech_library_search` +- `sustech_library_detail` +- `sustech_library_search_url` + +Public faculty and campus datasets: + +- `sustech_faculty_departments` +- `sustech_faculty_list` +- `sustech_faculty_get` +- `sustech_faculty_search` +- `sustech_faculty_render` +- `sustech_transit_facilities` +- `sustech_transit_find` +- `sustech_transit_lines` +- `sustech_transit_schedule` +- `sustech_transit_stops` +- `sustech_transit_live` + +Public SUSTech Online layer: + +- `sustech_online_search` +- `sustech_online_talks_list` +- `sustech_online_talks_search` +- `sustech_online_talks_get` +- `sustech_online_contact_search` +- `sustech_online_contact_get` + +All tools return the same versioned JSON envelope that the direct CLI already +uses, both as `structuredContent` and as a text fallback. This keeps the CLI as +the installed source of truth while giving MCP clients typed input schemas. + +## Resources and prompts + +The server also exposes JSON resources and reusable prompts. + +Static resources (`5`): + +- `sustech://version` +- `sustech://capabilities` +- `sustech://services` +- `sustech://consequences` +- `sustech://mcp/policy` + +Resource templates (`5`): + +- `sustech://faculty/{slug}` +- `sustech://command/{command}` +- `sustech://online/talk/{id}` +- `sustech://nces/course/{id}` +- `sustech://library/{context}/{docId}` + +Prompts (`4`): + +- `sustech_public_lookup` +- `sustech_guarded_cli_review` +- `sustech_course_research` +- `sustech_talk_digest` + +The static policy resource documents the transport and safety boundary. The +template resources reuse the same typed CLI paths as the tool surface instead +of inventing a second parser. Template variables are validated locally before +the CLI subprocess starts. For commands with spaces, use normal URL encoding, +for example `sustech://command/calendar%20day`. + +## Safety boundary + +This MCP server is intentionally narrower than the CLI. + +- No authenticated personal data is exposed through MCP. +- No local private state is exposed through MCP. +- No local file writes are exposed through MCP. +- No remote mutations are exposed through MCP. +- No browser-assisted or interactive flows are exposed through MCP. +- No generic shell or generic CLI runner is exposed through MCP. + +In practice, that means MCP excludes commands and flags such as: + +- authenticated TIS, Blackboard, booking, library-booking, PMS, profile, and + auth flows; +- `context --live`, `wifi status`, `wifi events`, and other local/private + machine state; +- persistent `tis plan` writes; +- downloads, exports, and other filesystem outputs; +- `--confirm`, `--browser`, `--interactive`, `--credentials-file`, + `--password-stdin`, `--url-stdin`, `--reveal`, and output-mode overrides. + +The execution bridge validates the exact command name, blocks command-changing +arguments, validates resource-template variables before dispatch, launches the +packaged CLI without a shell, enforces size limits, and times out long-running +subprocesses. + +If the MCP client cancels a request, the bridge aborts the underlying +`sustech` subprocess instead of leaving it running in the background. + +Only protocol messages are written to standard output. Diagnostics stay on +standard error so they cannot corrupt the `stdio` stream. + +## When to use the direct CLI instead + +Use the direct `sustech` CLI whenever the task needs any of the following: + +- authenticated campus data; +- remote apply/mutation workflows; +- preview/approval/`--confirm` sequences; +- local exports, downloads, or persistent plan edits; +- browser-assisted library fallback. + +Those paths keep the repo's normal preview, explicit approval, apply, and +read-back verification model. + +## Hosted deployments + +This repo does not ship a hosted HTTP or Streamable HTTP MCP server. If a +future deployment needs cross-machine or shared access, it should be treated as +a separate product surface with its own authentication, authorization, rate +limits, audit logs, and server-side secret handling. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 7fc8b1c..fef115d 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -46,7 +46,7 @@ no real account mutation was attempted while completing this expansion. Every mutation stays unavailable until its preview payload, confirmation gate, success criteria, and post-action verification have fixture tests. As of -preview v0.9.0, guarded remote mutations include TIS enroll/cart/drop/bid, +v0.10.0, guarded remote mutations include TIS enroll/cart/drop/bid, Blackboard submission, eHall and library booking create/cancel, and PMS queue upload/delete. They require an exact typed target, fresh preflight, explicit `--confirm`, and operation-specific read-back; an ambiguous result exits 5 with diff --git a/docs/ONLINE.md b/docs/ONLINE.md new file mode 100644 index 0000000..30221b6 --- /dev/null +++ b/docs/ONLINE.md @@ -0,0 +1,69 @@ +# SUSTech Online public layer + +The `online` commands read a deliberately small subset of the public, +community-maintained [SUSTech Online](https://sustech.online) manual. They do +not require a campus account: + +```bash +sustech online talks list --since 2026-09-01 --limit 20 +sustech online talks search "artificial intelligence" --limit 10 +sustech online talks get 2026-07-30T10-00-00_François_Forget +sustech online contact search "教学" --limit 10 +sustech online contact get teaching:教学工作部 +sustech online search "library" --section contact +``` + +## Authority and freshness + +SUSTech Online is a community source, not an official university system. Every +record retains: + +- `authority: "community"`; +- the public page URL and repository path; +- page update time when the rendered site exposes it; +- the fetch time; +- the upstream `CC-BY-SA-4.0` license and link; +- explicit `COMMUNITY_MAINTAINED`, `AI_PROCESSED_SOURCE`, + `SOURCE_UPDATE_UNKNOWN`, and `STALE_SOURCE` advisories when applicable. + +The talks source itself says its entries are compiled from public information +and processed by a model, so talk results always retain +`AI_PROCESSED_SOURCE`. Contact results do not receive that label unless the +source changes to say so. A missing rendered-page timestamp does not block a +raw public read, but it is reported as `SOURCE_UPDATE_UNKNOWN`. + +Use these records for discovery and convenience. Recheck time-sensitive talk +details and important institutional contacts against the linked official page +before acting. + +## Selected contact scope + +The contact parser is an allowlist, not a full mirror of the source page. It +keeps selected institutional teaching, administration, general service, and +non-dining logistics records. It intentionally excludes: + +- professor email lists; +- medical, safety, emergency, and psychological-crisis sections; +- dining/community-chat and QQ-group lists; +- reimbursement, bank-account, tax, and invoice information; +- postal examples, informal personal notes, and lost-and-found guidance. + +This prevents a general campus search command from becoming an emergency or +financial authority. The CLI does not provide a dedicated emergency command. + +## Network boundary + +The client fetches only the exact allowlisted Markdown files from the public +`SUSTech-CRA/sustech-online-ng` repository and the matching rendered +`sustech.online` page used for update metadata. Redirects are rejected, final +origins and exact paths are checked, document size and timeout are bounded, and +talk identifiers can resolve to only one file in the talks directory. + +Returned institutional links are limited to `sustech.edu.cn` subdomains and +the community site. Poster links are limited to those hosts plus the exact +image-mirror host currently used by the upstream talks archive; unrelated, +social, document-sharing, and deceptive lookalike domains are omitted. + +Rendered-page metadata is optional; raw Markdown is required. Tests use frozen +synthetic fixtures and do not copy the upstream contact or talks content into +the package. diff --git a/docs/SERVICES.md b/docs/SERVICES.md index bf397d9..bd7d12c 100644 --- a/docs/SERVICES.md +++ b/docs/SERVICES.md @@ -29,6 +29,7 @@ while the CLI already supplies that transport for a specific command family. | `pms` | `implemented` | PMS auth token, RSA login, OSESSIONID cookie, campus reachability | `pms check`, `pms server-groups`, `pms stations`, `pms jobs`, `pms scan-jobs`, `pms usage`, `pms upload preview/apply`, `pms delete preview/apply` | CLI performs the PMS auth flow directly, keeps OSESSIONID in memory, and uses transient RSA login material. Queue mutations are fixture-tested only. A first browser-side account link may still be needed on some accounts. | | `nces` | `implemented` | none | `nces browse`, `nces search`, `nces course` | Public HTTP API backed by `ncesnext.com`; callers should avoid aggressive polling. | | `papers` | `implemented` | none | `papers search`, `papers fetch-oa` | Uses CrossRef bibliographic relevance plus optional Unpaywall resolution. OA downloads require an explicit guarded destination and validate redirects, PDF bytes, size, and SHA-256. | +| `sustech-online` | `implemented` | none | `online search`, `online talks list/search/get`, `online contact search/get` | Reads exact allowlisted public Markdown and optional rendered-page freshness metadata. Results remain community-labelled and CC BY-SA attributed; high-stakes, financial, personal, dining/chat, and professor-list contact sections are excluded. | ## Authenticated transport guards diff --git a/package-lock.json b/package-lock.json index 3c935c4..8236a65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,25 @@ { "name": "sustech-cli", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sustech-cli", - "version": "0.9.0", + "version": "0.10.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", "@napi-rs/keyring": "1.3.0", - "playwright-core": "^1.62.1" + "playwright-core": "^1.62.1", + "zod": "^4.5.2" }, "bin": { - "sustech": "dist/cli.js" + "sustech": "dist/cli.js", + "sustech-mcp": "dist/mcp/server.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^22.15.30", "typescript": "^5.8.3" }, @@ -23,6 +27,50 @@ "node": ">=20.18.0" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -252,6 +300,81 @@ "undici-types": "~6.21.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright-core": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", @@ -264,6 +387,29 @@ "node": ">=20" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -284,6 +430,31 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.2.tgz", + "integrity": "sha512-XkYXCol10+ba/6F/cueWV+TezUeOqXW0hdeJt5CdXjTYeAgAQg5N03RQdJ80mhfFE72+pblvYMW4wy2Qp4Qbrg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 5ee4b8c..d909e19 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "sustech-cli", - "version": "0.9.0", + "version": "0.10.0", "description": "Human-friendly and agent-ready command-line access to SUSTech services.", "license": "PolyForm-Noncommercial-1.0.0", "type": "module", "bin": { - "sustech": "dist/cli.js" + "sustech": "dist/cli.js", + "sustech-mcp": "dist/mcp/server.js" }, "files": [ "dist/cli.d.ts", @@ -20,6 +21,8 @@ "dist/core", "dist/doctor", "dist/faculty", + "dist/mcp", + "dist/online", "dist/profile", "dist/resources", "dist/services", @@ -46,6 +49,8 @@ "sustech", "cli", "agent", + "mcp", + "sustech-online", "tis", "course-selection" ], @@ -54,7 +59,7 @@ }, "scripts": { "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc --project tsconfig.json", - "postbuild": "node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"", + "postbuild": "node -e \"const fs=require('node:fs'); for (const file of ['dist/cli.js','dist/mcp/server.js']) fs.chmodSync(file, 0o755)\"", "check": "tsc --noEmit --project tsconfig.json", "prepare": "npm run build", "start": "node dist/cli.js", @@ -63,11 +68,14 @@ }, "packageManager": "npm@10.9.3", "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^22.15.30", "typescript": "^5.8.3" }, "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", "@napi-rs/keyring": "1.3.0", - "playwright-core": "^1.62.1" + "playwright-core": "^1.62.1", + "zod": "^4.5.2" } } diff --git a/skills/sustech-cli/SKILL.md b/skills/sustech-cli/SKILL.md index f379d2b..aa78dc2 100644 --- a/skills/sustech-cli/SKILL.md +++ b/skills/sustech-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: sustech-cli -description: Use the installed sustech CLI for SUSTech campus reads, planning, guarded exports, and confirm-gated workflows across TIS, Blackboard, profile/context, booking, library booking, PMS, transit, faculty, papers, and NCES. Do not use for unrelated universities or invent commands the installed CLI does not report. +description: Use the installed sustech CLI for SUSTech campus reads, planning, guarded exports, and confirm-gated workflows across TIS, Blackboard, profile/context, booking, library booking, PMS, transit, faculty, papers, NCES, and selected SUSTech Online public information. Do not use for unrelated universities or invent commands the installed CLI does not report. --- # SUSTech CLI @@ -35,7 +35,8 @@ includes these high-value areas: `resources list`, `resources search`, `wifi status`, `wifi events`, `faculty departments`, `faculty list`, `faculty get`, `faculty search`, `faculty render`, `transit facilities`, `transit find`, `transit lines`, - `transit schedule`, `transit stops`, `transit live`. + `transit schedule`, `transit stops`, `transit live`, `online search`, + `online talks list/search/get`, `online contact search/get`. - Academic profile and audits: `profile show`, `profile export`, `academic snapshot save`, `academic changes`, `academic watch`, `doctor`. - Research helpers: `papers search`, `papers fetch-oa`, `nces browse`, @@ -100,6 +101,29 @@ Some useful routing hints: - `library search` and `library detail` are read-only Primo catalog commands. Use `--browser` or `--browser --interactive` when the direct public HTTP path cannot complete on the current host. Browser auth stays manual. +- Treat every `online` result as community-maintained. Preserve its source URL, + repository path, fetch/update times, CC BY-SA license, and advisories. Talk + records may be model-processed. The selected contact surface deliberately + excludes emergency, medical/crisis, financial/bank, personal, dining/chat, + QQ-group, and professor-email-list sections; do not use it as an emergency + directory or invent excluded records. Recheck consequential contact or event + details against the linked official source. + +## Use the local MCP surface when present + +Some installations expose `sustech-mcp` as a local `stdio` server. It requires +no hosted service and provides a stable typed allowlist plus JSON resources and +prompts. Start with `sustech_discover`, `sustech_describe`, or the +`sustech://mcp/policy` resource. Then prefer the dedicated public tools for +calendar, resources, services status, papers, NCES, library, faculty, transit, +and `sustech_online_*` when their schemas match the request. + +There is intentionally no generic MCP shell/run tool and no MCP mutation tool. +Do not try to route `auth login`, `* apply`, exports/downloads, persistent TIS +plan edits, confirmation flags, secret reveal, credentials files, or +interactive browser flows through MCP. MCP also excludes authenticated personal +data plus local/private machine state such as Wi-Fi and live Context. Use the +direct CLI and the approval workflow below when state must change. ## Consume output safely diff --git a/src/cli.ts b/src/cli.ts index ce09122..a1f7c44 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -82,6 +82,20 @@ import { import { formatDoctorReport } from "./doctor/text.js"; import { FacultyClient } from "./faculty/client.js"; import { formatDepartments, formatFaculty } from "./faculty/text.js"; +import { + formatOnlineContact, + formatOnlineContactSearch, + formatOnlineSearchHits, + formatOnlineTalk, + formatOnlineTalkSearch, + formatOnlineTalks, + getOnlineContact, + getOnlineTalk, + listOnlineTalks, + searchOnline, + searchOnlineContacts, + searchOnlineTalks, +} from "./online/index.js"; import { searchResources, type ResourceCategory } from "./resources/catalog.js"; import { formatResources } from "./resources/text.js"; import { @@ -333,6 +347,12 @@ Usage: sustech faculty get SLUG sustech faculty search QUERY [--department DEPARTMENT] [--limit N] sustech faculty render SLUG + sustech online search QUERY [--section talks|contact] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] + sustech online talks list [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] + sustech online talks search QUERY [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] + sustech online talks get ID + sustech online contact search QUERY [--limit N] + sustech online contact get ID sustech context [--date YYYY-MM-DD] [--calendar-level undergraduate|graduate] [--level terse|normal|verbose] [--live] [--credentials-file PATH] sustech profile show [--profile NAME] [--credentials-file PATH] sustech profile export --destination PATH [--overwrite] [--profile NAME] [--credentials-file PATH] @@ -499,6 +519,7 @@ type Values = OutputFlags & { full?: boolean; minutes?: string; category?: string; + section?: string; page?: string; "page-size"?: string; sort?: string; @@ -669,6 +690,10 @@ async function main(argv: string[]): Promise { await runFaculty(parsed.positionals, values, output); return; } + if (group === "online") { + await runOnline(parsed.positionals, values, output); + return; + } if (group === "context") { await runContext(parsed.positionals, values, output); return; @@ -2256,6 +2281,111 @@ async function runFaculty( throw usageError(`Unknown command: ${positionals.join(" ")}`); } +async function runOnline( + positionals: string[], + values: Values, + output: ReturnType, +): Promise { + const section = positionals[1]; + const operation = positionals[2]; + const limit = parsePositiveInteger(values.limit, 20, "--limit"); + if (limit > 200) throw usageError("--limit cannot exceed 200 for SUSTech Online queries."); + const since = values.since === undefined ? undefined : isoDate(values.since, "--since"); + const until = values.until === undefined ? undefined : isoDate(values.until, "--until"); + if (since && until && since > until) throw usageError("--since cannot be later than --until."); + const meta = { + authority: "community", + official: false, + project: "SUSTech Online", + license: "CC-BY-SA-4.0", + }; + + if (section === "search") { + const query = positionals.slice(2).join(" ").trim(); + if (!query) throw usageError("A SUSTech Online search query is required."); + const selectedSection = onlineSection(values.section); + if (selectedSection === "contact" && (since || until)) { + throw usageError("--since and --until apply only to talk searches."); + } + const hits = await searchOnline(query, { + section: selectedSection, + since, + until, + limit, + }); + writeSuccess({ + command: "online search", + data: { query, section: selectedSection ?? "all", hits, total: hits.length }, + text: formatOnlineSearchHits(hits, query), + items: hits, + summary: { query, section: selectedSection ?? "all", total: hits.length }, + meta, + }, output); + return; + } + + if (section === "talks" && operation === "list" && positionals.length === 3) { + const talks = await listOnlineTalks({ since, until, limit }); + writeSuccess({ + command: "online talks list", + data: { since, until, talks, total: talks.length }, + text: formatOnlineTalks(talks), + items: talks, + summary: { since, until, total: talks.length }, + meta, + }, output); + return; + } + if (section === "talks" && operation === "search") { + const query = positionals.slice(3).join(" ").trim(); + if (!query) throw usageError("A talk search query is required."); + const talks = await searchOnlineTalks(query, { since, until, limit }); + writeSuccess({ + command: "online talks search", + data: { query, since, until, talks, total: talks.length }, + text: formatOnlineTalkSearch(talks, query), + items: talks, + summary: { query, since, until, total: talks.length }, + meta, + }, output); + return; + } + if (section === "talks" && operation === "get" && positionals.length === 4) { + const talk = await getOnlineTalk(required(positionals[3], "talk id")); + writeSuccess({ command: "online talks get", data: talk, text: formatOnlineTalk(talk), meta }, output); + return; + } + + if (section === "contact" && operation === "search") { + const query = positionals.slice(3).join(" ").trim(); + if (!query) throw usageError("A contact search query is required."); + const contacts = await searchOnlineContacts(query, { limit }); + writeSuccess({ + command: "online contact search", + data: { query, contacts, total: contacts.length }, + text: formatOnlineContactSearch(contacts, query), + items: contacts, + summary: { query, total: contacts.length }, + meta, + }, output); + return; + } + if (section === "contact" && operation === "get" && positionals.length >= 4) { + const identifier = positionals.slice(3).join(" ").trim(); + const contact = await getOnlineContact(required(identifier, "contact id")); + writeSuccess({ command: "online contact get", data: contact, text: formatOnlineContact(contact), meta }, output); + return; + } + + throw usageError(`Unknown command: ${positionals.join(" ")}`); +} + +function onlineSection(value?: string): "talks" | "contact" | undefined { + if (value === undefined) return undefined; + if (value === "talks" || value === "contact") return value; + throw usageError("--section must be talks or contact."); +} + async function runProfile( positionals: string[], values: Values, diff --git a/src/core/argv.ts b/src/core/argv.ts index 3a8009a..7754ec8 100644 --- a/src/core/argv.ts +++ b/src/core/argv.ts @@ -24,6 +24,7 @@ export function inferCommandName(argv: string[]): string { if (!command) return group; if ( (group === "tis" && ["courses", "enroll", "classroom", "selection", "bid", "plan", "degree"].includes(command)) + || (group === "online" && ["talks", "contact"].includes(command)) || (group === "academic" && command === "snapshot") || (group === "bb" && ["submit", "calendar-link"].includes(command)) || (group === "pms" && (command === "upload" || command === "delete")) diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 37cb079..deb89d9 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -13,6 +13,7 @@ export interface Capability { export const CAPABILITIES: readonly Capability[] = [ capability("version", "Show CLI and runtime versions.", "local"), capability("capabilities", "List the machine-discoverable command surface.", "local"), + capability("describe", "Describe one installed command's exact usage, options, capability, and consequences.", "local"), capability("consequences", "List structured risks and verification rules for real-state mutations.", "local"), capability("doctor", "Inspect runtime, credential storage, and optional live service authentication without mutating remote state.", "read", { authentication: "selected-service", status: "preview" }), capability("calendar terms", "Read semester boundaries from the public academic-calendar dataset.", "read"), @@ -22,6 +23,12 @@ export const CAPABILITIES: readonly Capability[] = [ capability("faculty get", "Read one public faculty profile.", "read", { status: "preview" }), capability("faculty search", "Search public faculty profile fields.", "read", { status: "preview" }), capability("faculty render", "Render a public faculty profile as Agent-readable Markdown.", "read", { status: "preview" }), + capability("online search", "Search selected public community-maintained SUSTech Online content with source and freshness metadata.", "read", { status: "preview" }), + capability("online talks list", "List public SUSTech talks indexed by the community-maintained SUSTech Online repository.", "read", { status: "preview" }), + capability("online talks search", "Search public SUSTech talks indexed by the community-maintained SUSTech Online repository.", "read", { status: "preview" }), + capability("online talks get", "Read one exact public SUSTech talk with community-source provenance.", "read", { status: "preview" }), + capability("online contact search", "Search institutional public contacts selected from the community-maintained SUSTech Online repository.", "read", { status: "preview" }), + capability("online contact get", "Read one exact institutional public contact with community-source provenance.", "read", { status: "preview" }), capability("context", "Compose a truthful current-date snapshot and optional live academic context with per-source availability.", "read", { status: "preview" }), capability("profile show", "Read a whitelisted student-profile summary with independent source statuses instead of raw upstream payloads.", "read", { authentication: "sustech-cas", status: "preview" }), capability("profile export", "Export the whitelisted student-profile report as versioned local JSON at an explicit guarded destination.", "mutation", { authentication: "sustech-cas", status: "preview" }), diff --git a/src/core/command-metadata.ts b/src/core/command-metadata.ts index c49ded5..1f7f931 100644 --- a/src/core/command-metadata.ts +++ b/src/core/command-metadata.ts @@ -48,6 +48,7 @@ export const CLI_PARSE_OPTIONS = { full: { type: "boolean", default: false }, minutes: { type: "string" }, category: { type: "string" }, + section: { type: "string" }, page: { type: "string" }, "page-size": { type: "string" }, sort: { type: "string" }, @@ -129,6 +130,12 @@ export const COMMAND_OPTIONS: Readonly> "academic watch": ["credentials-file", "semester", "state", "include-blackboard", "overwrite"], "faculty list": ["full", "limit"], "faculty search": ["department", "limit"], + "online search": ["section", "since", "until", "limit"], + "online talks list": ["since", "until", "limit"], + "online talks search": ["since", "until", "limit"], + "online talks get": [], + "online contact search": ["limit"], + "online contact get": [], context: ["date", "calendar-level", "level", "live", "credentials-file"], "profile show": ["credentials-file", "profile"], "profile export": ["credentials-file", "profile", "destination", "overwrite"], diff --git a/src/core/version.ts b/src/core/version.ts index f81ff31..36f3aa8 100644 --- a/src/core/version.ts +++ b/src/core/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = "0.9.0"; +export const CLI_VERSION = "0.10.0"; export const USER_AGENT = `sustech-cli/${CLI_VERSION} (+https://github.com/wormforce/sustech-cli)`; diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts new file mode 100644 index 0000000..1c92a14 --- /dev/null +++ b/src/mcp/prompts.ts @@ -0,0 +1,124 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; + +const ISO_DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD."); +const QUESTION = z.string().trim().min(1).max(1_000); +const TASK = z.string().trim().min(1).max(1_000); +const COMMAND = z.string().trim().min(1).max(200); +const COURSE = z.string().trim().min(1).max(200); +const PUBLIC_FOCI = ["auto", "calendar", "resources", "faculty", "online", "papers", "nces", "library", "transit"] as const; + +export function registerSustechMcpPrompts(server: McpServer): void { + server.registerPrompt( + "sustech_public_lookup", + { + title: "SUSTech public lookup", + description: "Guide a model to answer a SUSTech question with the public sustech MCP surface.", + argsSchema: z.object({ + question: QUESTION, + focus: z.enum(PUBLIC_FOCI).optional(), + date: ISO_DATE.optional(), + }), + }, + ({ question, focus, date }) => ({ + messages: [{ + role: "user" as const, + content: { + type: "text" as const, + text: [ + "Answer the following SUSTech question using the public sustech MCP surface.", + `Question: ${question}`, + `Preferred focus: ${focus ?? "auto"}`, + `Reference date: ${date ?? "today"}`, + "Prefer typed MCP tools and resources over free-form guessing.", + "Preserve source provenance and freshness metadata when a tool returns it.", + "Treat SUSTech Online as community-maintained rather than official university authority.", + ].join("\n"), + }, + }], + }), + ); + + server.registerPrompt( + "sustech_guarded_cli_review", + { + title: "SUSTech guarded CLI review", + description: "Guide a model to classify a SUSTech task against the MCP boundary and the direct CLI safety workflow.", + argsSchema: z.object({ + task: TASK, + command: COMMAND.optional(), + }), + }, + ({ task, command }) => ({ + messages: [{ + role: "user" as const, + content: { + type: "text" as const, + text: [ + "Review this SUSTech task against the local MCP safety boundary.", + `Task: ${task}`, + `Candidate command: ${command ?? "not yet chosen"}`, + "Use sustech_discover, sustech_describe, sustech_consequences, and the MCP policy resource to classify the safest path.", + "If the task requires authenticated personal data, browser-assisted reads, local file writes, or remote mutations, say that it must continue through the direct sustech CLI.", + "If a mutation is needed, require preview, explicit approval, --confirm, and read-back verification.", + ].join("\n"), + }, + }], + }), + ); + + server.registerPrompt( + "sustech_course_research", + { + title: "SUSTech course research", + description: "Guide a model to research one course from public sources without treating community reviews as official requirements.", + argsSchema: z.object({ + course: COURSE, + question: QUESTION.optional(), + }), + }, + ({ course, question }) => ({ + messages: [{ + role: "user" as const, + content: { + type: "text" as const, + text: [ + `Research the SUSTech course ${course} using only the public sustech MCP tools and resources.`, + `Question: ${question ?? "Summarize the available public evidence and unresolved points."}`, + "Use NCES as community evidence, not as an official course or degree authority.", + "Label the source of every recommendation and leave missing or conflicting facts unresolved.", + "For cultivation-plan or graduation requirements, direct the user to the applicable official plan or authenticated TIS view.", + ].join("\n"), + }, + }], + }), + ); + + server.registerPrompt( + "sustech_talk_digest", + { + title: "SUSTech public talk digest", + description: "Guide a model to find and summarize public SUSTech talks while retaining provenance and freshness caveats.", + argsSchema: z.object({ + query: QUESTION.optional(), + since: ISO_DATE.optional(), + until: ISO_DATE.optional(), + }), + }, + ({ query, since, until }) => ({ + messages: [{ + role: "user" as const, + content: { + type: "text" as const, + text: [ + "Build a concise digest from the selected public SUSTech Online talks index.", + `Topic: ${query ?? "all relevant talks"}`, + `Date range: ${since ?? "unbounded"} to ${until ?? "unbounded"}`, + "Use the typed talks tools, preserve source URLs and fetched/updated timestamps, and distinguish future from past events.", + "State that the index is community-maintained and do not infer missing venue, speaker, or schedule details.", + ].join("\n"), + }, + }], + }), + ); +} diff --git a/src/mcp/public-tool-names.ts b/src/mcp/public-tool-names.ts new file mode 100644 index 0000000..dd8e6c5 --- /dev/null +++ b/src/mcp/public-tool-names.ts @@ -0,0 +1,27 @@ +export const PUBLIC_MCP_TOOL_BY_COMMAND = { + consequences: "sustech_consequences", + "calendar terms": "sustech_calendar_terms", + "resources list": "sustech_resources_list", + "resources search": "sustech_resources_search", + "services status": "sustech_services_status", + "papers search": "sustech_papers_search", + "nces browse": "sustech_nces_browse", + "nces search": "sustech_nces_search", + "nces course": "sustech_nces_course", + "library search": "sustech_library_search", + "library detail": "sustech_library_detail", + "library search-url": "sustech_library_search_url", + "faculty departments": "sustech_faculty_departments", + "faculty list": "sustech_faculty_list", + "faculty get": "sustech_faculty_get", + "faculty search": "sustech_faculty_search", + "faculty render": "sustech_faculty_render", + "transit facilities": "sustech_transit_facilities", + "transit find": "sustech_transit_find", + "transit lines": "sustech_transit_lines", + "transit schedule": "sustech_transit_schedule", + "transit stops": "sustech_transit_stops", + "transit live": "sustech_transit_live", +} as const; + +export type PublicMcpExposedCommand = keyof typeof PUBLIC_MCP_TOOL_BY_COMMAND; diff --git a/src/mcp/public-tools.ts b/src/mcp/public-tools.ts new file mode 100644 index 0000000..8aa701d --- /dev/null +++ b/src/mcp/public-tools.ts @@ -0,0 +1,423 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { CONSEQUENCES } from "../core/consequences.js"; +import { DEPARTMENTS } from "../faculty/client.js"; +import type { ResourceCategory } from "../resources/catalog.js"; +import { runCliForMcp } from "./runner.js"; +import { PUBLIC_MCP_TOOL_BY_COMMAND } from "./public-tool-names.js"; + +const YEAR = z.number().int().min(2000).max(2100); +const QUERY = z.string().trim().min(1).max(500); +const IDENTIFIER = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,160}$/, "Unsupported identifier format."); +const LINE_NAME = z.string().trim().min(1).max(160); +const PRIMO_SEGMENT = z.string().trim().min(1).max(500) + .refine((value) => !/[/?#]/u.test(value), "Primo identifiers cannot contain path separators or URL fragments."); +const RESOURCE_CATEGORIES = ["official", "academic", "maps", "papers", "community"] as const satisfies readonly ResourceCategory[]; +const TRANSIT_DAY_TYPES = ["workday", "holiday"] as const; +const NCES_SORTS = ["rating", "reviews", "name"] as const; +const FACULTY_LIMIT = z.number().int().min(1).max(200); +const TRANSIT_LIMIT = z.number().int().min(1).max(100); +const PAPERS_MAX = z.number().int().min(1).max(100); +const NCES_PAGE = z.number().int().min(1).max(10_000); +const NCES_PAGE_SIZE = z.number().int().min(1).max(50); +const LIBRARY_LIMIT = z.number().int().min(1).max(50); +const TRANSIT_ROUTE_INDEX = z.number().int().min(0).max(100); +const TRANSIT_DIRECTION = z.number().int().min(0).max(1); +const SERVICE_NAMES = [ + "blackboard", + "booking", + "library-catalog", + "library-booking", + "ws", + "pms", + "nces", + "papers", + "sustech-online", +] as const; +const CONSEQUENCE_OPERATIONS = CONSEQUENCES.map((entry) => entry.operation) as [string, ...string[]]; + +export function registerPublicMcpTools(server: McpServer): void { + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND.consequences, + { + title: "List SUSTech CLI consequence rules", + description: "Return structured risk and verification rules for one exact consequence-rich operation or the full registry.", + inputSchema: z.object({ + operation: z.enum(CONSEQUENCE_OPERATIONS).optional(), + }), + annotations: readOnlyAnnotations(false), + }, + async ({ operation }, ctx) => runTypedCommand("consequences", operation === undefined ? [] : [operation], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["calendar terms"], + { + title: "Read SUSTech academic terms", + description: "Read public semester boundaries for one academic-calendar year and level.", + inputSchema: z.object({ + year: YEAR.optional(), + level: z.enum(["undergraduate", "graduate"]).optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ year, level }, ctx) => runTypedCommand("calendar terms", [ + ...numberOption("--year", year), + ...option("--calendar-level", level), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["resources list"], + { + title: "List built-in campus resources", + description: "List the built-in resource registry, optionally filtered by category.", + inputSchema: z.object({ + category: z.enum(RESOURCE_CATEGORIES).optional(), + }), + annotations: readOnlyAnnotations(false), + }, + async ({ category }, ctx) => runTypedCommand("resources list", option("--category", category), ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["resources search"], + { + title: "Search built-in campus resources", + description: "Search the built-in resource registry by keyword and optional category.", + inputSchema: z.object({ + query: QUERY, + category: z.enum(RESOURCE_CATEGORIES).optional(), + }), + annotations: readOnlyAnnotations(false), + }, + async ({ query, category }, ctx) => runTypedCommand("resources search", [query, ...option("--category", category)], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["services status"], + { + title: "Read service adapter availability", + description: "Report which built-in service adapters are implemented, preview-only, or unavailable.", + inputSchema: z.object({ + service: z.enum(SERVICE_NAMES).optional(), + }), + annotations: readOnlyAnnotations(false), + }, + async ({ service }, ctx) => runTypedCommand("services status", service === undefined ? [] : [service], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["papers search"], + { + title: "Search public paper metadata", + description: "Search public CrossRef paper metadata with optional minimum year and open-access filtering.", + inputSchema: z.object({ + query: QUERY, + max: PAPERS_MAX.optional(), + minYear: z.number().int().min(1900).max(new Date().getFullYear() + 1).optional(), + openAccessOnly: z.boolean().optional(), + resolveOpenAccess: z.boolean().optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ query, max, minYear, openAccessOnly, resolveOpenAccess }, ctx) => runTypedCommand("papers search", [ + query, + ...numberOption("--max", max), + ...numberOption("--min-year", minYear), + ...(openAccessOnly ? ["--open-access"] : []), + ...(resolveOpenAccess ? ["--resolve-oa"] : []), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces browse"], + { + title: "Browse NCES community course evaluations", + description: "Browse public NCES courses by page, page size, and supported sort order.", + inputSchema: z.object({ + page: NCES_PAGE.optional(), + pageSize: NCES_PAGE_SIZE.optional(), + sort: z.enum(NCES_SORTS).optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ page, pageSize, sort }, ctx) => runTypedCommand("nces browse", [ + ...numberOption("--page", page), + ...numberOption("--page-size", pageSize), + ...option("--sort", sort), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces search"], + { + title: "Search NCES courses", + description: "Search public NCES courses and review samples by keyword.", + inputSchema: z.object({ + query: QUERY, + }), + annotations: readOnlyAnnotations(true), + }, + async ({ query }, ctx) => runTypedCommand("nces search", [query], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces course"], + { + title: "Read one NCES course", + description: "Read one public NCES course and its reviews by numeric course identifier.", + inputSchema: z.object({ + id: z.number().int().min(1), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ id }, ctx) => runTypedCommand("nces course", [String(id)], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["library search"], + { + title: "Search the public library catalog", + description: "Search public Primo metadata through the normalized JSON path without opening a browser session.", + inputSchema: z.object({ + query: QUERY, + limit: LIBRARY_LIMIT.optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ query, limit }, ctx) => runTypedCommand("library search", [ + query, + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["library detail"], + { + title: "Read one library catalog record", + description: "Read one exact public Primo record by CONTEXT:DOC_ID reference.", + inputSchema: z.object({ + context: PRIMO_SEGMENT, + docId: PRIMO_SEGMENT, + }), + annotations: readOnlyAnnotations(true), + }, + async ({ context, docId }, ctx) => runTypedCommand("library detail", [`${context}:${docId}`], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["library search-url"], + { + title: "Build a library discovery URL", + description: "Build a browser handoff URL for Primo without fabricating catalog results.", + inputSchema: z.object({ + query: QUERY, + limit: LIBRARY_LIMIT.optional(), + }), + annotations: readOnlyAnnotations(false), + }, + async ({ query, limit }, ctx) => runTypedCommand("library search-url", [ + query, + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["faculty departments"], + { + title: "List public faculty departments", + description: "List the known public SUSTech faculty departments.", + inputSchema: z.object({}), + annotations: readOnlyAnnotations(false), + }, + async (_input, ctx) => runTypedCommand("faculty departments", [], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["faculty list"], + { + title: "List faculty profiles in one department", + description: "List public faculty profiles in one exact department, with optional full-profile expansion.", + inputSchema: z.object({ + department: z.enum(DEPARTMENTS), + full: z.boolean().optional(), + limit: FACULTY_LIMIT.optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ department, full, limit }, ctx) => runTypedCommand("faculty list", [ + department, + ...(full ? ["--full"] : []), + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["faculty get"], + { + title: "Read one faculty profile", + description: "Read one public faculty profile by exact slug.", + inputSchema: z.object({ + slug: IDENTIFIER, + }), + annotations: readOnlyAnnotations(true), + }, + async ({ slug }, ctx) => runTypedCommand("faculty get", [slug], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["faculty search"], + { + title: "Search faculty profiles", + description: "Search public faculty profiles by keyword, with an optional exact department filter.", + inputSchema: z.object({ + query: QUERY, + department: z.enum(DEPARTMENTS).optional(), + limit: FACULTY_LIMIT.optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ query, department, limit }, ctx) => runTypedCommand("faculty search", [ + query, + ...option("--department", department), + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["faculty render"], + { + title: "Render one faculty profile as Markdown", + description: "Render one public faculty profile into agent-readable Markdown by exact slug.", + inputSchema: z.object({ + slug: IDENTIFIER, + }), + annotations: readOnlyAnnotations(true), + }, + async ({ slug }, ctx) => runTypedCommand("faculty render", [slug], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["transit facilities"], + { + title: "List campus facilities and gates", + description: "List public campus buildings and gates from the transit datasets.", + inputSchema: z.object({}), + annotations: readOnlyAnnotations(true), + }, + async (_input, ctx) => runTypedCommand("transit facilities", [], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["transit find"], + { + title: "Search campus facilities and stops", + description: "Search public campus buildings, gates, and bus stops by keyword.", + inputSchema: z.object({ + query: QUERY, + limit: TRANSIT_LIMIT.optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ query, limit }, ctx) => runTypedCommand("transit find", [ + query, + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["transit lines"], + { + title: "List public bus lines", + description: "List public campus bus lines for workdays or holidays.", + inputSchema: z.object({ + dayType: z.enum(TRANSIT_DAY_TYPES).optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ dayType }, ctx) => runTypedCommand("transit lines", option("--day", dayType), ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["transit schedule"], + { + title: "Read a public bus schedule", + description: "Read departures for one exact public bus line and optional sub-route index.", + inputSchema: z.object({ + lineId: LINE_NAME, + routeIndex: TRANSIT_ROUTE_INDEX.optional(), + dayType: z.enum(TRANSIT_DAY_TYPES).optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ lineId, routeIndex, dayType }, ctx) => runTypedCommand("transit schedule", [ + lineId, + ...numberOption("--route-index", routeIndex), + ...option("--day", dayType), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["transit stops"], + { + title: "List public stops for one route", + description: "List public stops for one exact live route code and direction.", + inputSchema: z.object({ + line: LINE_NAME, + direction: TRANSIT_DIRECTION.optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ line, direction }, ctx) => runTypedCommand("transit stops", [ + line, + ...numberOption("--direction", direction), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["transit live"], + { + title: "Read live campus bus positions", + description: "Read public live campus bus positions from the transit dataset.", + inputSchema: z.object({}), + annotations: readOnlyAnnotations(true), + }, + async (_input, ctx) => runTypedCommand("transit live", [], ctx.mcpReq.signal), + ); +} + +function toolResult(value: Record, isError = false) { + return { + content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], + structuredContent: value, + ...(isError ? { isError: true } : {}), + }; +} + +function toolError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const code = /^([A-Z][A-Z0-9_]+):/u.exec(message)?.[1] ?? "MCP_REQUEST_REJECTED"; + return toolResult({ ok: false, error: { code, message } }, true); +} + +async function runTypedCommand(command: string, args: string[], signal?: AbortSignal) { + try { + const result = await runCliForMcp(command, args, { signal }); + return toolResult(result.envelope, result.exitCode !== 0 || result.envelope.ok === false); + } catch (error) { + return toolError(error); + } +} + +function option(name: string, value: string | undefined): string[] { + return value === undefined ? [] : [name, value]; +} + +function numberOption(name: string, value: number | undefined): string[] { + return value === undefined ? [] : [name, String(value)]; +} + +function readOnlyAnnotations(openWorldHint: boolean) { + return { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint }; +} diff --git a/src/mcp/registry.ts b/src/mcp/registry.ts new file mode 100644 index 0000000..428e3bc --- /dev/null +++ b/src/mcp/registry.ts @@ -0,0 +1,26 @@ +import type { Capability } from "../core/capabilities.js"; +import { PUBLIC_MCP_TOOL_BY_COMMAND } from "./public-tool-names.js"; + +export const MCP_TOOL_BY_COMMAND = { + capabilities: "sustech_discover", + describe: "sustech_describe", + version: "sustech_version", + "calendar day": "sustech_calendar_day", + "online search": "sustech_online_search", + "online talks list": "sustech_online_talks_list", + "online talks search": "sustech_online_talks_search", + "online talks get": "sustech_online_talks_get", + "online contact search": "sustech_online_contact_search", + "online contact get": "sustech_online_contact_get", + ...PUBLIC_MCP_TOOL_BY_COMMAND, +} as const; + +export type McpExposedCommand = keyof typeof MCP_TOOL_BY_COMMAND; + +export function mcpToolForCommand(command: string): string | undefined { + return MCP_TOOL_BY_COMMAND[command as McpExposedCommand]; +} + +export function isMcpExecutableCapability(capability: Capability): boolean { + return mcpToolForCommand(capability.command) !== undefined; +} diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts new file mode 100644 index 0000000..0e7410e --- /dev/null +++ b/src/mcp/resources.ts @@ -0,0 +1,276 @@ +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server"; +import { CAPABILITIES } from "../core/capabilities.js"; +import { describeCliForMcp, runCliForMcp } from "./runner.js"; +import { isMcpExecutableCapability, mcpToolForCommand } from "./registry.js"; + +const JSON_MIME_TYPE = "application/json"; +const OPAQUE_TOKEN_RE = /^[A-Za-z0-9._:-]{1,160}$/u; + +export function registerSustechMcpResources(server: McpServer): void { + server.registerResource( + "sustech-version-resource", + "sustech://version", + { + title: "Installed sustech-cli version", + description: "Read the installed sustech-cli and Node.js runtime versions as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, ctx) => jsonResource(uri, await cliEnvelope("version", [], ctx.mcpReq.signal)), + ); + + server.registerResource( + "sustech-capabilities-resource", + "sustech://capabilities", + { + title: "Installed sustech-cli capability registry", + description: "Read the installed sustech-cli capability registry as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, ctx) => jsonResource(uri, await cliEnvelope("capabilities", [], ctx.mcpReq.signal)), + ); + + server.registerResource( + "sustech-services-resource", + "sustech://services", + { + title: "Built-in service adapter status", + description: "Read the installed service-adapter availability registry as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, ctx) => jsonResource(uri, await cliEnvelope("services status", [], ctx.mcpReq.signal)), + ); + + server.registerResource( + "sustech-consequences-resource", + "sustech://consequences", + { + title: "CLI consequence rules", + description: "Read structured risk and verification rules for consequence-rich operations as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, ctx) => jsonResource(uri, await cliEnvelope("consequences", [], ctx.mcpReq.signal)), + ); + + server.registerResource( + "sustech-mcp-policy-resource", + "sustech://mcp/policy", + { + title: "SUSTech MCP policy", + description: "Read the local sustech MCP transport, allowlist, and safety boundary as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri) => jsonResource(uri, mcpPolicyResource()), + ); + + server.registerResource( + "sustech-faculty-profile-resource", + new ResourceTemplate("sustech://faculty/{slug}", { list: undefined }), + { + title: "Faculty profile resource", + description: "Read one public faculty profile by slug as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, variables, ctx) => guardedJsonResource(uri, async () => { + const slug = requireOpaqueToken(variables.slug, "slug"); + return cliEnvelope("faculty get", [slug], ctx.mcpReq.signal); + }), + ); + + server.registerResource( + "sustech-command-description-resource", + new ResourceTemplate("sustech://command/{command}", { list: undefined }), + { + title: "SUSTech CLI command description", + description: "Read usage, options, safety classification, and consequence metadata for one installed command.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, variables, ctx) => guardedJsonResource(uri, async () => { + const command = requireInstalledCommand(variables.command); + return describeEnvelope(command, ctx.mcpReq.signal); + }), + ); + + server.registerResource( + "sustech-online-talk-resource", + new ResourceTemplate("sustech://online/talk/{id}", { list: undefined }), + { + title: "SUSTech Online talk resource", + description: "Read one public SUSTech Online talk by its stable slug identifier as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, variables, ctx) => guardedJsonResource(uri, async () => { + const id = requirePathSafeIdentifier(variables.id, "id"); + return cliEnvelope("online talks get", [id], ctx.mcpReq.signal); + }), + ); + + server.registerResource( + "sustech-nces-course-resource", + new ResourceTemplate("sustech://nces/course/{id}", { list: undefined }), + { + title: "NCES course resource", + description: "Read one public NCES course by numeric identifier as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, variables, ctx) => guardedJsonResource(uri, async () => { + const id = requirePositiveIntegerString(variables.id, "id"); + return cliEnvelope("nces course", [id], ctx.mcpReq.signal); + }), + ); + + server.registerResource( + "sustech-library-record-resource", + new ResourceTemplate("sustech://library/{context}/{docId}", { list: undefined }), + { + title: "Library catalog record resource", + description: "Read one public Primo library record by context and doc ID as JSON.", + mimeType: JSON_MIME_TYPE, + }, + async (uri, variables, ctx) => guardedJsonResource(uri, async () => { + const context = requirePrimoSegment(variables.context, "context"); + const docId = requirePrimoSegment(variables.docId, "docId"); + return cliEnvelope("library detail", [`${context}:${docId}`], ctx.mcpReq.signal); + }), + ); +} + +async function cliEnvelope(command: string, args: string[], signal?: AbortSignal): Promise> { + try { + const result = await runCliForMcp(command, args, { signal }); + return result.envelope; + } catch (error) { + return resourceError(error); + } +} + +async function describeEnvelope(command: string, signal?: AbortSignal): Promise> { + try { + return (await describeCliForMcp(command, { signal })).envelope; + } catch (error) { + return resourceError(error); + } +} + +function resourceError(error: unknown): Record { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + error: { + code: /^([A-Z][A-Z0-9_]+):/u.exec(message)?.[1] ?? "MCP_REQUEST_REJECTED", + message, + }, + }; +} + +function jsonResource(uri: URL, value: Record) { + return { + contents: [{ + uri: uri.href, + mimeType: JSON_MIME_TYPE, + text: JSON.stringify(value, null, 2), + }], + }; +} + +async function guardedJsonResource( + uri: URL, + load: () => Promise>, +) { + try { + return jsonResource(uri, await load()); + } catch (error) { + return jsonResource(uri, resourceError(error)); + } +} + +function mcpPolicyResource(): Record { + const exposedCommands = CAPABILITIES + .filter((capability) => isMcpExecutableCapability(capability)) + .map((capability) => ({ + command: capability.command, + kind: capability.kind, + network: capability.network, + authentication: capability.authentication, + status: capability.status, + mcpTool: mcpToolForCommand(capability.command), + })); + return { + schemaVersion: "1", + transport: "stdio", + typedAllowlist: true, + genericRunner: false, + publicAndLocalOnly: true, + authenticatedPersonalDataBlocked: true, + localWritesBlocked: true, + remoteMutationsBlocked: true, + authenticatedReadsExposed: false, + localPrivateWritesExposed: false, + notes: [ + "Use dedicated typed MCP tools and resources for public or non-sensitive local metadata.", + "Use the direct sustech CLI for authenticated personal data, local file writes, browser-assisted reads, and all remote mutations.", + "SUSTech Online remains community-maintained and should retain provenance and freshness advisories.", + ], + exposedCommands, + }; +} + +function variableText(value: unknown): string { + const raw = typeof value === "string" ? value : String(value ?? ""); + try { + return decodeURIComponent(raw); + } catch { + throw new Error("MCP_RESOURCE_INVALID_ARGUMENT: resource variable contains invalid percent-encoding."); + } +} + +function requireInstalledCommand(value: unknown): string { + const command = requireInlineText(value, "command", 200); + if (!CAPABILITIES.some((capability) => capability.command === command)) { + throw new Error(`MCP_RESOURCE_INVALID_ARGUMENT: unknown sustech command '${command}'.`); + } + return command; +} + +function requireOpaqueToken(value: unknown, name: string): string { + const text = requireInlineText(value, name, 160); + if (!OPAQUE_TOKEN_RE.test(text)) { + throw new Error(`MCP_RESOURCE_INVALID_ARGUMENT: '${name}' contains unsupported characters.`); + } + return text; +} + +function requirePathSafeIdentifier(value: unknown, name: string): string { + const text = requireInlineText(value, name, 500); + if (text === "." || text === ".." || /[%/\\?#\u0000-\u001f\u007f]/u.test(text)) { + throw new Error(`MCP_RESOURCE_INVALID_ARGUMENT: '${name}' contains unsupported path characters.`); + } + return text; +} + +function requirePositiveIntegerString(value: unknown, name: string): string { + const text = requireInlineText(value, name, 16); + if (!/^[1-9]\d*$/u.test(text)) { + throw new Error(`MCP_RESOURCE_INVALID_ARGUMENT: '${name}' must be a positive integer.`); + } + return text; +} + +function requirePrimoSegment(value: unknown, name: string): string { + const text = requireInlineText(value, name, 500); + if (/[%/?#\\\u0000-\u001f\u007f]/u.test(text)) { + throw new Error(`MCP_RESOURCE_INVALID_ARGUMENT: '${name}' cannot contain path separators or URL fragments.`); + } + return text; +} + +function requireInlineText(value: unknown, name: string, maxLength: number): string { + const text = variableText(value).trim(); + if (!text) throw new Error(`MCP_RESOURCE_INVALID_ARGUMENT: '${name}' is required.`); + if (text.length > maxLength) { + throw new Error(`MCP_RESOURCE_INVALID_ARGUMENT: '${name}' cannot exceed ${maxLength} characters.`); + } + if (/[\u0000-\u001f\u007f]/u.test(text)) { + throw new Error(`MCP_RESOURCE_INVALID_ARGUMENT: '${name}' cannot contain control characters.`); + } + return text; +} diff --git a/src/mcp/runner.ts b/src/mcp/runner.ts new file mode 100644 index 0000000..4f5a387 --- /dev/null +++ b/src/mcp/runner.ts @@ -0,0 +1,224 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { inferCommandName } from "../core/argv.js"; +import { CAPABILITIES, type Capability } from "../core/capabilities.js"; +import { isMcpExecutableCapability } from "./registry.js"; + +const DEFAULT_TIMEOUT_MS = 60_000; +const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; +const MAX_ARGUMENTS = 128; +const MAX_ARGUMENT_LENGTH = 4_096; + +const LOCAL_WRITE_COMMANDS = new Set([ + "tis plan add", + "tis plan init", + "tis plan remove", +]); + +const BLOCKED_ARGUMENTS = new Set([ + "-h", + "--browser", + "--confirm", + "--credentials-file", + "--destination", + "--file", + "--help", + "--interactive", + "--json", + "--jsonl", + "--output", + "--overwrite", + "--password-stdin", + "--path", + "--pretty", + "--requirements", + "--reveal", + "--state", + "--url-stdin", + "--", +]); + +export interface McpCliEnvelope { + schemaVersion?: string; + ok?: boolean; + command?: string; + data?: unknown; + error?: unknown; + [key: string]: unknown; +} + +export interface McpCliRunResult { + exitCode: number; + envelope: McpCliEnvelope; +} + +export interface McpRunnerOptions { + cliPath?: string; + timeoutMs?: number; + signal?: AbortSignal; +} + +export function capabilityByCommand(command: string): Capability | undefined { + return CAPABILITIES.find((entry) => entry.command === command); +} + +export function validateMcpCommand(command: string, args: readonly string[]): Capability { + const capability = capabilityByCommand(command); + if (!capability) throw new Error(`Unknown sustech command: ${command}`); + if (capability.kind === "mutation" || LOCAL_WRITE_COMMANDS.has(command)) { + throw new Error(`MCP_MUTATION_BLOCKED: '${command}' changes state and must be run directly in the CLI.`); + } + if (!isMcpExecutableCapability(capability)) { + throw new Error(`MCP_COMMAND_NOT_EXPOSED: '${command}' does not have a typed MCP tool.`); + } + if (args.length > MAX_ARGUMENTS) throw new Error(`Too many arguments; maximum is ${MAX_ARGUMENTS}.`); + + for (const argument of args) { + if (argument.length > MAX_ARGUMENT_LENGTH) { + throw new Error(`An argument exceeds the ${MAX_ARGUMENT_LENGTH}-character limit.`); + } + if (argument.includes("\0")) throw new Error("Arguments cannot contain NUL bytes."); + const optionName = argument.startsWith("--") ? argument.split("=", 1)[0] : argument; + if (BLOCKED_ARGUMENTS.has(optionName)) { + throw new Error(`MCP_ARGUMENT_BLOCKED: '${optionName}' is unavailable through MCP.`); + } + } + + const inferred = inferCommandName([...command.split(" "), ...args]); + if (inferred !== command) { + throw new Error(`Arguments changed the command from '${command}' to '${inferred}'.`); + } + return capability; +} + +export async function runCliForMcp( + command: string, + args: readonly string[] = [], + options: McpRunnerOptions = {}, +): Promise { + validateMcpCommand(command, args); + return spawnStructuredCli([...command.split(" "), ...args], options); +} + +export async function describeCliForMcp( + command: string, + options: McpRunnerOptions = {}, +): Promise { + if (!capabilityByCommand(command)) throw new Error(`Unknown sustech command: ${command}`); + return spawnStructuredCli(["describe", ...command.split(" ")], options); +} + +async function spawnStructuredCli( + argv: readonly string[], + options: McpRunnerOptions, +): Promise { + const cliPath = options.cliPath ?? fileURLToPath(new URL("../cli.js", import.meta.url)); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120_000) { + throw new Error("MCP runner timeout must be between 1 and 120000 milliseconds."); + } + if (options.signal?.aborted) { + throw cancelError(options.signal.reason, true); + } + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cliPath, ...argv, "--json"], { + env: process.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const stdout: Buffer[] = []; + let stdoutBytes = 0; + let settled = false; + let terminationReason: Error | undefined; + let forceKill: NodeJS.Timeout | undefined; + + const cleanup = (): void => { + clearTimeout(timeout); + if (forceKill) clearTimeout(forceKill); + options.signal?.removeEventListener("abort", abortHandler); + }; + + const terminate = (reason: Error): void => { + if (terminationReason) return; + terminationReason = reason; + child.kill("SIGTERM"); + forceKill = setTimeout(() => child.kill("SIGKILL"), 1_000); + forceKill.unref(); + }; + + const abortHandler = (): void => { + terminate(cancelError(options.signal?.reason)); + }; + + const timeout = setTimeout(() => { + terminate(new Error(`MCP_CLI_TIMEOUT: command exceeded ${timeoutMs} milliseconds.`)); + }, timeoutMs); + timeout.unref(); + options.signal?.addEventListener("abort", abortHandler, { once: true }); + if (options.signal?.aborted) abortHandler(); + + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.length; + if (stdoutBytes > MAX_OUTPUT_BYTES) { + terminate(new Error(`MCP_OUTPUT_TOO_LARGE: command exceeded ${MAX_OUTPUT_BYTES} bytes.`)); + return; + } + stdout.push(chunk); + }); + + // Consume stderr so the child cannot block. It is intentionally not returned + // because upstream diagnostics may contain sensitive campus-service details. + child.stderr.resume(); + + child.once("error", (error) => { + cleanup(); + if (settled) return; + settled = true; + reject(error); + }); + + child.once("close", (code) => { + cleanup(); + if (settled) return; + settled = true; + if (terminationReason) { + reject(terminationReason); + return; + } + const raw = Buffer.concat(stdout).toString("utf8").trim(); + if (!raw) { + reject(new Error(`MCP_CLI_NO_OUTPUT: sustech exited with code ${code ?? 1}.`)); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + reject(new Error("MCP_CLI_INVALID_OUTPUT: sustech did not return one JSON envelope.")); + return; + } + if (!isRecord(parsed)) { + reject(new Error("MCP_CLI_INVALID_OUTPUT: sustech returned a non-object JSON value.")); + return; + } + resolve({ exitCode: code ?? 1, envelope: parsed as McpCliEnvelope }); + }); + }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function cancelError(reason: unknown, beforeStart = false): Error { + const detail = reason instanceof Error + ? reason.message + : typeof reason === "string" + ? reason + : beforeStart + ? "request was cancelled before the CLI started." + : "request was cancelled by the MCP client."; + return new Error(`MCP_CLI_CANCELLED: ${detail}`); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..0dbeb4d --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,304 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { z } from "zod"; +import { CAPABILITIES } from "../core/capabilities.js"; +import { CLI_VERSION } from "../core/version.js"; +import { describeCliForMcp, runCliForMcp, type McpRunnerOptions } from "./runner.js"; +import { registerSustechMcpPrompts } from "./prompts.js"; +import { isMcpExecutableCapability, mcpToolForCommand } from "./registry.js"; +import { registerPublicMcpTools } from "./public-tools.js"; +import { registerSustechMcpResources } from "./resources.js"; +import { resolve as resolvePath } from "node:path"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const KIND_VALUES = ["local", "read", "plan", "mutation"] as const; +const ISO_DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD."); +const QUERY = z.string().trim().min(1).max(500); +const IDENTIFIER = z.string().trim().min(1).max(500); +const LIMIT = z.number().int().min(1).max(200); + +export interface SustechMcpServerOptions { + runner?: Pick; +} + +export function createSustechMcpServer(options: SustechMcpServerOptions = {}): McpServer { + const execute = (command: string, args: string[], signal?: AbortSignal) => runTypedCommand( + command, + args, + signal, + options.runner, + ); + const server = new McpServer( + { name: "sustech-cli", version: CLI_VERSION }, + { + instructions: [ + "Use this server for public or local SUSTech information only.", + "Treat SUSTech Online records as community-maintained and preserve their provenance and freshness advisories.", + "Authenticated personal data, local private state, file writes, and remote mutations are intentionally unavailable through MCP; use the direct sustech CLI and its preview/confirmation/read-back workflow for those operations.", + ].join(" "), + cacheHints: { + "server/discover": { ttlMs: 300_000, cacheScope: "public" }, + "tools/list": { ttlMs: 300_000, cacheScope: "public" }, + "prompts/list": { ttlMs: 300_000, cacheScope: "public" }, + "resources/list": { ttlMs: 300_000, cacheScope: "public" }, + "resources/templates/list": { ttlMs: 300_000, cacheScope: "public" }, + "resources/read": { ttlMs: 60_000, cacheScope: "public" }, + }, + }, + ); + + server.registerTool( + "sustech_discover", + { + title: "Discover SUSTech CLI capabilities", + description: "List and filter the installed sustech-cli command surface. Each result states whether a dedicated typed MCP tool is available.", + inputSchema: z.object({ + query: z.string().trim().max(200).optional().describe("Optional text matched against command names and summaries."), + kind: z.enum(KIND_VALUES).optional().describe("Optional capability kind filter."), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ query, kind }) => { + const needle = query?.toLocaleLowerCase("en-US"); + const capabilities = CAPABILITIES.filter((entry) => { + if (kind && entry.kind !== kind) return false; + if (!needle) return true; + return `${entry.command}\n${entry.summary}`.toLocaleLowerCase("en-US").includes(needle); + }); + const result = { + schemaVersion: "1", + mcpPolicy: { + typedAllowlist: true, + genericRunner: false, + publicAndLocalOnly: true, + authenticatedPersonalDataBlocked: true, + localWritesBlocked: true, + remoteMutationsBlocked: true, + }, + total: capabilities.length, + capabilities: capabilities.map((capability) => ({ + ...capability, + mcpExecutable: isMcpExecutableCapability(capability), + ...(mcpToolForCommand(capability.command) ? { mcpTool: mcpToolForCommand(capability.command) } : {}), + })), + }; + return toolResult(result); + }, + ); + + server.registerTool( + "sustech_describe", + { + title: "Describe one SUSTech CLI command", + description: "Return exact usage, options, safety classification, and consequences for one installed command.", + inputSchema: z.object({ + command: z.string().trim().min(1).max(200).describe("Exact command name returned by sustech_discover."), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ command }, ctx) => { + try { + const result = await describeCliForMcp(command, { ...options.runner, signal: ctx.mcpReq.signal }); + return toolResult(result.envelope, result.exitCode !== 0 || result.envelope.ok === false); + } catch (error) { + return toolError(error); + } + }, + ); + + server.registerTool( + "sustech_version", + { + title: "Read the installed SUSTech CLI version", + description: "Return the installed sustech-cli and Node.js runtime versions.", + inputSchema: z.object({}), + annotations: readOnlyAnnotations(false), + }, + async (_input, ctx) => execute("version", [], ctx.mcpReq.signal), + ); + + server.registerTool( + "sustech_calendar_day", + { + title: "Read one SUSTech academic-calendar day", + description: "Resolve one date into teaching week, holiday, makeup, and exam flags from the public calendar dataset.", + inputSchema: z.object({ + date: ISO_DATE, + level: z.enum(["undergraduate", "graduate"]).optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ date, level }, ctx) => execute("calendar day", [date, ...option("--calendar-level", level)], ctx.mcpReq.signal), + ); + + server.registerTool( + "sustech_online_search", + { + title: "Search selected SUSTech Online public information", + description: "Search talks and institutional contacts from selected community-maintained SUSTech Online pages, with provenance and freshness metadata.", + inputSchema: z.object({ + query: QUERY, + section: z.enum(["talks", "contact"]).optional(), + since: ISO_DATE.optional(), + until: ISO_DATE.optional(), + limit: LIMIT.optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ query, section, since, until, limit }, ctx) => execute("online search", [ + query, + ...option("--section", section), + ...option("--since", since), + ...option("--until", until), + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + "sustech_online_talks_list", + { + title: "List public SUSTech talks", + description: "List public talks indexed by the community-maintained SUSTech Online repository.", + inputSchema: z.object({ since: ISO_DATE.optional(), until: ISO_DATE.optional(), limit: LIMIT.optional() }), + annotations: readOnlyAnnotations(true), + }, + async ({ since, until, limit }, ctx) => execute("online talks list", [ + ...option("--since", since), + ...option("--until", until), + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + "sustech_online_talks_search", + { + title: "Search public SUSTech talks", + description: "Search titles, speakers, places, and other public talk fields indexed by SUSTech Online.", + inputSchema: z.object({ query: QUERY, since: ISO_DATE.optional(), until: ISO_DATE.optional(), limit: LIMIT.optional() }), + annotations: readOnlyAnnotations(true), + }, + async ({ query, since, until, limit }, ctx) => execute("online talks search", [ + query, + ...option("--since", since), + ...option("--until", until), + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + "sustech_online_talks_get", + { + title: "Read one public SUSTech talk", + description: "Read one exact talk by the stable identifier returned by a talks list or search.", + inputSchema: z.object({ id: IDENTIFIER }), + annotations: readOnlyAnnotations(true), + }, + async ({ id }, ctx) => execute("online talks get", [id], ctx.mcpReq.signal), + ); + + server.registerTool( + "sustech_online_contact_search", + { + title: "Search institutional SUSTech contacts", + description: "Search selected public institutional contacts from SUSTech Online. Personal, social, finance, and emergency content is excluded.", + inputSchema: z.object({ query: QUERY, limit: LIMIT.optional() }), + annotations: readOnlyAnnotations(true), + }, + async ({ query, limit }, ctx) => execute("online contact search", [query, ...numberOption("--limit", limit)], ctx.mcpReq.signal), + ); + + server.registerTool( + "sustech_online_contact_get", + { + title: "Read one institutional SUSTech contact", + description: "Read one exact institutional public contact by the stable identifier returned by contact search.", + inputSchema: z.object({ id: IDENTIFIER }), + annotations: readOnlyAnnotations(true), + }, + async ({ id }, ctx) => execute("online contact get", [id], ctx.mcpReq.signal), + ); + + registerPublicMcpTools(server); + registerSustechMcpResources(server); + registerSustechMcpPrompts(server); + + return server; +} + +function toolResult(value: Record, isError = false) { + return { + content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], + structuredContent: value, + ...(isError ? { isError: true } : {}), + }; +} + +function toolError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const code = /^([A-Z][A-Z0-9_]+):/u.exec(message)?.[1] ?? "MCP_REQUEST_REJECTED"; + return toolResult({ ok: false, error: { code, message } }, true); +} + +async function runTypedCommand( + command: string, + args: string[], + signal?: AbortSignal, + runnerOptions: Pick = {}, +) { + try { + const result = await runCliForMcp(command, args, { ...runnerOptions, signal }); + return toolResult(result.envelope, result.exitCode !== 0 || result.envelope.ok === false); + } catch (error) { + return toolError(error); + } +} + +function option(name: string, value: string | undefined): string[] { + return value === undefined ? [] : [name, value]; +} + +function numberOption(name: string, value: number | undefined): string[] { + return value === undefined ? [] : [name, String(value)]; +} + +function readOnlyAnnotations(openWorldHint: boolean) { + return { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint }; +} + +if (isDirectExecution()) { + const args = process.argv.slice(2); + if (args.length === 1 && ["-h", "--help"].includes(args[0]!)) { + process.stdout.write([ + "sustech-mcp — local SUSTech Model Context Protocol server", + "", + "Usage:", + " sustech-mcp Serve MCP over stdio", + " sustech-mcp --help Show this help", + " sustech-mcp --version Print the installed version", + "", + "Configure an MCP client to launch `sustech-mcp` as a local command.", + "The server exposes public/local read-only tools; authenticated data and writes remain in `sustech`.", + "", + ].join("\n")); + } else if (args.length === 1 && ["-V", "--version"].includes(args[0]!)) { + process.stdout.write(`${CLI_VERSION}\n`); + } else if (args.length > 0) { + process.stderr.write(`sustech-mcp: unsupported argument: ${args.join(" ")}\n`); + process.exitCode = 2; + } else { + serveStdio(() => createSustechMcpServer(), { + onerror: (error) => process.stderr.write(`sustech-mcp: ${error.message}\n`), + }); + } +} + +function isDirectExecution(): boolean { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolvePath(process.argv[1])); + } catch { + return false; + } +} diff --git a/src/online/contact-text.ts b/src/online/contact-text.ts new file mode 100644 index 0000000..f388777 --- /dev/null +++ b/src/online/contact-text.ts @@ -0,0 +1,34 @@ +import { formatOnlineAdvisories } from "./shared.js"; +import { contactSearchSnippet } from "./contact.js"; +import type { OnlineContactRecord } from "./types.js"; + +export function formatOnlineContacts(records: readonly OnlineContactRecord[], title = "SUSTech Online contacts"): string { + if (records.length === 0) return `${title}\n\nNo public institutional contacts matched.`; + const blocks = records.map((record, index) => [ + `${index + 1}. ${record.name}`, + ` Id: ${record.id}`, + ` Category: ${record.category}`, + record.address ? ` Address: ${record.address}` : "", + record.phones.length > 0 ? ` Phones: ${record.phones.join(", ")}` : "", + record.emails.length > 0 ? ` Emails: ${record.emails.join(", ")}` : "", + record.hours && record.hours.length > 0 ? ` Hours: ${record.hours.join("; ")}` : "", + record.websiteUrl ? ` Website: ${record.websiteUrl}` : "", + record.notes.length > 0 ? ` Notes: ${record.notes.join(" | ")}` : "", + ` Source: ${record.provenance.sourceRepoPath}${record.provenance.sourceUpdatedAt ? ` · updated ${record.provenance.sourceUpdatedAt}` : ""} · ${record.provenance.license}`, + ` Advisories: ${formatOnlineAdvisories(record.provenance.advisories)}`, + ].filter(Boolean).join("\n")); + return `${title}\n\n${blocks.join("\n\n")}\n\n${records.length} contact(s).`; +} + +export function formatOnlineContact(record: OnlineContactRecord): string { + return formatOnlineContacts([record], `SUSTech Online contact · ${record.name}`); +} + +export function formatOnlineContactSearch(records: readonly OnlineContactRecord[], query: string): string { + if (records.length === 0) return `SUSTech Online contact search · ${query}\n\nNo public institutional contacts matched.`; + const blocks = records.map((record, index) => [ + `${index + 1}. ${record.name} [${record.id}]`, + ` ${contactSearchSnippet(record)}`, + ].join("\n")); + return `SUSTech Online contact search · ${query}\n\n${blocks.join("\n\n")}\n\n${records.length} match(es).`; +} diff --git a/src/online/contact.ts b/src/online/contact.ts new file mode 100644 index 0000000..5a51751 --- /dev/null +++ b/src/online/contact.ts @@ -0,0 +1,344 @@ +import { CliError } from "../core/errors.js"; +import { collapseWhitespace } from "../services/base.js"; +import { + buildOnlineProvenance, + extractEmails, + extractMarkdownLink, + extractPhones, + fetchOnlineMarkdownDocument, + formatOnlineAdvisories, + makeOnlineId, + ONLINE_CONTACT_REPO_PATH, + ONLINE_CONTACT_SITE_PATH, + onlineSiteUrl, + scoreSearchMatch, + stripMarkdown, + uniqueStrings, + type OnlineFetchOptions, +} from "./shared.js"; +import type { OnlineContactRecord } from "./types.js"; + +export interface OnlineContactQueryOptions extends OnlineFetchOptions { + limit?: number; +} + +interface RankedContact { + score: number; + record: OnlineContactRecord; +} + +export async function listOnlineContacts(options: OnlineContactQueryOptions = {}): Promise { + const document = await fetchOnlineMarkdownDocument(ONLINE_CONTACT_REPO_PATH, ONLINE_CONTACT_SITE_PATH, options); + const contacts = parseOnlineContactsMarkdown(document.markdown, { + fetchedAt: document.fetchedAt, + sourceUpdatedAt: document.pageUpdatedAt, + sourceMetadataAvailable: document.pageMetadataAvailable, + staleAfterDays: options.staleAfterDays, + }); + return applyLimit(contacts, options.limit); +} + +export async function searchOnlineContacts(query: string, options: OnlineContactQueryOptions = {}): Promise { + const needle = query.trim(); + if (!needle) throw new CliError("A search query is required.", "USAGE", 2); + const contacts = await listOnlineContacts({ ...options, limit: undefined }); + const ranked = contacts + .map((record) => ({ + record, + score: scoreSearchMatch(needle, [ + { value: record.id, weight: 8 }, + { value: record.name, weight: 10 }, + { value: record.category, weight: 5 }, + { value: record.address, weight: 4 }, + { value: record.phones.join(" "), weight: 4 }, + { value: record.emails.join(" "), weight: 4 }, + { value: record.notes.join(" "), weight: 2 }, + ]), + })) + .filter((entry) => entry.score > 0) + .sort(compareRankedContacts) + .map((entry) => entry.record); + return applyLimit(ranked, options.limit); +} + +export async function getOnlineContact(identifier: string, options: OnlineFetchOptions = {}): Promise { + const needle = identifier.trim(); + if (!needle) throw new CliError("A contact name or id is required.", "USAGE", 2); + const contacts = await listOnlineContacts(options); + const exact = contacts.find((record) => record.id === needle || record.name === needle); + if (exact) return exact; + throw new CliError("No public institutional contact matched that exact id or name.", "ONLINE_CONTACT_NOT_FOUND", 1, { + query: needle, + }); +} + +export function parseOnlineContactsMarkdown( + markdown: string, + options: { + fetchedAt: string; + sourceUpdatedAt?: string; + sourceMetadataAvailable?: boolean; + staleAfterDays?: number; + }, +): OnlineContactRecord[] { + const sourceUrl = onlineSiteUrl(ONLINE_CONTACT_SITE_PATH); + const provenance = buildOnlineProvenance( + sourceUrl, + ONLINE_CONTACT_REPO_PATH, + options.fetchedAt, + options.sourceUpdatedAt, + options.staleAfterDays, + { aiProcessed: false, sourceMetadataAvailable: options.sourceMetadataAvailable }, + ); + const officeHours = extractGeneralOfficeHours(markdown); + const records = [ + ...parseGeneralSection(markdown, provenance), + ...parseTeachingSection(sectionBody(markdown, "教学"), officeHours, provenance), + ...parseLogisticsTable(sectionBody(markdown, "物流、餐饮、康体、后勤"), provenance), + ...parseSimpleBullets(sectionBody(markdown, "行政"), "行政", "administration", provenance), + ...parseSimpleBullets(sectionBody(markdown, "更多官方部门的联系方式"), "官方链接", "official-links", provenance), + ]; + return records.sort((left, right) => left.category.localeCompare(right.category, "zh-Hans-CN") || left.name.localeCompare(right.name, "zh-Hans-CN")); +} + +export function contactSearchSnippet(record: OnlineContactRecord): string { + return uniqueStrings([ + record.address ?? "", + record.phones.join(" "), + record.emails.join(" "), + record.hours?.join("; ") ?? "", + record.notes.join(" "), + formatOnlineAdvisories(record.provenance.advisories), + ]).join(" · "); +} + +function parseGeneralSection(markdown: string, provenance: OnlineContactRecord["provenance"]): OnlineContactRecord[] { + const body = betweenHeadings(markdown, "## 电话与邮件", "### 教学"); + if (!body) return []; + const hotlineMatch = /\*\*24h 校内服务热线[^::]*[::]\s*([0-9-]+)\*\*/u.exec(body); + if (!hotlineMatch) return []; + const notes = uniqueStrings([ + "物业热线,报修用,查号用", + /\*\*座机默认区号([0-9]+)\*\*/u.exec(body)?.[1] ? `座机默认区号 ${/\*\*座机默认区号([0-9]+)\*\*/u.exec(body)?.[1]}` : "", + ]); + return [{ + kind: "contact", + id: makeOnlineId("general", "24h 校内服务热线"), + name: "24h 校内服务热线", + category: "通用服务", + categoryKey: "general", + phones: [hotlineMatch[1]], + emails: [], + hours: ["24小时"], + notes, + provenance, + }]; +} + +function parseTeachingSection( + section: string, + defaultHours: readonly string[], + provenance: OnlineContactRecord["provenance"], +): OnlineContactRecord[] { + return bulletBlocks(section) + .map((block) => parseStructuredBullet(block, "教学", "teaching", provenance, defaultHours)) + .filter((record): record is OnlineContactRecord => record !== undefined); +} + +function parseLogisticsTable(section: string, provenance: OnlineContactRecord["provenance"]): OnlineContactRecord[] { + const table = markdownTable(section); + if (!table) return []; + return table.rows + .map(([name, address, phone, hours]) => { + const cleanName = collapseWhitespace(name); + if (!cleanName || /(?:餐饮|食堂|美食)/u.test(cleanName)) return undefined; + return { + kind: "contact" as const, + id: makeOnlineId("logistics", cleanName), + name: cleanName, + category: "后勤服务", + categoryKey: "logistics", + phones: uniqueStrings(phone ? [phone] : []), + emails: [] as string[], + ...(address ? { address: collapseWhitespace(address) } : {}), + ...(hours ? { hours: [collapseWhitespace(hours)] } : {}), + notes: [] as string[], + provenance, + }; + }) + .filter((record): record is OnlineContactRecord => record !== undefined); +} + +function parseSimpleBullets( + section: string, + category: string, + categoryKey: string, + provenance: OnlineContactRecord["provenance"], +): OnlineContactRecord[] { + return bulletBlocks(section) + .map((block) => parseStructuredBullet(block, category, categoryKey, provenance)) + .filter((record): record is OnlineContactRecord => record !== undefined); +} + +function parseStructuredBullet( + block: string, + category: string, + categoryKey: string, + provenance: OnlineContactRecord["provenance"], + defaultHours: readonly string[] = [], +): OnlineContactRecord | undefined { + if (!block.trim() || block.includes("./professor-emails")) return undefined; + const lines = block.split("\n").map((line) => line.trimEnd()); + const normalizedLines = lines.map(stripLeadingBullet).map(collapseWhitespace).filter(Boolean); + if (normalizedLines.length === 0) return undefined; + const firstLine = normalizedLines[0]; + const link = extractMarkdownLink(firstLine); + const rawName = link ? link.text : firstLine.split(/[::]/u, 1)[0] ?? firstLine; + const name = collapseWhitespace(rawName.split("|")[0] ?? rawName); + const phones = extractPhones(block); + const emails = extractEmails(block); + const address = firstAddress(normalizedLines.slice(1)); + const hours = normalizedLines + .filter((line) => /^[^::]+[::]/u.test(line) && /(?:工作时间|服务时间)/u.test(line)) + .map((line) => collapseWhitespace(line.split(/[::]/u).slice(1).join(":"))); + const resolvedHours = hours.length > 0 ? hours : [...defaultHours]; + const websiteUrl = safeInstitutionalUrl(link?.url); + if (link && !websiteUrl) return undefined; + const notes = normalizedLines + .slice(1) + .map((line) => line.replace(/^(?:电话|公共邮箱[^::]*|学生学习服务邮箱|邮箱|办公地点|地址|地点|选课咨询电话|工作时间|服务时间)[::]\s*/u, "").trim()) + .filter((line) => Boolean(line)) + .filter((line) => !phones.includes(line)) + .filter((line) => !emails.includes(line)) + .filter((line) => line !== address) + .filter((line) => !resolvedHours.includes(line)); + return { + kind: "contact", + id: makeOnlineId(categoryKey, name), + name, + category, + categoryKey, + phones, + emails, + ...(address ? { address } : {}), + ...(resolvedHours.length > 0 ? { hours: resolvedHours } : {}), + ...(websiteUrl ? { websiteUrl } : {}), + notes: uniqueStrings(notes), + provenance, + }; +} + +function sectionBody(markdown: string, title: string): string { + const match = new RegExp(`^###\\s+${escapeRegExp(title)}\\s*\\n([\\s\\S]*?)(?=^###\\s+|^##\\s+|(?![\\s\\S]))`, "mu").exec(markdown); + return match?.[1] ?? ""; +} + +function betweenHeadings(markdown: string, startHeading: string, endHeading: string): string { + const start = markdown.indexOf(startHeading); + if (start < 0) return ""; + const from = start + startHeading.length; + const end = markdown.indexOf(endHeading, from); + return markdown.slice(from, end >= 0 ? end : undefined); +} + +function bulletBlocks(section: string): string[] { + const blocks: string[] = []; + let current: string[] = []; + for (const line of section.split("\n")) { + if (/^-\s+/u.test(line)) { + if (current.length > 0) blocks.push(current.join("\n")); + current = [line]; + continue; + } + if (current.length > 0) current.push(line); + } + if (current.length > 0) blocks.push(current.join("\n")); + return blocks; +} + +function markdownTable(section: string): { headers: string[]; rows: string[][] } | undefined { + const lines = section.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("|")); + if (lines.length < 3) return undefined; + const headers = splitTableRow(lines[0]); + const rows = lines.slice(2).map(splitTableRow).filter((row) => row.length === headers.length); + return { headers, rows }; +} + +function splitTableRow(line: string): string[] { + return line + .replace(/^\|/u, "") + .replace(/\|$/u, "") + .split("|") + .map((entry) => collapseWhitespace(entry)); +} + +function firstAddress(lines: readonly string[]): string | undefined { + for (const line of lines) { + if (/^(?:办公地点|地址|地点)[::]/u.test(line)) { + return collapseWhitespace(line.split(/[::]/u).slice(1).join(":")); + } + } + return lines.find((line) => + line + && !/[::]/u.test(line) + && extractPhones(line).length === 0 + && extractEmails(line).length === 0, + ); +} + +function extractGeneralOfficeHours(body: string): string[] { + const lines = body.split("\n"); + const start = lines.findIndex((line) => line.includes("**一般办公时间**")); + if (start < 0) return []; + const result: string[] = []; + for (let index = start + 1; index < lines.length; index += 1) { + const line = collapseWhitespace(stripLeadingBullet(lines[index])); + if (!line) { + if (result.length > 0) break; + continue; + } + if (!/^(?:周|上午|下午)/u.test(line)) break; + result.push(line); + } + return result; +} + +function applyLimit(items: readonly T[], limit?: number): T[] { + if (limit === undefined) return [...items]; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) { + throw new CliError("Online contact limits must be integers from 1 to 200.", "USAGE", 2); + } + return items.slice(0, limit); +} + +function safeInstitutionalUrl(value?: string): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value, "https://sustech.online"); + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + return isSustechHostname(url.hostname) ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function isSustechHostname(hostname: string): boolean { + const normalized = hostname.toLocaleLowerCase("en-US"); + return normalized === "sustech.online" + || normalized === "sustech.edu.cn" + || normalized.endsWith(".sustech.edu.cn"); +} + +function compareRankedContacts(left: RankedContact, right: RankedContact): number { + return right.score - left.score + || left.record.category.localeCompare(right.record.category, "zh-Hans-CN") + || left.record.name.localeCompare(right.record.name, "zh-Hans-CN"); +} + +function stripLeadingBullet(value: string): string { + return value.replace(/^\s*[-*+]\s+/u, "").trim(); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} diff --git a/src/online/index.ts b/src/online/index.ts new file mode 100644 index 0000000..62190f6 --- /dev/null +++ b/src/online/index.ts @@ -0,0 +1,7 @@ +export * from "./contact.js"; +export * from "./contact-text.js"; +export * from "./search.js"; +export * from "./shared.js"; +export * from "./talks.js"; +export * from "./talks-text.js"; +export * from "./types.js"; diff --git a/src/online/search.ts b/src/online/search.ts new file mode 100644 index 0000000..9673a9b --- /dev/null +++ b/src/online/search.ts @@ -0,0 +1,69 @@ +import { CliError } from "../core/errors.js"; +import { searchOnlineContacts, contactSearchSnippet } from "./contact.js"; +import { formatOnlineAdvisories, scoreSearchMatch } from "./shared.js"; +import { searchOnlineTalks, talkSearchSnippet } from "./talks.js"; +import type { OnlineFetchOptions } from "./shared.js"; +import type { OnlineSearchHit } from "./types.js"; + +export interface OnlineSearchOptions extends OnlineFetchOptions { + limit?: number; + section?: "talks" | "contact"; + since?: string; + until?: string; +} + +export async function searchOnline(query: string, options: OnlineSearchOptions = {}): Promise { + const withoutLimit = { ...options, limit: undefined }; + const [talks, contacts] = await Promise.all([ + options.section === "contact" ? Promise.resolve([]) : searchOnlineTalks(query, withoutLimit), + options.section === "talks" ? Promise.resolve([]) : searchOnlineContacts(query, withoutLimit), + ]); + const hits = [ + ...talks.map((talk) => ({ + kind: "talk" as const, + id: talk.id, + title: talk.title, + subtitle: [talk.date, talk.timeText, talk.speakerLine].filter(Boolean).join(" · "), + snippet: talkSearchSnippet(talk), + url: talk.detailUrl, + provenance: talk.provenance, + })), + ...contacts.map((contact) => ({ + kind: "contact" as const, + id: contact.id, + title: contact.name, + subtitle: contact.category, + snippet: contactSearchSnippet(contact), + url: contact.websiteUrl, + provenance: contact.provenance, + })), + ]; + const ranked = hits + .map((hit) => ({ + hit, + score: scoreSearchMatch(query, [ + { value: hit.title, weight: 10 }, + { value: hit.subtitle, weight: 7 }, + { value: hit.snippet, weight: 3 }, + ]), + })) + .sort((left, right) => right.score - left.score || left.hit.title.localeCompare(right.hit.title, "zh-Hans-CN")) + .map((entry) => entry.hit); + if (options.limit === undefined) return ranked; + if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 200) { + throw new CliError("Online search limits must be integers from 1 to 200.", "USAGE", 2); + } + return ranked.slice(0, options.limit); +} + +export function formatOnlineSearchHits(hits: readonly OnlineSearchHit[], query: string): string { + if (hits.length === 0) return `SUSTech Online search · ${query}\n\nNo public community-maintained records matched.`; + const blocks = hits.map((hit, index) => [ + `${index + 1}. [${hit.kind}] ${hit.title}`, + hit.subtitle ? ` ${hit.subtitle}` : "", + ` ${hit.snippet}`, + ` Source: ${hit.provenance.sourceRepoPath} · ${hit.provenance.license}`, + ` Advisories: ${formatOnlineAdvisories(hit.provenance.advisories)}`, + ].filter(Boolean).join("\n")); + return `SUSTech Online search · ${query}\n\n${blocks.join("\n\n")}\n\n${hits.length} hit(s).`; +} diff --git a/src/online/shared.ts b/src/online/shared.ts new file mode 100644 index 0000000..52695d9 --- /dev/null +++ b/src/online/shared.ts @@ -0,0 +1,404 @@ +import { CliError } from "../core/errors.js"; +import { USER_AGENT } from "../core/version.js"; +import { + collapseWhitespace, + createFetchAdapter, + sampleText, + ServiceError, + type ServiceAdapter, +} from "../services/base.js"; +import type { OnlineAdvisory, OnlineProvenance } from "./types.js"; + +export const ONLINE_SITE_ORIGIN = "https://sustech.online"; +export const ONLINE_REPO_OWNER = "SUSTech-CRA"; +export const ONLINE_REPO_NAME = "sustech-online-ng"; +export const ONLINE_REPO_BRANCH = "master"; +export const ONLINE_RAW_ORIGIN = "https://raw.githubusercontent.com"; +export const ONLINE_TALKS_INDEX_REPO_PATH = "docs/study/talks/README.md"; +export const ONLINE_TALKS_DIRECTORY_REPO_PATH = "docs/study/talks"; +export const ONLINE_CONTACT_REPO_PATH = "docs/contact/README.md"; +export const ONLINE_TALKS_INDEX_SITE_PATH = "/study/talks/"; +export const ONLINE_CONTACT_SITE_PATH = "/contact/"; +export const ONLINE_DEFAULT_TIMEOUT_MS = 15_000; +export const ONLINE_DEFAULT_STALE_AFTER_DAYS = 365; +export const ONLINE_MAX_DOCUMENT_BYTES = 1_000_000; + +export interface OnlineFetchOptions { + adapter?: ServiceAdapter; + fetchedAt?: string; + staleAfterDays?: number; + timeoutMs?: number; +} + +export interface OnlineMarkdownDocument { + markdown: string; + pageUpdatedAt?: string; + pageMetadataAvailable: boolean; + fetchedAt: string; +} + +interface SearchField { + value?: string; + weight: number; +} + +export function createOnlineAdapter(fetchImpl: typeof fetch = globalThis.fetch): ServiceAdapter { + return createFetchAdapter(fetchImpl, "sustech-online"); +} + +export async function fetchOnlineMarkdownDocument( + repoPath: string, + sitePath: string, + options: OnlineFetchOptions = {}, +): Promise { + assertAllowedOnlineRepoPath(repoPath); + assertAllowedOnlineSitePath(sitePath); + const adapter = options.adapter ?? createOnlineAdapter(); + const [markdownResult, pageResult] = await Promise.allSettled([ + fetchAllowlistedText(adapter, onlineRawUrl(repoPath), { timeoutMs: options.timeoutMs, kind: "raw" }), + fetchAllowlistedText(adapter, onlineSiteUrl(sitePath), { timeoutMs: options.timeoutMs, kind: "site" }), + ]); + if (markdownResult.status === "rejected") throw markdownResult.reason; + const pageHtml = pageResult.status === "fulfilled" ? pageResult.value : undefined; + return { + markdown: stripBom(markdownResult.value), + ...(pageHtml ? { pageUpdatedAt: extractLastUpdatedFromHtml(pageHtml) } : {}), + pageMetadataAvailable: pageResult.status === "fulfilled", + fetchedAt: options.fetchedAt ?? new Date().toISOString(), + }; +} + +export function buildOnlineProvenance( + sourceUrl: string, + sourceRepoPath: string, + fetchedAt: string, + sourceUpdatedAt?: string, + staleAfterDays = ONLINE_DEFAULT_STALE_AFTER_DAYS, + options: { aiProcessed?: boolean; sourceMetadataAvailable?: boolean } = {}, +): OnlineProvenance { + const advisories: OnlineAdvisory[] = ["COMMUNITY_MAINTAINED"]; + if (options.aiProcessed) advisories.push("AI_PROCESSED_SOURCE"); + if (options.sourceMetadataAvailable === false || !sourceUpdatedAt) advisories.push("SOURCE_UPDATE_UNKNOWN"); + if (isStaleSource(sourceUpdatedAt, fetchedAt, staleAfterDays)) advisories.push("STALE_SOURCE"); + return { + authority: "community", + sourceUrl, + sourceRepoPath, + ...(sourceUpdatedAt ? { sourceUpdatedAt } : {}), + fetchedAt, + license: "CC-BY-SA-4.0", + licenseUrl: "https://creativecommons.org/licenses/by-sa/4.0/", + advisories, + }; +} + +export function onlineRawUrl(repoPath: string): string { + assertAllowedOnlineRepoPath(repoPath); + const segments = repoPath.split("/").map((segment) => encodeURIComponent(segment)); + return `${ONLINE_RAW_ORIGIN}/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}/${segments.join("/")}`; +} + +export function onlineSiteUrl(sitePath: string): string { + assertAllowedOnlineSitePath(sitePath); + return new URL(sitePath, ONLINE_SITE_ORIGIN).toString(); +} + +export function talkRepoPathFromSlug(slug: string): string { + return `${ONLINE_TALKS_DIRECTORY_REPO_PATH}/${normaliseTalkSlug(slug)}.md`; +} + +export function talkSitePathFromSlug(slug: string): string { + return `${ONLINE_TALKS_INDEX_SITE_PATH}${encodeURIComponent(normaliseTalkSlug(slug))}.html`; +} + +export function normaliseTalkSlug(input: string): string { + const trimmed = input.trim(); + if (!trimmed) throw new CliError("A talks slug is required.", "USAGE", 2); + let slug = trimmed; + try { + const url = new URL(trimmed); + slug = decodeURIComponent(url.pathname.split("/").pop() ?? ""); + } catch { + try { + slug = decodeURIComponent(trimmed.split("/").pop() ?? trimmed); + } catch { + throw new CliError("Talk slugs contain invalid percent encoding.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + slug: trimmed, + }); + } + } + slug = slug.replace(/\.(?:md|html)$/iu, ""); + if (!slug || /[/\\?#\p{Control}]/u.test(slug)) { + throw new CliError("Talk slugs must resolve to exactly one file name.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + slug: trimmed, + }); + } + if (slug === "." || slug === ".." || slug.includes("\0")) { + throw new CliError("Talk slugs contain unsupported path characters.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + slug: trimmed, + }); + } + return slug; +} + +export function parseTalkLabel(label: string): { + title: string; + series?: string; + speakerLine?: string; + speakerName?: string; + speakerAffiliation?: string; +} { + const cleanLabel = collapseWhitespace(stripMarkdown(label)); + const separatorIndex = findTalkSeparator(cleanLabel); + if (separatorIndex < 0) return { title: cleanLabel }; + const left = cleanLabel.slice(0, separatorIndex).trim(); + const right = cleanLabel.slice(separatorIndex + 1).trim(); + const lastQuote = left.lastIndexOf("》"); + const series = left.startsWith("《") && lastQuote >= 0 ? left.slice(0, lastQuote + 1).trim() : undefined; + const speakerLine = (series ? left.slice(lastQuote + 1) : left).trim() || undefined; + const { name, affiliation } = splitSpeakerLine(speakerLine); + return { + title: right || cleanLabel, + ...(series ? { series } : {}), + ...(speakerLine ? { speakerLine } : {}), + ...(name ? { speakerName: name } : {}), + ...(affiliation ? { speakerAffiliation: affiliation } : {}), + }; +} + +export function splitSpeakerLine(value?: string): { name?: string; affiliation?: string } { + if (!value) return {}; + const line = collapseWhitespace(stripMarkdown(value)); + const divider = line.indexOf(" @ "); + if (divider < 0) return { name: line }; + const name = line.slice(0, divider).trim(); + const affiliation = line.slice(divider + 3).trim(); + return { + ...(name ? { name } : {}), + ...(affiliation ? { affiliation } : {}), + }; +} + +export function normaliseSearchText(value: string): string { + return collapseWhitespace(stripMarkdown(value)).toLocaleLowerCase("en-US"); +} + +export function scoreSearchMatch(query: string, fields: readonly SearchField[]): number { + const needle = normaliseSearchText(query); + if (!needle) return 0; + const terms = needle.split(" ").filter(Boolean); + const combined = fields + .map((field) => normaliseSearchText(field.value ?? "")) + .filter(Boolean) + .join(" "); + if (!combined) return 0; + if (!combined.includes(needle) && terms.some((term) => !combined.includes(term))) return 0; + let score = combined.includes(needle) ? 2_000 - Math.min(combined.indexOf(needle), 999) : 0; + for (const field of fields) { + const haystack = normaliseSearchText(field.value ?? ""); + if (!haystack) continue; + if (haystack.includes(needle)) score += field.weight * (1_000 - Math.min(haystack.indexOf(needle), 999)); + for (const term of terms) { + const index = haystack.indexOf(term); + if (index >= 0) score += field.weight * (150 - Math.min(index, 149)); + } + } + return score; +} + +export function stripMarkdown(value: string): string { + return collapseWhitespace( + value + .replace(/!\[[^\x5d]*\x5d\(([^)]+)\)/gu, " $1 ") + .replace(/\[([^\x5d]+)\x5d\(([^)]+)\)/gu, "$1") + .replace(/`([^`]+)`/gu, "$1") + .replace(/[*_~>#]/gu, " ") + .replace(/]*\/>/gu, " ") + .replace(/:::.+$/gmu, " ") + .replace(/^\s*[-*+]\s+/gmu, " ") + .replace(/\|/gu, " ") + .replace(/<\/?[^>]+>/gu, " "), + ); +} + +export function uniqueStrings(values: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values.map((entry) => collapseWhitespace(entry)).filter(Boolean)) { + if (seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + +export function extractEmails(value: string): string[] { + return uniqueStrings(value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu) ?? []); +} + +export function extractPhones(value: string): string[] { + return uniqueStrings(value.match(/(?:\d{3}-\d{3,4}-\d{4}(?:-\d)?|\d{8,11})/gu) ?? []); +} + +export function extractMarkdownLink(value: string): { text: string; url: string } | undefined { + const match = /\[([^\]]+)\]\(([^)]+)\)/u.exec(value); + return match ? { text: collapseWhitespace(match[1]), url: match[2].trim() } : undefined; +} + +export function makeOnlineId(prefix: string, value: string): string { + return `${prefix}:${collapseWhitespace(value) + .toLocaleLowerCase("en-US") + .replace(/\s+/gu, "-") + .replace(/[^\p{Letter}\p{Number}-]/gu, "")}`; +} + +export function formatOnlineAdvisories(advisories: readonly OnlineAdvisory[]): string { + return advisories.join(", "); +} + +function assertAllowedOnlineRepoPath(repoPath: string): void { + if (repoPath === ONLINE_TALKS_INDEX_REPO_PATH || repoPath === ONLINE_CONTACT_REPO_PATH) return; + if (repoPath.startsWith(`${ONLINE_TALKS_DIRECTORY_REPO_PATH}/`) && repoPath.endsWith(".md")) { + const relative = repoPath.slice(`${ONLINE_TALKS_DIRECTORY_REPO_PATH}/`.length); + if (relative && !relative.includes("/")) return; + } + throw new CliError("The requested SUSTech Online source is outside the allowlist.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceRepoPath: repoPath, + }); +} + +function assertAllowedOnlineSitePath(sitePath: string): void { + if (sitePath === ONLINE_TALKS_INDEX_SITE_PATH || sitePath === ONLINE_CONTACT_SITE_PATH) return; + if (sitePath.startsWith(ONLINE_TALKS_INDEX_SITE_PATH) && sitePath.endsWith(".html")) { + const relative = sitePath.slice(ONLINE_TALKS_INDEX_SITE_PATH.length); + if (relative && !relative.includes("/")) return; + } + throw new CliError("The requested SUSTech Online page is outside the allowlist.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceUrl: sitePath, + }); +} + +function extractLastUpdatedFromHtml(html: string): string | undefined { + const match = /]+datetime="([^"]+)"/iu.exec(html); + const value = match?.[1]?.trim(); + if (!value) return undefined; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined; +} + +function isStaleSource(sourceUpdatedAt: string | undefined, fetchedAt: string, staleAfterDays: number): boolean { + if (!sourceUpdatedAt) return false; + const updatedAtMs = Date.parse(sourceUpdatedAt); + const fetchedAtMs = Date.parse(fetchedAt); + if (!Number.isFinite(updatedAtMs) || !Number.isFinite(fetchedAtMs)) return false; + return fetchedAtMs - updatedAtMs >= staleAfterDays * 24 * 60 * 60 * 1000; +} + +function findTalkSeparator(value: string): number { + const chinese = value.indexOf(":"); + if (chinese >= 0) return chinese; + const ascii = value.indexOf(":"); + return ascii; +} + +function stripBom(value: string): string { + return value.replace(/^\uFEFF/u, ""); +} + +async function fetchAllowlistedText( + adapter: ServiceAdapter, + url: string, + options: { timeoutMs?: number; kind: "raw" | "site" }, +): Promise { + let response: Response; + try { + response = await adapter.fetch(url, { + headers: { + accept: options.kind === "raw" ? "text/markdown, text/plain;q=0.9, */*;q=0.1" : "text/html, */*;q=0.1", + "user-agent": USER_AGENT, + }, + redirect: "error", + signal: AbortSignal.timeout(options.timeoutMs ?? ONLINE_DEFAULT_TIMEOUT_MS), + }); + } catch (error) { + throw new ServiceError("Could not reach the SUSTech Online source.", { + url, + cause: error instanceof Error ? error.message : String(error), + }); + } + validateFetchedUrl(response.url || url, url, options.kind); + const advertisedLength = Number(response.headers.get("content-length")); + if (Number.isFinite(advertisedLength) && advertisedLength > ONLINE_MAX_DOCUMENT_BYTES) { + throw new ServiceError("SUSTech Online returned an oversized document.", { + url, + status: response.status, + }); + } + const bytes = await readBoundedOnlineBody(response, url); + const text = new TextDecoder().decode(bytes); + if (!response.ok) { + throw new ServiceError("SUSTech Online returned an HTTP error.", { + url, + status: response.status, + bodySample: sampleText(text), + }); + } + return text; +} + +async function readBoundedOnlineBody(response: Response, url: string): Promise { + if (!response.body) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.byteLength; + if (size > ONLINE_MAX_DOCUMENT_BYTES) { + await reader.cancel().catch(() => undefined); + throw new ServiceError("SUSTech Online returned an oversized document.", { + url, + status: response.status, + }); + } + chunks.push(chunk.value); + } + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error; + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function validateFetchedUrl(value: string, expected: string, kind: "raw" | "site"): void { + const url = new URL(value); + const expectedUrl = new URL(expected); + if (url.href !== expectedUrl.href) { + throw new CliError("The fetched SUSTech Online source escaped its exact allowlist target.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceUrl: value, + }); + } + if (kind === "raw") { + const expectedPrefix = `/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}/docs/`; + if (url.origin !== ONLINE_RAW_ORIGIN || !url.pathname.startsWith(expectedPrefix)) { + throw new CliError("The fetched SUSTech Online source escaped the raw allowlist.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceUrl: value, + }); + } + return; + } + if (url.origin !== ONLINE_SITE_ORIGIN) { + throw new CliError("The fetched SUSTech Online page escaped the site allowlist.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceUrl: value, + }); + } + assertAllowedOnlineSitePath(url.pathname); +} diff --git a/src/online/talks-text.ts b/src/online/talks-text.ts new file mode 100644 index 0000000..60822b5 --- /dev/null +++ b/src/online/talks-text.ts @@ -0,0 +1,46 @@ +import { formatOnlineAdvisories } from "./shared.js"; +import { talkSearchSnippet } from "./talks.js"; +import type { OnlineTalk, OnlineTalkSummary } from "./types.js"; + +export function formatOnlineTalks(talks: readonly OnlineTalkSummary[], title = "SUSTech Online talks"): string { + if (talks.length === 0) return `${title}\n\nNo public talk records matched.`; + const blocks = talks.map((talk, index) => [ + `${index + 1}. ${talk.title}`, + ` Id: ${talk.id}`, + ` When: ${[talk.date, talk.weekday, talk.timeText].filter(Boolean).join(" ")}`, + talk.speakerLine ? ` Speaker: ${talk.speakerLine}` : "", + talk.series ? ` Series: ${talk.series}` : "", + ` Detail: ${talk.detailUrl}`, + ` Source: ${talk.provenance.sourceRepoPath}${talk.provenance.sourceUpdatedAt ? ` · updated ${talk.provenance.sourceUpdatedAt}` : ""} · ${talk.provenance.license}`, + ` Advisories: ${formatOnlineAdvisories(talk.provenance.advisories)}`, + ].filter(Boolean).join("\n")); + return `${title}\n\n${blocks.join("\n\n")}\n\n${talks.length} talk(s).`; +} + +export function formatOnlineTalkSearch(talks: readonly OnlineTalkSummary[], query: string): string { + if (talks.length === 0) return `SUSTech Online talks search · ${query}\n\nNo public talk records matched.`; + const blocks = talks.map((talk, index) => [ + `${index + 1}. ${talk.title} [${talk.id}]`, + ` ${talkSearchSnippet(talk)}`, + ].join("\n")); + return `SUSTech Online talks search · ${query}\n\n${blocks.join("\n\n")}\n\n${talks.length} match(es).`; +} + +export function formatOnlineTalk(talk: OnlineTalk): string { + const lines = [ + `SUSTech Online talk · ${talk.title}`, + "", + `Id: ${talk.id}`, + `When: ${talk.timeRangeText ?? [talk.date, talk.weekday, talk.timeText].filter(Boolean).join(" ")}`, + talk.speakerLine ? `Speaker: ${talk.speakerLine}` : "", + talk.series ? `Series: ${talk.series}` : "", + talk.venue ? `Venue: ${talk.venue}` : "", + talk.abstract ? `Abstract: ${talk.abstract}` : "", + talk.speakerBio ? `Speaker bio: ${talk.speakerBio}` : "", + talk.posterUrl ? `Poster: ${talk.posterUrl}` : "", + `Detail: ${talk.detailUrl}`, + `Source: ${talk.provenance.sourceRepoPath}${talk.provenance.sourceUpdatedAt ? ` · updated ${talk.provenance.sourceUpdatedAt}` : ""} · ${talk.provenance.license}`, + `Advisories: ${formatOnlineAdvisories(talk.provenance.advisories)}`, + ].filter(Boolean); + return lines.join("\n"); +} diff --git a/src/online/talks.ts b/src/online/talks.ts new file mode 100644 index 0000000..1ddc4cd --- /dev/null +++ b/src/online/talks.ts @@ -0,0 +1,379 @@ +import { CliError } from "../core/errors.js"; +import { collapseWhitespace } from "../services/base.js"; +import { + buildOnlineProvenance, + fetchOnlineMarkdownDocument, + normaliseTalkSlug, + onlineSiteUrl, + ONLINE_TALKS_INDEX_REPO_PATH, + ONLINE_TALKS_INDEX_SITE_PATH, + parseTalkLabel, + scoreSearchMatch, + splitSpeakerLine, + stripMarkdown, + talkRepoPathFromSlug, + talkSitePathFromSlug, + uniqueStrings, + type OnlineFetchOptions, +} from "./shared.js"; +import type { OnlineTalk, OnlineTalkSummary } from "./types.js"; + +export interface OnlineTalkQueryOptions extends OnlineFetchOptions { + limit?: number; + since?: string; + until?: string; +} + +interface RankedTalk { + score: number; + talk: OnlineTalkSummary; +} + +export async function listOnlineTalks(options: OnlineTalkQueryOptions = {}): Promise { + const range = normaliseDateRange(options.since, options.until); + const document = await fetchOnlineMarkdownDocument(ONLINE_TALKS_INDEX_REPO_PATH, ONLINE_TALKS_INDEX_SITE_PATH, options); + const talks = parseOnlineTalksIndexMarkdown(document.markdown, { + fetchedAt: document.fetchedAt, + sourceUpdatedAt: document.pageUpdatedAt, + sourceMetadataAvailable: document.pageMetadataAvailable, + staleAfterDays: options.staleAfterDays, + }); + return applyLimit(talks.filter((talk) => inDateRange(talk.date, range)), options.limit); +} + +export async function searchOnlineTalks(query: string, options: OnlineTalkQueryOptions = {}): Promise { + const needle = query.trim(); + if (!needle) throw new CliError("A search query is required.", "USAGE", 2); + const talks = await listOnlineTalks({ ...options, limit: undefined }); + const ranked = talks + .map((talk) => ({ + talk, + score: scoreSearchMatch(needle, [ + { value: talk.id, weight: 8 }, + { value: talk.title, weight: 10 }, + { value: talk.label, weight: 7 }, + { value: talk.series, weight: 6 }, + { value: talk.speakerLine, weight: 8 }, + { value: talk.date, weight: 3 }, + ]), + })) + .filter((entry) => entry.score > 0) + .sort(compareRankedTalks) + .map((entry) => entry.talk); + return applyLimit(ranked, options.limit); +} + +export async function getOnlineTalk(identifier: string, options: OnlineFetchOptions = {}): Promise { + const slug = normaliseTalkSlug(identifier); + const repoPath = talkRepoPathFromSlug(slug); + const sitePath = talkSitePathFromSlug(slug); + const document = await fetchOnlineMarkdownDocument(repoPath, sitePath, options); + return parseOnlineTalkDetailMarkdown(slug, document.markdown, { + fetchedAt: document.fetchedAt, + sourceUpdatedAt: document.pageUpdatedAt, + sourceMetadataAvailable: document.pageMetadataAvailable, + staleAfterDays: options.staleAfterDays, + }); +} + +export function parseOnlineTalksIndexMarkdown( + markdown: string, + options: { + fetchedAt: string; + sourceUpdatedAt?: string; + sourceMetadataAvailable?: boolean; + staleAfterDays?: number; + }, +): OnlineTalkSummary[] { + const lines = markdown.replace(/\r\n?/gu, "\n").split("\n"); + const sourceUrl = onlineSiteUrl(ONLINE_TALKS_INDEX_SITE_PATH); + const provenance = buildOnlineProvenance( + sourceUrl, + ONLINE_TALKS_INDEX_REPO_PATH, + options.fetchedAt, + options.sourceUpdatedAt, + options.staleAfterDays, + { aiProcessed: true, sourceMetadataAvailable: options.sourceMetadataAvailable }, + ); + const talks: OnlineTalkSummary[] = []; + let currentDate = ""; + let currentWeekday: string | undefined; + for (const rawLine of lines) { + const line = rawLine.trim(); + const heading = /^##\s+(\d{4}-\d{2}-\d{2})(?:\s+(.+))?$/u.exec(line); + if (heading) { + currentDate = isIsoDate(heading[1]) ? heading[1] : ""; + currentWeekday = currentDate && heading[2] ? collapseWhitespace(heading[2]) : undefined; + continue; + } + const entry = /^-\s+(\d{1,2}:\d{2})\s+-\s+\[(.+)\]\((.+\.md)\)$/u.exec(line); + if (!entry || !currentDate) continue; + const [, rawTimeText, label, target] = entry; + const timeText = padTime(rawTimeText); + if (!isClockTime(timeText)) continue; + const slug = relativeTalkTargetToSlug(target); + const detailRepoPath = talkRepoPathFromSlug(slug); + const detailUrl = onlineSiteUrl(talkSitePathFromSlug(slug)); + const parsed = parseTalkLabel(label); + talks.push({ + kind: "talk", + id: slug, + slug, + label: collapseWhitespace(stripMarkdown(label)), + title: parsed.title, + ...(parsed.series ? { series: parsed.series } : {}), + ...(parsed.speakerLine ? { speakerLine: parsed.speakerLine } : {}), + ...(parsed.speakerName ? { speakerName: parsed.speakerName } : {}), + ...(parsed.speakerAffiliation ? { speakerAffiliation: parsed.speakerAffiliation } : {}), + date: currentDate, + ...(currentWeekday ? { weekday: currentWeekday } : {}), + timeText, + startAt: `${currentDate}T${timeText.padStart(5, "0")}:00+08:00`, + detailUrl, + detailRepoPath, + provenance, + }); + } + return talks; +} + +export function parseOnlineTalkDetailMarkdown( + slug: string, + markdown: string, + options: { + fetchedAt: string; + sourceUpdatedAt?: string; + sourceMetadataAvailable?: boolean; + staleAfterDays?: number; + }, +): OnlineTalk { + const repoPath = talkRepoPathFromSlug(slug); + const sitePath = talkSitePathFromSlug(slug); + const sourceUrl = onlineSiteUrl(sitePath); + const provenance = buildOnlineProvenance( + sourceUrl, + repoPath, + options.fetchedAt, + options.sourceUpdatedAt, + options.staleAfterDays, + { aiProcessed: true, sourceMetadataAvailable: options.sourceMetadataAvailable }, + ); + const normalised = markdown.replace(/\r\n?/gu, "\n").replace(/^\uFEFF/u, ""); + const titleLine = /^#\s+(.+)$/mu.exec(normalised)?.[1]; + if (!titleLine) { + throw new CliError("The SUSTech Online talk detail is missing its title heading.", "UPSTREAM_PROTOCOL_ERROR", 1, { + sourceRepoPath: repoPath, + }); + } + const parsedTitle = parseTalkLabel(titleLine); + const timeRangeText = bulletField(normalised, "时间"); + const speakerLine = bulletField(normalised, "主讲人") ?? parsedTitle.speakerLine; + const speakerParts = splitSpeakerLine(speakerLine); + const venue = bulletField(normalised, "地点"); + const title = bulletField(normalised, "题目") ?? parsedTitle.title; + const rawPosterUrl = /!\[[^\x5d]*\x5d\(([^)]+)\)/u.exec(rawSectionBody(normalised, "海报链接"))?.[1]?.trim(); + const posterUrl = safePosterUrl(rawPosterUrl, sourceUrl); + const dateRange = parseTalkTimeRange(timeRangeText, slug); + return { + kind: "talk", + id: slug, + slug, + label: collapseWhitespace(stripMarkdown(titleLine)), + title, + ...(parsedTitle.series ? { series: parsedTitle.series } : {}), + ...(speakerLine ? { speakerLine } : {}), + ...(speakerParts.name ? { speakerName: speakerParts.name } : {}), + ...(speakerParts.affiliation ? { speakerAffiliation: speakerParts.affiliation } : {}), + date: dateRange.startDate, + ...(dateRange.weekday ? { weekday: dateRange.weekday } : {}), + timeText: dateRange.startTime, + ...(dateRange.startAt ? { startAt: dateRange.startAt } : {}), + detailUrl: sourceUrl, + detailRepoPath: repoPath, + provenance, + ...(timeRangeText ? { timeRangeText } : {}), + ...(dateRange.endAt ? { endAt: dateRange.endAt } : {}), + ...(venue ? { venue } : {}), + ...(sectionBody(normalised, "主讲人简介") ? { speakerBio: sectionBody(normalised, "主讲人简介") } : {}), + ...(sectionBody(normalised, "讲座简介") ? { abstract: sectionBody(normalised, "讲座简介") } : {}), + ...(posterUrl ? { posterUrl } : {}), + }; +} + +export function talkSearchSnippet(talk: OnlineTalkSummary): string { + return uniqueStrings([ + [talk.date, talk.weekday, talk.timeText].filter(Boolean).join(" "), + talk.series ?? "", + talk.speakerLine ?? "", + talk.label, + ]).join(" · "); +} + +function sectionBody(markdown: string, heading: string): string { + return collapseWhitespace(stripMarkdown(rawSectionBody(markdown, heading))); +} + +function rawSectionBody(markdown: string, heading: string): string { + return new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*\\n([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "mu") + .exec(markdown)?.[1] ?? ""; +} + +function bulletField(markdown: string, label: string): string | undefined { + const match = new RegExp(`^\\*\\s+${escapeRegExp(label)}[::]\\s*(.+)$`, "mu").exec(markdown); + return match ? collapseWhitespace(stripMarkdown(match[1])) : undefined; +} + +function relativeTalkTargetToSlug(target: string): string { + return normaliseTalkSlug(target.replace(/^\.?\//u, "")); +} + +function parseTalkTimeRange(value: string | undefined, slug: string): { + startDate: string; + startTime: string; + startAt?: string; + endAt?: string; + weekday?: string; +} { + const slugStart = talkStartFromSlug(slug); + if (!value) { + if (slugStart.startDate && slugStart.startTime) return slugStart; + throw new CliError("The SUSTech Online talk has no valid source date and time.", "UPSTREAM_PROTOCOL_ERROR", 1, { + slug, + }); + } + const singleDay = /^(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}:\d{2})(?:-(\d{1,2}:\d{2}))?$/u.exec(value); + if (singleDay) { + const startDate = isoDate(singleDay[1], singleDay[2], singleDay[3]); + const startTime = padTime(singleDay[4]); + const endTime = singleDay[5] ? padTime(singleDay[5]) : undefined; + if (isIsoDate(startDate) && isClockTime(startTime) && (!endTime || isClockTime(endTime))) { + return { + startDate, + startTime, + startAt: `${startDate}T${startTime}:00+08:00`, + ...(endTime ? { endAt: `${startDate}T${endTime}:00+08:00` } : {}), + }; + } + } + const multiDay = /^(\d{4})年(\d{1,2})月(\d{1,2})日\s*-\s*(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}:\d{2})(?:-(\d{1,2}:\d{2}))?$/u.exec(value); + if (multiDay) { + const startDate = isoDate(multiDay[1], multiDay[2], multiDay[3]); + const endDate = isoDate(multiDay[4], multiDay[5], multiDay[6]); + const startTime = padTime(multiDay[7]); + const endTime = multiDay[8] ? padTime(multiDay[8]) : undefined; + if ( + isIsoDate(startDate) + && isIsoDate(endDate) + && startDate <= endDate + && isClockTime(startTime) + && (!endTime || isClockTime(endTime)) + ) { + return { + startDate, + startTime, + startAt: `${startDate}T${startTime}:00+08:00`, + ...(endTime ? { endAt: `${endDate}T${endTime}:00+08:00` } : {}), + }; + } + } + const fallbackDate = /(\d{4})-(\d{2})-(\d{2})/u.exec(value)?.slice(1) ?? []; + const fallbackCandidateDate = fallbackDate.length === 3 + ? `${fallbackDate[0]}-${fallbackDate[1]}-${fallbackDate[2]}` + : ""; + const fallbackStartDate = isIsoDate(fallbackCandidateDate) ? fallbackCandidateDate : slugStart.startDate; + const fallbackCandidateTime = /(\d{1,2}:\d{2})/u.exec(value)?.[1] + ? padTime(/(\d{1,2}:\d{2})/u.exec(value)?.[1] ?? "") + : ""; + const fallbackStartTime = isClockTime(fallbackCandidateTime) ? fallbackCandidateTime : slugStart.startTime; + if (!fallbackStartDate || !fallbackStartTime) { + throw new CliError("The SUSTech Online talk has no valid source date and time.", "UPSTREAM_PROTOCOL_ERROR", 1, { + slug, + }); + } + return { + startDate: fallbackStartDate, + startTime: fallbackStartTime, + ...(fallbackStartDate && fallbackStartTime ? { startAt: `${fallbackStartDate}T${fallbackStartTime}:00+08:00` } : {}), + }; +} + +function talkStartFromSlug(slug: string): { startDate: string; startTime: string; startAt?: string } { + const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})/u.exec(slug); + if (!match) return { startDate: "", startTime: "" }; + const startDate = match[1]; + const startTime = `${match[2]}:${match[3]}`; + if (!isIsoDate(startDate) || !isClockTime(startTime)) return { startDate: "", startTime: "" }; + return { startDate, startTime, startAt: `${startDate}T${startTime}:00+08:00` }; +} + +function isoDate(year: string, month: string, day: string): string { + return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; +} + +function padTime(value: string): string { + return value.length === 4 ? `0${value}` : value; +} + +function compareRankedTalks(left: RankedTalk, right: RankedTalk): number { + return right.score - left.score + || right.talk.date.localeCompare(left.talk.date) + || right.talk.timeText.localeCompare(left.talk.timeText) + || left.talk.title.localeCompare(right.talk.title, "zh-Hans-CN"); +} + +function applyLimit(items: readonly T[], limit?: number): T[] { + if (limit === undefined) return [...items]; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) { + throw new CliError("Online talk limits must be integers from 1 to 200.", "USAGE", 2); + } + return items.slice(0, limit); +} + +function normaliseDateRange(since?: string, until?: string): { since?: string; until?: string } { + for (const [name, value] of [["--since", since], ["--until", until]] as const) { + if (value !== undefined && !isIsoDate(value)) { + throw new CliError(`${name} must be a real date using YYYY-MM-DD.`, "USAGE", 2); + } + } + if (since && until && since > until) { + throw new CliError("--since cannot be later than --until.", "USAGE", 2); + } + return { ...(since ? { since } : {}), ...(until ? { until } : {}) }; +} + +function isIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +function safePosterUrl(value: string | undefined, baseUrl: string): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value, baseUrl); + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + const hostname = url.hostname.toLocaleLowerCase("en-US"); + const allowed = hostname === "gtimg.liziwl.cn" + || hostname === "sustech.online" + || hostname === "sustech.edu.cn" + || hostname.endsWith(".sustech.edu.cn"); + return allowed ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function isClockTime(value: string): boolean { + const match = /^(\d{2}):(\d{2})$/u.exec(value); + return Boolean(match && Number(match[1]) <= 23 && Number(match[2]) <= 59); +} + +function inDateRange(date: string, range: { since?: string; until?: string }): boolean { + if (range.since && date < range.since) return false; + if (range.until && date > range.until) return false; + return true; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} diff --git a/src/online/types.ts b/src/online/types.ts new file mode 100644 index 0000000..fb8b539 --- /dev/null +++ b/src/online/types.ts @@ -0,0 +1,70 @@ +export type OnlineAuthority = "community"; +export type OnlineAdvisory = + | "AI_PROCESSED_SOURCE" + | "COMMUNITY_MAINTAINED" + | "SOURCE_UPDATE_UNKNOWN" + | "STALE_SOURCE"; + +export interface OnlineProvenance { + authority: OnlineAuthority; + sourceUrl: string; + sourceRepoPath: string; + sourceUpdatedAt?: string; + fetchedAt: string; + license: "CC-BY-SA-4.0"; + licenseUrl: "https://creativecommons.org/licenses/by-sa/4.0/"; + advisories: readonly OnlineAdvisory[]; +} + +export interface OnlineTalkSummary { + kind: "talk"; + id: string; + slug: string; + label: string; + title: string; + series?: string; + speakerLine?: string; + speakerName?: string; + speakerAffiliation?: string; + date: string; + weekday?: string; + timeText: string; + startAt?: string; + detailUrl: string; + detailRepoPath: string; + provenance: OnlineProvenance; +} + +export interface OnlineTalk extends OnlineTalkSummary { + timeRangeText?: string; + endAt?: string; + venue?: string; + speakerBio?: string; + abstract?: string; + posterUrl?: string; +} + +export interface OnlineContactRecord { + kind: "contact"; + id: string; + name: string; + category: string; + categoryKey: string; + phones: string[]; + emails: string[]; + address?: string; + hours?: string[]; + websiteUrl?: string; + notes: string[]; + provenance: OnlineProvenance; +} + +export interface OnlineSearchHit { + kind: "talk" | "contact"; + id: string; + title: string; + subtitle?: string; + snippet: string; + url?: string; + provenance: OnlineProvenance; +} diff --git a/src/services/index.ts b/src/services/index.ts index 36029b4..815f381 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -4,6 +4,7 @@ import { LIBRARY_BOOKING_STATUS, LIBRARY_CATALOG_STATUS } from "./library.js"; import { NCES_STATUS } from "./nces.js"; import { PAPERS_STATUS } from "./papers.js"; import { PMS_STATUS } from "./pms.js"; +import { SUSTECH_ONLINE_STATUS } from "./sustech-online.js"; import { WS_STATUS } from "./ws.js"; import type { ServiceStatus } from "./base.js"; @@ -16,6 +17,7 @@ export const SERVICE_STATUSES: readonly ServiceStatus[] = [ PMS_STATUS, NCES_STATUS, PAPERS_STATUS, + SUSTECH_ONLINE_STATUS, ] as const; export function serviceStatus(name: string): ServiceStatus | undefined { @@ -62,4 +64,5 @@ export * from "./nces.js"; export * from "./papers.js"; export * from "./pms-auth.js"; export * from "./pms.js"; +export * from "./sustech-online.js"; export * from "./ws.js"; diff --git a/src/services/sustech-online.ts b/src/services/sustech-online.ts new file mode 100644 index 0000000..882131c --- /dev/null +++ b/src/services/sustech-online.ts @@ -0,0 +1,31 @@ +import { + ONLINE_CONTACT_REPO_PATH, + ONLINE_CONTACT_SITE_PATH, + ONLINE_RAW_ORIGIN, + ONLINE_REPO_BRANCH, + ONLINE_REPO_NAME, + ONLINE_REPO_OWNER, + ONLINE_SITE_ORIGIN, + ONLINE_TALKS_INDEX_REPO_PATH, + ONLINE_TALKS_INDEX_SITE_PATH, +} from "../online/shared.js"; +import type { ServiceStatus } from "./base.js"; + +export const SUSTECH_ONLINE_STATUS: ServiceStatus = { + service: "sustech-online", + availability: "implemented", + auth: "none", + campusNetwork: false, + browser: false, + summary: "Selected public talks and institutional contacts are read from the community-maintained SUSTech Online project.", + notes: [ + "Results retain community authority, source, freshness, and CC BY-SA attribution metadata.", + "High-stakes, financial, personal, dining/chat, and professor-list contact sections are excluded.", + ], + endpoints: [ + `${ONLINE_RAW_ORIGIN}/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}/${ONLINE_TALKS_INDEX_REPO_PATH}`, + `${ONLINE_RAW_ORIGIN}/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}/${ONLINE_CONTACT_REPO_PATH}`, + `${ONLINE_SITE_ORIGIN}${ONLINE_TALKS_INDEX_SITE_PATH}`, + `${ONLINE_SITE_ORIGIN}${ONLINE_CONTACT_SITE_PATH}`, + ], +}; diff --git a/src/test/argv.test.ts b/src/test/argv.test.ts index d6fa6ff..f091772 100644 --- a/src/test/argv.test.ts +++ b/src/test/argv.test.ts @@ -27,6 +27,9 @@ test("command inference skips option values in machine-readable errors", () => { assert.equal(inferCommandName(["describe", "bb", "submit", "apply", "--json"]), "describe"); assert.equal(inferCommandName(["library", "search", "machine learning", "--limit", "5", "--json"]), "library search"); assert.equal(inferCommandName(["library", "detail", "L:alma991234567890106561", "--json"]), "library detail"); + assert.equal(inferCommandName(["online", "search", "AI", "--section", "talks", "--json"]), "online search"); + assert.equal(inferCommandName(["online", "talks", "search", "AI safety", "--limit", "5", "--json"]), "online talks search"); + assert.equal(inferCommandName(["online", "contact", "get", "teaching:教学工作部", "--json"]), "online contact get"); assert.equal(inferCommandName(["profile", "show", "--profile", "personal", "--json"]), "profile show"); assert.equal(inferCommandName(["profile", "export", "--destination", "/tmp/profile.json", "--overwrite", "--json"]), "profile export"); assert.equal(inferCommandName(["auth", "login", "--profile", "personal", "--sid", "12410000"]), "auth login"); diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 725a156..6b92f4b 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -18,7 +18,7 @@ test("compiled CLI serves human text and versioned JSON from the real entrypoint const text = run(["version"]); assert.equal(text.status, 0); assert.match(text.stdout, /:\*##: :#######:/); - assert.match(text.stdout, /sustech-cli 0\.9\.0/); + assert.match(text.stdout, /sustech-cli 0\.10\.0/); assert.doesNotMatch(text.stdout, /\u001b\[/); const json = run(["version", "--json"]); @@ -27,7 +27,7 @@ test("compiled CLI serves human text and versioned JSON from the real entrypoint schemaVersion: "1", ok: true, command: "version", - data: { version: "0.9.0", runtime: `node ${process.version}` }, + data: { version: "0.10.0", runtime: `node ${process.version}` }, }); }); @@ -83,6 +83,21 @@ test("calendar and selection inputs reject normalized or silently-coerced values assert.equal(JSON.parse(invalidCultivation.stdout).error.code, "USAGE"); }); +test("online commands are registered and reject unsafe IDs before network access", () => { + const unsafe = run(["online", "talks", "get", "%2F", "--json"]); + assert.equal(unsafe.status, 2); + assert.equal(JSON.parse(unsafe.stdout).command, "online talks get"); + assert.equal(JSON.parse(unsafe.stdout).error.code, "ONLINE_SOURCE_NOT_ALLOWED"); + + const invalidDate = run(["online", "talks", "list", "--since", "2026-02-30", "--json"]); + assert.equal(invalidDate.status, 2); + assert.equal(JSON.parse(invalidDate.stdout).error.code, "USAGE"); + + const described = run(["describe", "online", "talks", "search", "--json"]); + assert.equal(described.status, 0); + assert.equal(JSON.parse(described.stdout).data.command, "online talks search"); +}); + test("enrollment preview is a no-network command with an exact apply handoff", () => { const result = run([ "tis", "enroll", "preview", @@ -242,6 +257,7 @@ test("context accepts calendar-level and help documents it", () => { assert.match(help.stdout, /sustech academic watch --state PATH \[--semester YYYY-YYYY-N\] \[--include-blackboard\] \[--overwrite\]/); assert.match(help.stdout, /sustech library search QUERY \[--limit N\] \[--browser \[--interactive\]\]/); assert.match(help.stdout, /sustech library detail CONTEXT:DOC_ID \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech online talks search QUERY \[--since YYYY-MM-DD\] \[--until YYYY-MM-DD\] \[--limit N\]/); assert.match(help.stdout, /sustech tis plan explain COURSE_OR_RWH --round ROUND/); assert.match(help.stdout, /sustech tis plan recommend \[CODE\.\.\.\] --round ROUND/); assert.match(help.stdout, /sustech tis degree missing \[--semester YYYY-YYYY-N\]/); @@ -501,7 +517,11 @@ test("auth profile commands are machine-readable without exposing or inventing c test("new local Agent surfaces remain machine-readable and mutation-free", () => { const services = run(["services", "status", "--json"]); assert.equal(services.status, 0); - assert.ok(JSON.parse(services.stdout).data.statuses.length >= 8); + assert.ok(JSON.parse(services.stdout).data.statuses.length >= 9); + + const onlineService = run(["services", "status", "sustech-online", "--json"]); + assert.equal(onlineService.status, 0); + assert.equal(JSON.parse(onlineService.stdout).data.statuses[0].availability, "implemented"); const risks = run(["consequences", "tis.drop", "--json"]); assert.equal(risks.status, 0); diff --git a/src/test/dashboard.test.ts b/src/test/dashboard.test.ts index 53d7c0c..343c301 100644 --- a/src/test/dashboard.test.ts +++ b/src/test/dashboard.test.ts @@ -15,7 +15,7 @@ const loggedOut: CredentialProfileStatus = { test("dashboard guides an unconfigured profile to login without expanding full help", () => { const output = formatDashboard({ - version: "0.9.0", + version: "0.10.0", runtime: "node v22.0.0", credentials: loggedOut, brandArt: "ART", @@ -31,7 +31,7 @@ test("dashboard guides an unconfigured profile to login without expanding full h test("dashboard shows the masked active account and authenticated quick actions", () => { const output = formatDashboard({ - version: "0.9.0", + version: "0.10.0", runtime: "node v22.0.0", credentials: { ...loggedOut, @@ -54,17 +54,17 @@ test("dashboard shows the masked active account and authenticated quick actions" test("dashboard uses a fastfetch-style side-by-side layout only when it fits", () => { const wide = formatDashboard({ - version: "0.9.0", + version: "0.10.0", runtime: "node v22.0.0", credentials: loggedOut, brandArt: "AAAA\nBBBB", terminalColumns: 80, }); - assert.match(wide, /^AAAA {4}sustech-cli 0\.9\.0/); + assert.match(wide, /^AAAA {4}sustech-cli 0\.10\.0/); assert.match(wide, /^BBBB {4}$/m); const narrow = formatDashboard({ - version: "0.9.0", + version: "0.10.0", runtime: "node v22.0.0", credentials: loggedOut, brandArt: "AAAA\nBBBB", diff --git a/src/test/fixtures/empty-cli.ts b/src/test/fixtures/empty-cli.ts new file mode 100644 index 0000000..32312f0 --- /dev/null +++ b/src/test/fixtures/empty-cli.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +process.exitCode = 0; diff --git a/src/test/fixtures/invalid-json-cli.ts b/src/test/fixtures/invalid-json-cli.ts new file mode 100644 index 0000000..00a8618 --- /dev/null +++ b/src/test/fixtures/invalid-json-cli.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +process.stdout.write("not a JSON envelope\n"); diff --git a/src/test/fixtures/oversized-cli.ts b/src/test/fixtures/oversized-cli.ts new file mode 100644 index 0000000..fa8876e --- /dev/null +++ b/src/test/fixtures/oversized-cli.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +process.stdout.write(JSON.stringify({ + schemaVersion: "1", + ok: true, + command: "version", + data: { payload: "x".repeat(2 * 1024 * 1024) }, +})); diff --git a/src/test/fixtures/slow-cli.ts b/src/test/fixtures/slow-cli.ts new file mode 100644 index 0000000..64c6fab --- /dev/null +++ b/src/test/fixtures/slow-cli.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +import { writeFileSync } from "node:fs"; + +process.on("SIGTERM", () => { + const marker = process.env.SUSTECH_MCP_TEST_CANCEL_MARKER; + if (marker) writeFileSync(marker, "cancelled\n", "utf8"); + process.exit(0); +}); + +setTimeout(() => { + process.stdout.write(JSON.stringify({ schemaVersion: "1", ok: true, command: "version", data: {} })); +}, 30_000); diff --git a/src/test/mcp-stdio.test.ts b/src/test/mcp-stdio.test.ts new file mode 100644 index 0000000..b24182a --- /dev/null +++ b/src/test/mcp-stdio.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { CLI_VERSION } from "../core/version.js"; + +test("stdio MCP entrypoint exposes help and version without starting the protocol", () => { + const serverPath = fileURLToPath(new URL("../mcp/server.js", import.meta.url)); + + const help = spawnSync(process.execPath, [serverPath, "--help"], { encoding: "utf8" }); + assert.equal(help.status, 0); + assert.match(help.stdout, /Usage:/u); + assert.match(help.stdout, /Serve MCP over stdio/u); + + const version = spawnSync(process.execPath, [serverPath, "--version"], { encoding: "utf8" }); + assert.equal(version.status, 0); + assert.equal(version.stdout.trim(), CLI_VERSION); + + const invalid = spawnSync(process.execPath, [serverPath, "--bogus"], { encoding: "utf8" }); + assert.equal(invalid.status, 2); + assert.match(invalid.stderr, /unsupported argument/u); + assert.equal(invalid.stdout, ""); +}); + +test("stdio MCP entrypoint runs through an npm-style executable symlink", { skip: process.platform === "win32" }, () => { + const serverPath = fileURLToPath(new URL("../mcp/server.js", import.meta.url)); + const temporaryDirectory = mkdtempSync(join(tmpdir(), "sustech-mcp-bin-")); + const executablePath = join(temporaryDirectory, "sustech-mcp"); + try { + symlinkSync(serverPath, executablePath); + const version = spawnSync(process.execPath, [executablePath, "--version"], { encoding: "utf8" }); + assert.equal(version.status, 0); + assert.equal(version.stdout.trim(), CLI_VERSION); + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test("packaged stdio MCP negotiates the current protocol and serves typed tools", async () => { + const serverPath = fileURLToPath(new URL("../mcp/server.js", import.meta.url)); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverPath], + stderr: "pipe", + }); + const client = new Client( + { name: "sustech-cli-stdio-test", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } }, + ); + + try { + await client.connect(transport); + const listed = await client.listTools(); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_online_talks_search")); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_library_search")); + assert.ok(listed.tools.every((tool) => tool.name !== "sustech_run")); + + const resources = await client.listResources(); + assert.equal(resources.resources.length, 5); + assert.ok(resources.resources.some((resource) => resource.uri === "sustech://version")); + + const prompts = await client.listPrompts(); + assert.equal(prompts.prompts.length, 4); + assert.ok(prompts.prompts.some((prompt) => prompt.name === "sustech_public_lookup")); + + const version = await client.callTool({ name: "sustech_version", arguments: {} }); + assert.equal(version.isError, undefined); + assert.equal((version.structuredContent as { ok: boolean }).ok, true); + + const versionResource = await client.readResource({ uri: "sustech://version" }); + assert.match(resourceText(versionResource.contents[0]), /"command": "version"/); + + const guardedOnline = await client.callTool({ + name: "sustech_online_talks_get", + arguments: { id: "%2F" }, + }); + assert.equal(guardedOnline.isError, true); + assert.match(JSON.stringify(guardedOnline.structuredContent), /ONLINE_SOURCE_NOT_ALLOWED/); + } finally { + await client.close(); + } +}); + +function resourceText( + content: { text: string; uri: string } | { blob: string; uri: string } | undefined, +): string { + assert.ok(content); + assert.ok("text" in content); + return content.text; +} diff --git a/src/test/mcp.test.ts b/src/test/mcp.test.ts new file mode 100644 index 0000000..35afaa5 --- /dev/null +++ b/src/test/mcp.test.ts @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { fileURLToPath } from "node:url"; +import { MCP_TOOL_BY_COMMAND } from "../mcp/registry.js"; +import { createSustechMcpServer } from "../mcp/server.js"; +import { + describeCliForMcp, + runCliForMcp, + validateMcpCommand, +} from "../mcp/runner.js"; + +test("MCP runner blocks remote mutations and local state writes", () => { + assert.throws( + () => validateMcpCommand("tis selection apply", []), + /MCP_MUTATION_BLOCKED/, + ); + assert.throws( + () => validateMcpCommand("tis plan add", ["CS101"]), + /MCP_MUTATION_BLOCKED/, + ); + assert.throws( + () => validateMcpCommand("version", ["--reveal"]), + /MCP_ARGUMENT_BLOCKED/, + ); + assert.throws( + () => validateMcpCommand("library search", ["aspirin", "--browser"]), + /MCP_ARGUMENT_BLOCKED/, + ); + assert.throws( + () => validateMcpCommand("library detail", ["L:alma123", "--interactive"]), + /MCP_ARGUMENT_BLOCKED/, + ); + assert.doesNotThrow(() => validateMcpCommand("calendar day", ["--date", "2026-08-29"])); + assert.doesNotThrow(() => validateMcpCommand("faculty search", ["vision"])); + assert.throws(() => validateMcpCommand("profile show", []), /MCP_COMMAND_NOT_EXPOSED/); + assert.throws(() => validateMcpCommand("context", []), /MCP_COMMAND_NOT_EXPOSED/); + assert.throws(() => validateMcpCommand("wifi status", []), /MCP_COMMAND_NOT_EXPOSED/); +}); + +test("MCP runner reuses CLI JSON envelopes", async () => { + const version = await runCliForMcp("version"); + assert.equal(version.exitCode, 0); + assert.equal(version.envelope.ok, true); + assert.equal(version.envelope.command, "version"); + + const description = await describeCliForMcp("version"); + assert.equal(description.exitCode, 0); + assert.equal(description.envelope.ok, true); + assert.equal(description.envelope.command, "describe"); +}); + +test("MCP runner terminates the CLI when the client cancels", async () => { + const controller = new AbortController(); + const slowCliPath = fileURLToPath(new URL("./fixtures/slow-cli.js", import.meta.url)); + const pending = runCliForMcp("version", [], { cliPath: slowCliPath, signal: controller.signal }); + setTimeout(() => controller.abort(), 25); + await assert.rejects(pending, /MCP_CLI_CANCELLED/u); +}); + +test("MCP client cancellation reaches the server tool and terminates its CLI process", { skip: process.platform === "win32" }, async () => { + const slowCliPath = fileURLToPath(new URL("./fixtures/slow-cli.js", import.meta.url)); + const temporaryDirectory = mkdtempSync(join(tmpdir(), "sustech-mcp-cancel-")); + const markerPath = join(temporaryDirectory, "cancelled.txt"); + const previousMarker = process.env.SUSTECH_MCP_TEST_CANCEL_MARKER; + process.env.SUSTECH_MCP_TEST_CANCEL_MARKER = markerPath; + const server = createSustechMcpServer({ runner: { cliPath: slowCliPath } }); + const client = new Client({ name: "sustech-cli-cancel-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const controller = new AbortController(); + const pending = client.callTool( + { name: "sustech_version", arguments: {} }, + { signal: controller.signal }, + ); + setTimeout(() => controller.abort(), 25); + await assert.rejects(pending, /abort|cancel/u); + for (let attempt = 0; attempt < 20 && !existsSync(markerPath); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(readFileSync(markerPath, "utf8").trim(), "cancelled"); + } finally { + if (previousMarker === undefined) delete process.env.SUSTECH_MCP_TEST_CANCEL_MARKER; + else process.env.SUSTECH_MCP_TEST_CANCEL_MARKER = previousMarker; + await Promise.allSettled([client.close(), server.close()]); + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test("MCP runner rejects pre-cancelled and timed-out commands", async () => { + const slowCliPath = fileURLToPath(new URL("./fixtures/slow-cli.js", import.meta.url)); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + runCliForMcp("version", [], { cliPath: slowCliPath, signal: controller.signal }), + /MCP_CLI_CANCELLED/u, + ); + await assert.rejects( + runCliForMcp("version", [], { cliPath: slowCliPath, timeoutMs: 10 }), + /MCP_CLI_TIMEOUT/u, + ); +}); + +test("MCP runner rejects missing, invalid, and oversized CLI envelopes", async () => { + const emptyCliPath = fileURLToPath(new URL("./fixtures/empty-cli.js", import.meta.url)); + const invalidCliPath = fileURLToPath(new URL("./fixtures/invalid-json-cli.js", import.meta.url)); + const oversizedCliPath = fileURLToPath(new URL("./fixtures/oversized-cli.js", import.meta.url)); + await assert.rejects(runCliForMcp("version", [], { cliPath: emptyCliPath }), /MCP_CLI_NO_OUTPUT/u); + await assert.rejects(runCliForMcp("version", [], { cliPath: invalidCliPath }), /MCP_CLI_INVALID_OUTPUT/u); + await assert.rejects(runCliForMcp("version", [], { cliPath: oversizedCliPath }), /MCP_OUTPUT_TOO_LARGE/u); +}); + +test("MCP exposes discovery, description, and a typed public-read allowlist", async () => { + const server = createSustechMcpServer(); + const client = new Client({ name: "sustech-cli-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + + const listed = await client.listTools(); + assert.deepEqual( + listed.tools.map((tool) => tool.name).sort(), + [...new Set(Object.values(MCP_TOOL_BY_COMMAND))].sort(), + ); + assert.ok(listed.tools.every((tool) => tool.name !== "sustech_run")); + assert.ok(listed.tools.every((tool) => tool.annotations?.readOnlyHint === true)); + assert.equal(listed.tools.length, 33); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_library_search_url")); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_transit_live")); + + const discovered = await client.callTool({ + name: "sustech_discover", + arguments: { kind: "read", query: "calendar" }, + }); + assert.equal(discovered.isError, undefined); + const discovery = discovered.structuredContent as { capabilities: Array<{ command: string; mcpExecutable: boolean }> }; + assert.ok(discovery.capabilities.some((entry) => entry.command === "calendar day" && entry.mcpExecutable)); + + const run = await client.callTool({ name: "sustech_version", arguments: {} }); + assert.equal(run.isError, undefined); + assert.equal((run.structuredContent as { ok: boolean }).ok, true); + + const consequences = await client.callTool({ + name: "sustech_consequences", + arguments: { operation: "tis.drop" }, + }); + assert.equal(consequences.isError, undefined); + assert.equal((consequences.structuredContent as { command: string }).command, "consequences"); + + const resources = await client.callTool({ + name: "sustech_resources_search", + arguments: { query: "library" }, + }); + assert.equal(resources.isError, undefined); + assert.equal((resources.structuredContent as { command: string }).command, "resources search"); + + const services = await client.callTool({ + name: "sustech_services_status", + arguments: { service: "sustech-online" }, + }); + assert.equal(services.isError, undefined); + assert.equal((services.structuredContent as { command: string }).command, "services status"); + + const blocked = await client.callTool({ + name: "sustech_describe", + arguments: { command: "tis plan add" }, + }); + assert.equal(blocked.isError, undefined); + const described = blocked.structuredContent as { data: { capability: { command: string } } }; + assert.equal(described.data.capability.command, "tis plan add"); + + const listedResources = await client.listResources(); + assert.equal(listedResources.resources.length, 5); + assert.ok(listedResources.resources.some((resource) => resource.uri === "sustech://version")); + assert.ok(listedResources.resources.some((resource) => resource.uri === "sustech://mcp/policy")); + + const listedTemplates = await client.listResourceTemplates(); + assert.equal(listedTemplates.resourceTemplates.length, 5); + assert.ok(listedTemplates.resourceTemplates.some((resource) => resource.uriTemplate === "sustech://faculty/{slug}")); + assert.ok(listedTemplates.resourceTemplates.some((resource) => resource.uriTemplate === "sustech://library/{context}/{docId}")); + + const versionResource = await client.readResource({ uri: "sustech://version" }); + assert.equal(versionResource.contents[0]?.mimeType, "application/json"); + assert.match(resourceText(versionResource.contents[0]), /"command": "version"/); + + const policyResource = await client.readResource({ uri: "sustech://mcp/policy" }); + assert.match(resourceText(policyResource.contents[0]), /"typedAllowlist": true/); + + const prompts = await client.listPrompts(); + assert.equal(prompts.prompts.length, 4); + assert.deepEqual( + prompts.prompts.map((prompt) => prompt.name).sort(), + [ + "sustech_course_research", + "sustech_guarded_cli_review", + "sustech_public_lookup", + "sustech_talk_digest", + ], + ); + + const prompt = await client.getPrompt({ + name: "sustech_public_lookup", + arguments: { question: "Where can I find public SUSTech talks?" }, + }); + assert.match(JSON.stringify(prompt.messages), /community-maintained/u); + + const commandResource = await client.readResource({ uri: "sustech://command/version" }); + assert.match(resourceText(commandResource.contents[0]), /"command": "describe"/u); + + const spacedCommandResource = await client.readResource({ uri: "sustech://command/calendar%20day" }); + assert.match(resourceText(spacedCommandResource.contents[0]), /"calendar day"/u); + + const invalidCommandResource = await client.readResource({ uri: "sustech://command/%252F" }); + assert.match(resourceText(invalidCommandResource.contents[0]), /MCP_RESOURCE_INVALID_ARGUMENT/u); + + const invalidNcesResource = await client.readResource({ uri: "sustech://nces/course/not-a-number" }); + assert.match(resourceText(invalidNcesResource.contents[0]), /MCP_RESOURCE_INVALID_ARGUMENT/u); + + const invalidLibraryResource = await client.readResource({ uri: "sustech://library/public-json/%252F" }); + assert.match(resourceText(invalidLibraryResource.contents[0]), /MCP_RESOURCE_INVALID_ARGUMENT/u); + + const guardedTalkResource = await client.readResource({ uri: "sustech://online/talk/%252F" }); + assert.match(resourceText(guardedTalkResource.contents[0]), /MCP_RESOURCE_INVALID_ARGUMENT/u); + } finally { + await Promise.allSettled([client.close(), server.close()]); + } +}); + +function resourceText( + content: { text: string; uri: string } | { blob: string; uri: string } | undefined, +): string { + assert.ok(content); + assert.ok("text" in content); + return content.text; +} diff --git a/src/test/online-contact.test.ts b/src/test/online-contact.test.ts new file mode 100644 index 0000000..2596794 --- /dev/null +++ b/src/test/online-contact.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ServiceAdapter } from "../services/base.js"; +import { + ONLINE_CONTACT_REPO_PATH, + ONLINE_CONTACT_SITE_PATH, + getOnlineContact, + onlineRawUrl, + onlineSiteUrl, + parseOnlineContactsMarkdown, + searchOnlineContacts, +} from "../online/index.js"; + +const FETCHED_AT = "2026-09-01T00:00:00.000Z"; +const UPDATED_AT = "2025-03-04T16:26:17.000Z"; + +const CONTACT_MARKDOWN = ` +# 黄页 + +## 电话与邮件 + +**座机默认区号0755** + +**24h 校内服务热线(物业热线,报修用,查号用): 88010123** + +**一般办公时间** +- 周一至周五 +- 上午 8:30 - 12:00 +- 下午 2:00 - 5:30 + +### 教学 + +- [教授邮件列表](./professor-emails) + +- 学生事务中心 + - 南科大中心二楼 + - 电话:88010555 + - 公共邮箱:servicescenter@sustech.edu.cn + - 本科生教学事务 + - 选课、退课、成绩单打印 + +- [教学工作部 | 联系方式](https://tao.sustech.edu.cn/department/index.html) + - 办公地点: 南科大中心三楼 303 + - 公共邮箱(教学事务): tao@sustech.edu.cn + - 选课咨询电话: 88010300 + +### 物流、餐饮、康体、后勤 + +| 名称 | 地址 | 电话 | 工作时间 | +| --- | --- | --- | --- | +| 信息中心 | 行政楼一楼 | 88010777 | 8:30-17:30 | +| 餐饮服务中心 | | 88015026 | | + +### 医疗与安全 + +- 24小时急诊联系电话: 18218715551 +- 安保报警: 88010110 + +### 行政 + +- 党政办公室: 88010229 + +### 更多官方部门的联系方式 + +- [联系我们/南方科技大学](https://www.sustech.edu.cn/zh/contact_us.html) + +- [伪造官方入口](https://sustech.edu.cn.evil.example/phishing) + +## 报销抬头 + +> 开户银行:测试银行 +> 银行账号:8110301013200000000 + +## 常用Q群 + +- 美食旅游:1094223907 +`; + +test("contact parser keeps only selected institutional records", () => { + const records = parseOnlineContactsMarkdown(CONTACT_MARKDOWN, { + fetchedAt: FETCHED_AT, + sourceUpdatedAt: UPDATED_AT, + sourceMetadataAvailable: true, + }); + const names = records.map((record) => record.name); + assert.ok(names.includes("24h 校内服务热线")); + assert.ok(names.includes("学生事务中心")); + assert.ok(names.includes("教学工作部")); + assert.ok(names.includes("信息中心")); + assert.ok(names.includes("党政办公室")); + assert.ok(names.includes("联系我们/南方科技大学")); + assert.ok(!names.includes("伪造官方入口")); + assert.ok(!names.some((name) => /教授邮件|餐饮|急诊|安保|美食/u.test(name))); + assert.equal(JSON.stringify(records).includes("8110301013200000000"), false); + assert.equal(JSON.stringify(records).includes("1094223907"), false); + + const student = records.find((record) => record.name === "学生事务中心"); + assert.deepEqual(student?.phones, ["88010555"]); + assert.deepEqual(student?.emails, ["servicescenter@sustech.edu.cn"]); + assert.equal(student?.address, "南科大中心二楼"); + assert.deepEqual(student?.hours, ["周一至周五", "上午 8:30 - 12:00", "下午 2:00 - 5:30"]); + assert.ok(student?.provenance.advisories.includes("STALE_SOURCE")); + assert.equal(student?.provenance.advisories.includes("AI_PROCESSED_SOURCE"), false); + assert.equal(student?.provenance.license, "CC-BY-SA-4.0"); + + const teaching = records.find((record) => record.name === "教学工作部"); + assert.equal(teaching?.notes.some((note) => note.includes("tao@sustech.edu.cn")), false); +}); + +test("contact search ranks the full selected set before applying limit", async () => { + const adapter = contactAdapter(); + const results = await searchOnlineContacts("信息中心", { adapter, fetchedAt: FETCHED_AT, limit: 1 }); + assert.deepEqual(results.map((record) => record.name), ["信息中心"]); + + const exact = await getOnlineContact(results[0].id, { adapter, fetchedAt: FETCHED_AT }); + assert.equal(exact.name, "信息中心"); + await assert.rejects( + getOnlineContact("信息", { adapter, fetchedAt: FETCHED_AT }), + hasCode("ONLINE_CONTACT_NOT_FOUND"), + ); +}); + +test("contact reads tolerate unavailable freshness HTML but mark it unknown", async () => { + const records = await searchOnlineContacts("信息中心", { + adapter: contactAdapter({ failSite: true }), + fetchedAt: FETCHED_AT, + }); + assert.ok(records[0].provenance.advisories.includes("SOURCE_UPDATE_UNKNOWN")); +}); + +function contactAdapter(options: { failSite?: boolean } = {}): ServiceAdapter { + return { + name: "contact-fixture", + async fetch(input: string): Promise { + if (input === onlineRawUrl(ONLINE_CONTACT_REPO_PATH)) return textResponse(CONTACT_MARKDOWN); + if (input === onlineSiteUrl(ONLINE_CONTACT_SITE_PATH)) { + if (options.failSite) throw new Error("site metadata unavailable"); + return textResponse(``, "text/html"); + } + throw new Error(`Unexpected fixture URL: ${input}`); + }, + }; +} + +function textResponse(body: string, contentType = "text/markdown"): Response { + return new Response(body, { status: 200, headers: { "content-type": contentType } }); +} + +function hasCode(code: string): (error: unknown) => boolean { + return (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/src/test/online-talks.test.ts b/src/test/online-talks.test.ts new file mode 100644 index 0000000..150ffd4 --- /dev/null +++ b/src/test/online-talks.test.ts @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ServiceAdapter } from "../services/base.js"; +import { + ONLINE_TALKS_INDEX_REPO_PATH, + ONLINE_TALKS_INDEX_SITE_PATH, + ONLINE_MAX_DOCUMENT_BYTES, + getOnlineTalk, + listOnlineTalks, + normaliseTalkSlug, + onlineRawUrl, + onlineSiteUrl, + parseOnlineTalkDetailMarkdown, + parseOnlineTalksIndexMarkdown, + searchOnlineTalks, + talkRepoPathFromSlug, + talkSitePathFromSlug, +} from "../online/index.js"; + +const FETCHED_AT = "2026-09-01T00:00:00.000Z"; +const UPDATED_AT = "2026-08-20T00:00:00.000Z"; +const FIRST_SLUG = "2026-09-10T10-00-00_Alice"; +const SECOND_SLUG = "2026-08-20T15-30-00_Bob"; + +const INDEX_MARKDOWN = ` +# 讲座信息 + +> 以下内容根据公开信息整理,并经大模型处理生成,可能存在疏漏或误差,请以实际信息为准。 + +## 2026-09-10 周四 + +- 10:00 - [《科学大讲堂 第1期》Alice Professor @ Example University:Quantum Widgets](${FIRST_SLUG}.md) + +## 2026-08-20 周四 + +- 15:30 - [Bob Researcher:An Older Matching Lecture](${SECOND_SLUG}.md) +`; + +const DETAIL_MARKDOWN = ` +# 《科学大讲堂 第1期》Alice Professor @ Example University:Quantum Widgets + +> 以下内容根据公开信息整理,并经大模型处理生成,可能存在疏漏或误差,请以实际信息为准。 + +* 题目: Quantum Widgets +* 主讲人:Alice Professor @ Example University +* 时间:2026年9月10日 10:00-11:00 +* 地点:第一科研楼 101 + +## 主讲人简介 +Alice studies widgets. + +## 讲座简介 +An introduction to quantum widgets. + +## 海报链接 +![](https://gtimg.liziwl.cn/poster.jpg) +`; + +test("talk index parser returns stable records with community provenance", () => { + const talks = parseOnlineTalksIndexMarkdown(INDEX_MARKDOWN, { + fetchedAt: FETCHED_AT, + sourceUpdatedAt: UPDATED_AT, + sourceMetadataAvailable: true, + }); + assert.equal(talks.length, 2); + assert.equal(talks[0].id, FIRST_SLUG); + assert.equal(talks[0].title, "Quantum Widgets"); + assert.equal(talks[0].speakerName, "Alice Professor"); + assert.equal(talks[0].speakerAffiliation, "Example University"); + assert.equal(talks[0].startAt, "2026-09-10T10:00:00+08:00"); + assert.equal(talks[0].provenance.authority, "community"); + assert.equal(talks[0].provenance.license, "CC-BY-SA-4.0"); + assert.deepEqual(talks[0].provenance.advisories, ["COMMUNITY_MAINTAINED", "AI_PROCESSED_SOURCE"]); +}); + +test("talk detail parser extracts bounded fields and falls back to the slug time", () => { + const talk = parseOnlineTalkDetailMarkdown(FIRST_SLUG, DETAIL_MARKDOWN, { + fetchedAt: FETCHED_AT, + sourceUpdatedAt: UPDATED_AT, + sourceMetadataAvailable: true, + }); + assert.equal(talk.date, "2026-09-10"); + assert.equal(talk.timeText, "10:00"); + assert.equal(talk.endAt, "2026-09-10T11:00:00+08:00"); + assert.equal(talk.venue, "第一科研楼 101"); + assert.equal(talk.abstract, "An introduction to quantum widgets."); + assert.equal(talk.speakerBio, "Alice studies widgets."); + assert.equal(talk.posterUrl, "https://gtimg.liziwl.cn/poster.jpg"); + + const unsafePoster = parseOnlineTalkDetailMarkdown( + FIRST_SLUG, + DETAIL_MARKDOWN.replace("https://gtimg.liziwl.cn/poster.jpg", "javascript:alert(1)"), + { + fetchedAt: FETCHED_AT, + sourceUpdatedAt: UPDATED_AT, + sourceMetadataAvailable: true, + }, + ); + assert.equal(unsafePoster.posterUrl, undefined); + + const untrustedPoster = parseOnlineTalkDetailMarkdown( + FIRST_SLUG, + DETAIL_MARKDOWN.replace("https://gtimg.liziwl.cn/poster.jpg", "https://evil.example/poster.jpg"), + { + fetchedAt: FETCHED_AT, + sourceUpdatedAt: UPDATED_AT, + sourceMetadataAvailable: true, + }, + ); + assert.equal(untrustedPoster.posterUrl, undefined); + + const withoutTime = parseOnlineTalkDetailMarkdown(FIRST_SLUG, DETAIL_MARKDOWN.replace(/^\* 时间:.*$/mu, ""), { + fetchedAt: FETCHED_AT, + sourceUpdatedAt: UPDATED_AT, + sourceMetadataAvailable: true, + }); + assert.equal(withoutTime.date, "2026-09-10"); + assert.equal(withoutTime.timeText, "10:00"); +}); + +test("talk list and search apply date/limit after parsing the full index", async () => { + const adapter = talksAdapter(); + const listed = await listOnlineTalks({ + adapter, + fetchedAt: FETCHED_AT, + since: "2026-09-01", + until: "2026-09-30", + limit: 1, + }); + assert.deepEqual(listed.map((talk) => talk.id), [FIRST_SLUG]); + + const searched = await searchOnlineTalks("Older Matching", { + adapter, + fetchedAt: FETCHED_AT, + limit: 1, + }); + assert.deepEqual(searched.map((talk) => talk.id), [SECOND_SLUG]); + + const detail = await getOnlineTalk(FIRST_SLUG, { adapter, fetchedAt: FETCHED_AT }); + assert.equal(detail.title, "Quantum Widgets"); +}); + +test("talk reads tolerate unavailable freshness HTML but mark it unknown", async () => { + const adapter = talksAdapter({ failSite: true }); + const talks = await listOnlineTalks({ adapter, fetchedAt: FETCHED_AT, limit: 2 }); + assert.ok(talks[0].provenance.advisories.includes("SOURCE_UPDATE_UNKNOWN")); +}); + +test("talk reads reject invalid freshness metadata and bound streamed source bodies", async () => { + const invalidTimestampAdapter: ServiceAdapter = { + name: "invalid-timestamp", + async fetch(input: string): Promise { + if (input === onlineRawUrl(ONLINE_TALKS_INDEX_REPO_PATH)) return textResponse(INDEX_MARKDOWN); + if (input === onlineSiteUrl(ONLINE_TALKS_INDEX_SITE_PATH)) { + return textResponse('', "text/html"); + } + throw new Error(`Unexpected fixture URL: ${input}`); + }, + }; + const talks = await listOnlineTalks({ adapter: invalidTimestampAdapter, fetchedAt: FETCHED_AT }); + assert.equal(talks[0].provenance.sourceUpdatedAt, undefined); + assert.ok(talks[0].provenance.advisories.includes("SOURCE_UPDATE_UNKNOWN")); + + const oversizedAdapter: ServiceAdapter = { + name: "oversized-source", + async fetch(input: string): Promise { + if (input === onlineRawUrl(ONLINE_TALKS_INDEX_REPO_PATH)) { + return new Response(new Uint8Array(ONLINE_MAX_DOCUMENT_BYTES + 1), { status: 200 }); + } + if (input === onlineSiteUrl(ONLINE_TALKS_INDEX_SITE_PATH)) return textResponse("", "text/html"); + throw new Error(`Unexpected fixture URL: ${input}`); + }, + }; + await assert.rejects( + listOnlineTalks({ adapter: oversizedAdapter, fetchedAt: FETCHED_AT }), + /oversized document/u, + ); +}); + +test("talk IDs cannot escape the single-file source allowlist", () => { + assert.throws(() => normaliseTalkSlug("%2F"), hasCode("ONLINE_SOURCE_NOT_ALLOWED")); + assert.throws(() => normaliseTalkSlug("..%2Fsecret"), hasCode("ONLINE_SOURCE_NOT_ALLOWED")); +}); + +test("talk parsers never emit impossible source dates or times", () => { + const invalidIndex = `${INDEX_MARKDOWN}\n## 2026-02-30 周一\n\n- 99:99 - [Invalid source row](2026-02-30T99-99-00_Invalid.md)\n`; + const talks = parseOnlineTalksIndexMarkdown(invalidIndex, { + fetchedAt: FETCHED_AT, + sourceUpdatedAt: UPDATED_AT, + sourceMetadataAvailable: true, + }); + assert.deepEqual(talks.map((talk) => talk.id), [FIRST_SLUG, SECOND_SLUG]); + + const fallback = parseOnlineTalkDetailMarkdown( + FIRST_SLUG, + DETAIL_MARKDOWN.replace("2026年9月10日 10:00-11:00", "2026年2月30日 99:99"), + { fetchedAt: FETCHED_AT, sourceUpdatedAt: UPDATED_AT, sourceMetadataAvailable: true }, + ); + assert.equal(fallback.startAt, "2026-09-10T10:00:00+08:00"); + assert.equal(fallback.endAt, undefined); + + assert.throws( + () => parseOnlineTalkDetailMarkdown( + "2026-02-30T99-99-00_Invalid", + DETAIL_MARKDOWN.replace(/^\* 时间:.*$/mu, ""), + { fetchedAt: FETCHED_AT, sourceUpdatedAt: UPDATED_AT, sourceMetadataAvailable: true }, + ), + hasCode("UPSTREAM_PROTOCOL_ERROR"), + ); +}); + +test("talk date filters reject impossible dates before fetching", async () => { + let fetched = false; + await assert.rejects( + listOnlineTalks({ + since: "2026-02-30", + adapter: { + name: "must-not-fetch", + async fetch(): Promise { + fetched = true; + throw new Error("unexpected fetch"); + }, + }, + }), + /real date using YYYY-MM-DD/u, + ); + assert.equal(fetched, false); +}); + +function talksAdapter(options: { failSite?: boolean } = {}): ServiceAdapter { + return { + name: "talk-fixture", + async fetch(input: string): Promise { + if (input === onlineRawUrl(ONLINE_TALKS_INDEX_REPO_PATH)) return textResponse(INDEX_MARKDOWN); + if (input === onlineSiteUrl(ONLINE_TALKS_INDEX_SITE_PATH)) { + if (options.failSite) throw new Error("site metadata unavailable"); + return textResponse(``, "text/html"); + } + if (input === onlineRawUrl(talkRepoPathFromSlug(FIRST_SLUG))) return textResponse(DETAIL_MARKDOWN); + if (input === onlineSiteUrl(talkSitePathFromSlug(FIRST_SLUG))) { + if (options.failSite) throw new Error("site metadata unavailable"); + return textResponse(``, "text/html"); + } + throw new Error(`Unexpected fixture URL: ${input}`); + }, + }; +} + +function textResponse(body: string, contentType = "text/markdown"): Response { + return new Response(body, { status: 200, headers: { "content-type": contentType } }); +} + +function hasCode(code: string): (error: unknown) => boolean { + return (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} From 64ad6dd81b67cbe3a8076a135701a1912f13367a Mon Sep 17 00:00:00 2001 From: Apryle Wu Date: Sat, 29 Aug 2026 15:59:27 +0800 Subject: [PATCH 2/2] test: stabilize MCP cancellation regression --- src/test/fixtures/slow-cli.ts | 3 +++ src/test/mcp.test.ts | 20 ++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/test/fixtures/slow-cli.ts b/src/test/fixtures/slow-cli.ts index 64c6fab..8a2d23a 100644 --- a/src/test/fixtures/slow-cli.ts +++ b/src/test/fixtures/slow-cli.ts @@ -8,6 +8,9 @@ process.on("SIGTERM", () => { process.exit(0); }); +const readyMarker = process.env.SUSTECH_MCP_TEST_READY_MARKER; +if (readyMarker) writeFileSync(readyMarker, "ready\n", "utf8"); + setTimeout(() => { process.stdout.write(JSON.stringify({ schemaVersion: "1", ok: true, command: "version", data: {} })); }, 30_000); diff --git a/src/test/mcp.test.ts b/src/test/mcp.test.ts index 35afaa5..279ffcc 100644 --- a/src/test/mcp.test.ts +++ b/src/test/mcp.test.ts @@ -65,8 +65,11 @@ test("MCP client cancellation reaches the server tool and terminates its CLI pro const slowCliPath = fileURLToPath(new URL("./fixtures/slow-cli.js", import.meta.url)); const temporaryDirectory = mkdtempSync(join(tmpdir(), "sustech-mcp-cancel-")); const markerPath = join(temporaryDirectory, "cancelled.txt"); + const readyMarkerPath = join(temporaryDirectory, "ready.txt"); const previousMarker = process.env.SUSTECH_MCP_TEST_CANCEL_MARKER; + const previousReadyMarker = process.env.SUSTECH_MCP_TEST_READY_MARKER; process.env.SUSTECH_MCP_TEST_CANCEL_MARKER = markerPath; + process.env.SUSTECH_MCP_TEST_READY_MARKER = readyMarkerPath; const server = createSustechMcpServer({ runner: { cliPath: slowCliPath } }); const client = new Client({ name: "sustech-cli-cancel-test", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -78,20 +81,29 @@ test("MCP client cancellation reaches the server tool and terminates its CLI pro { name: "sustech_version", arguments: {} }, { signal: controller.signal }, ); - setTimeout(() => controller.abort(), 25); + await waitForFile(readyMarkerPath); + controller.abort(); await assert.rejects(pending, /abort|cancel/u); - for (let attempt = 0; attempt < 20 && !existsSync(markerPath); attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForFile(markerPath); assert.equal(readFileSync(markerPath, "utf8").trim(), "cancelled"); } finally { if (previousMarker === undefined) delete process.env.SUSTECH_MCP_TEST_CANCEL_MARKER; else process.env.SUSTECH_MCP_TEST_CANCEL_MARKER = previousMarker; + if (previousReadyMarker === undefined) delete process.env.SUSTECH_MCP_TEST_READY_MARKER; + else process.env.SUSTECH_MCP_TEST_READY_MARKER = previousReadyMarker; await Promise.allSettled([client.close(), server.close()]); rmSync(temporaryDirectory, { recursive: true, force: true }); } }); +async function waitForFile(path: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (existsSync(path)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(`Timed out waiting for test marker: ${path}`); +} + test("MCP runner rejects pre-cancelled and timed-out commands", async () => { const slowCliPath = fileURLToPath(new URL("./fixtures/slow-cli.js", import.meta.url)); const controller = new AbortController();