wicker-study-mcp 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicker-study-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server for Wicker Study: read course material and a student's academic record, study on their behalf, collect a private Canvas course snapshot, and \u2014 with an admin key \u2014 run the editorial workflow.",
5
5
  "keywords": [
6
6
  "mcp",
package/server.mjs CHANGED
@@ -71,7 +71,7 @@ const json = (value) => ({ content: [{ type: 'text', text: typeof value === 'str
71
71
  const failed = (error) => ({ isError: true, content: [{ type: 'text', text: error.message }] })
72
72
  const run = (fn) => async (args) => { try { return json(await fn(args)) } catch (error) { return failed(error) } }
73
73
 
74
- const server = new McpServer({ name: 'wicker-study', version: '2.1.0' })
74
+ const server = new McpServer({ name: 'wicker-study', version: '2.2.0' })
75
75
  const courseId = z.string().describe('Course id (e.g. "sec"). Use list_courses to discover ids.')
76
76
  const chapterId = z.string().describe('Chapter id (e.g. "02").')
77
77
 
@@ -37,11 +37,40 @@ function filename(value, fallback = 'material') {
37
37
  }
38
38
 
39
39
  // The document that carries assessment rules, attendance requirements, and
40
- // deadlines. On real Maastricht courses it is a module item a PDF or a Canvas
41
- // page — and almost never the Canvas syllabus field, which usually holds only
42
- // its filename or an unfilled teacher placeholder.
40
+ // deadlines. Two places hold it, and the course usually uses only one: the
41
+ // Canvas Syllabus page — which typically contains a link to a PDF rather than
42
+ // the rules themselves — or a module item.
43
43
  export const COURSE_REQUIREMENTS_PATTERN = /(syllabus|course\s*manual|coursemanual|course\s*outline|course\s*information|study\s*guide|handbook|assessment)/i
44
44
 
45
+ // Maastricht ships every course a Syllabus page pre-filled with a link to a
46
+ // how-to guide. A course still carrying it has published nothing, and treating
47
+ // that link as the syllabus would be worse than reporting none.
48
+ const SYLLABUS_PLACEHOLDER = /scribehow\.com|embed\s+(?:your\s+)?(?:the\s+)?course\s+syllabus/i
49
+
50
+ // Pull the documents a Canvas Syllabus page links to. Stylesheets and the
51
+ // institution's placeholder are not documents.
52
+ export function syllabusDocuments(html, { origin = '', courseId = '' } = {}) {
53
+ const documents = []
54
+ for (const match of String(html || '').matchAll(/<a\b[^>]*href\s*=\s*"([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi)) {
55
+ const href = match[1]
56
+ const label = text(match[2].replace(/<[^>]*>/g, ' '), 200)
57
+ if (/\.css(\?|$)/i.test(href) || SYLLABUS_PLACEHOLDER.test(href) || SYLLABUS_PLACEHOLDER.test(label)) continue
58
+ const fileId = (href.match(/\/files\/(\d+)/) || [])[1] || null
59
+ const pageSlug = (href.match(new RegExp(`/courses/${courseId}/pages/([^/?#]+)`)) || [])[1] || null
60
+ documents.push({
61
+ title: label || 'Course syllabus',
62
+ type: fileId ? 'File' : pageSlug ? 'Page' : 'ExternalUrl',
63
+ contentId: fileId,
64
+ pageSlug: pageSlug ? decodeSegment(pageSlug) : null,
65
+ // A Canvas file link is fetchable through the account connection; an
66
+ // external one is recorded but never followed.
67
+ url: fileId && origin ? `${origin}/courses/${courseId}/files/${fileId}` : href,
68
+ source: 'syllabus-page'
69
+ })
70
+ }
71
+ return documents
72
+ }
73
+
45
74
  function fileCategory(value) {
46
75
  const name = text(value, 240).toLowerCase()
47
76
  if (/(syllabus|course manual|course outline|study guide|course information)/.test(name)) return 'course-information'
@@ -314,11 +343,30 @@ export async function listCanvasCourseModules({ courseUrl, accessToken, fetchImp
314
343
  // and say plainly when it is only a pointer. A field this short is a filename
315
344
  // or an unfilled placeholder, not the rules — the real document is the module
316
345
  // item flagged below, and it still has to be read.
317
- const syllabusHtml = sanitizeCanvasHtml(course.syllabus_body || '')
346
+ const rawSyllabus = String(course.syllabus_body || '')
347
+ const syllabusHtml = sanitizeCanvasHtml(rawSyllabus)
318
348
  const syllabusText = text(String(syllabusHtml).replace(/<[^>]*>/g, ' '), 20_000)
319
- const requirementItems = mapped.flatMap((module) => module.items
320
- .filter((item) => COURSE_REQUIREMENTS_PATTERN.test(item.title) && ['File', 'Page', 'Attachment'].includes(item.type))
321
- .map((item) => ({ ...item, module: module.name })))
349
+ const placeholder = SYLLABUS_PLACEHOLDER.test(rawSyllabus)
350
+ // Both places, in the order a reader should try them: the Syllabus page is
351
+ // where a course is supposed to put this, and a module item is where it ends
352
+ // up when the page was left as the institution's template.
353
+ const seenDocuments = new Set()
354
+ const requirementItems = [
355
+ ...syllabusDocuments(rawSyllabus, { origin: canvas.origin, courseId: canvas.courseId }),
356
+ ...mapped.flatMap((module) => module.items
357
+ .filter((item) => COURSE_REQUIREMENTS_PATTERN.test(item.title) && ['File', 'Page', 'Attachment'].includes(item.type))
358
+ .map((item) => ({ ...item, module: module.name, source: 'module' })))
359
+ ].filter((item) => {
360
+ // A course often carries the same document twice — linked from the Syllabus
361
+ // page and uploaded again as a module item, sometimes under different file
362
+ // ids. One entry is what a reader needs, and the Syllabus-page one comes
363
+ // first, so match on the filename as well as the id.
364
+ const name = String(item.title || '').trim().toLowerCase()
365
+ const keys = [item.contentId ? `file:${item.contentId}` : item.pageSlug ? `page:${item.pageSlug}` : `url:${item.url}`, name ? `name:${name}` : null].filter(Boolean)
366
+ if (keys.some((key) => seenDocuments.has(key))) return false
367
+ for (const key of keys) seenDocuments.add(key)
368
+ return true
369
+ })
322
370
 
323
371
  return {
324
372
  origin: canvas.origin,
@@ -328,10 +376,13 @@ export async function listCanvasCourseModules({ courseUrl, accessToken, fetchImp
328
376
  text: syllabusText || null,
329
377
  // 200 characters of rich text is not a syllabus. Say so rather than
330
378
  // letting a reader treat a filename as the course requirements.
331
- substantive: syllabusText.length >= 200,
332
- note: syllabusText.length >= 200 ? null : syllabusText
333
- ? 'The Canvas syllabus field only points at a document; read the requirements item instead.'
334
- : 'This course has no Canvas syllabus text. Read the requirements item, or ask the student for the course manual.'
379
+ substantive: syllabusText.length >= 200 && !placeholder,
380
+ placeholder,
381
+ note: syllabusText.length >= 200 && !placeholder ? null
382
+ : placeholder ? 'This course still has the institution’s empty syllabus template. Nothing has been published on the Syllabus page.'
383
+ : requirementItems.some((item) => item.source === 'syllabus-page') ? 'The Canvas Syllabus page links to the document rather than containing it. Read the item in requirementItems.'
384
+ : requirementItems.length ? 'This course has no Canvas syllabus text; the requirements document is a module item. Read the item in requirementItems.'
385
+ : 'This course has published no syllabus text and links to no document.'
335
386
  },
336
387
  requirementItems,
337
388
  modules: mapped