wicker-study-mcp 2.0.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/README.md CHANGED
@@ -65,8 +65,11 @@ they are how a key is obtained. Everything else needs one.
65
65
  `set_course_visibility`, `join_programme`
66
66
  - **Documents and calendars** — `analyze_documents`, `apply_changes`, `preview_calendar`,
67
67
  `save_calendar_link`, `sync_calendar_link`, `remove_calendar_link`
68
- - **Canvas** — `canvas_connect`, `canvas_list_remote_courses`, `canvas_list_remote_course_modules`,
69
- `canvas_import_remote_course`, `canvas_import_remote_course_set`
68
+ - **Canvas** — `canvas_connect`, `canvas_updates` (announcements, assignments with
69
+ submission state, events, grades), `canvas_course_requirements` (syllabus and the
70
+ module item carrying the assessment rules), `canvas_list_remote_courses`,
71
+ `canvas_list_remote_course_modules`, `canvas_import_remote_course`,
72
+ `canvas_import_remote_course_set`
70
73
  - **Editorial (admin key)** — the `admin_*` family: course editions, source folders, rights review,
71
74
  extraction, mapping, generation, artifact review, and publication.
72
75
 
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "wicker-study-mcp",
3
- "version": "2.0.0",
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 with an admin key run the editorial workflow.",
5
- "keywords": ["mcp", "model-context-protocol", "wicker-study", "canvas-lms", "study", "education"],
3
+ "version": "2.2.0",
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
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "wicker-study",
9
+ "canvas-lms",
10
+ "study",
11
+ "education"
12
+ ],
6
13
  "license": "MIT",
7
14
  "author": "David Wicker",
8
15
  "homepage": "https://study.wicker.life",
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.0.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
 
@@ -475,7 +475,7 @@ server.tool('list_mock_sessions', 'Completed mock sessions.', {}, run(() => api(
475
475
  server.tool('get_mock_session', 'One mock session with every answer and correction.', { sessionId: z.string() }, run(({ sessionId }) => api(`/api/mocks/${encodeURIComponent(sessionId)}`)))
476
476
  server.tool('get_academic_plan', 'Active academic programme: courses, attempts, exam dates, events, gates, summary.', {}, run(() => api('/api/academics')))
477
477
  server.tool('list_known_programmes', 'The catalogue of known bachelor programmes.', {}, run(() => api('/api/editorial-programmes')))
478
- server.tool('get_calendar', 'Unified calendar: exams, deadlines, registration windows, institution dates, and timetable feed events.', { from: z.string().optional().describe('ISO date; omit for everything'), to: z.string().optional() },
478
+ server.tool('get_calendar', 'Unified calendar in one call: exam attempts, personal events, registration windows, the institution calendar, saved timetable feeds (lectures, tutorials, labs), and — when Canvas is connected — Canvas assignment deadlines and Canvas course events. This is the tool for "when is my next lecture", "where do I need to be", and "what is due this week". Events carry `category`, `courseCode`, and for Canvas items a `canvasStatus`; `problems` names any source that could not be read, which is how you tell an empty week from a missing timetable feed.', { from: z.string().optional().describe('ISO date; omit for everything'), to: z.string().optional() },
479
479
  run(async ({ from, to }) => { const data = await api('/api/calendar/events'); const events = data.events.filter((e) => (!from || String(e.start) >= from) && (!to || String(e.start) <= to)); return { ...data, events } }))
480
480
  server.tool('get_activity', 'Study activity series, streak, weekly totals, recent events.', { days: z.number().int().min(7).max(120).optional() }, run(({ days }) => api('/api/activity', { query: { days } })))
481
481
  server.tool('get_account_summary', 'What is stored for the account, per record family.', {}, run(() => api('/api/account/summary')))
@@ -507,10 +507,48 @@ server.tool('set_course_visibility', 'Archive/unarchive or reorder a course for
507
507
  run(({ courseId, archived, order }) => api(`/api/courses/${encodeURIComponent(courseId)}`, { method: 'PATCH', body: { archived, order } })))
508
508
 
509
509
  // ── Canvas through the account connection (no local PAT) ──────────────────
510
+ server.tool('canvas_updates',
511
+ 'What is happening in the student’s Canvas courses right now: announcements, assignments with their submission state, Canvas course events, and the grade Canvas shows. This is the tool for "what was announced", "what is due", "what have I not handed in", and "how am I doing". Answers are cached for ten minutes; pass refresh:true only when the student says something is missing. Never returns the Canvas token.',
512
+ {
513
+ scope: z.enum(['current', 'all']).optional().describe('"current" (default) is the courses being taught now, plus any the student starred on their Canvas dashboard and the standing faculty spaces. "all" includes concluded enrolments.'),
514
+ days: z.number().int().min(1).max(365).optional().describe('How far back to read announcements. Default 60.'),
515
+ courseIds: z.array(z.string()).optional().describe('Restrict to these Canvas course ids. Overrides scope.'),
516
+ parts: z.array(z.enum(['announcements', 'assignments', 'events', 'grades'])).optional().describe('Fetch only what is needed. Omitting this fetches all four.'),
517
+ refresh: z.boolean().optional()
518
+ },
519
+ run(({ scope, days, courseIds, parts, refresh }) => api('/api/integrations/canvas/hub', {
520
+ query: {
521
+ canvasUrl: DEFAULT_CANVAS_URL,
522
+ scope,
523
+ days,
524
+ courseIds: courseIds?.join(','),
525
+ parts: parts?.join(','),
526
+ refresh: refresh ? '1' : undefined
527
+ }
528
+ })))
529
+
530
+ server.tool('canvas_course_requirements',
531
+ 'The course syllabus and whichever module item carries the rules — assessment components and weights, minimum grades, attendance, deadlines, resit conditions. Use this for "what do I need to pass", "is attendance mandatory", "how is this graded". Canvas’s own syllabus field is usually only a filename or an unfilled placeholder, so when `syllabus.substantive` is false the answer is in `requirementItems`: fetch that File or Page and read it before answering. Never state a rule you have not read in a source — say it is not published yet instead.',
532
+ { courseUrl: z.string().describe('Canvas course URL, e.g. https://canvas.example.edu/courses/25806/modules. Use canvas_list_remote_courses to find it.') },
533
+ run(async ({ courseUrl }) => {
534
+ const result = await listRemoteCanvasCourseModules({ courseUrl })
535
+ return {
536
+ course: result.course,
537
+ syllabus: result.syllabus,
538
+ requirementItems: result.requirementItems,
539
+ next: result.syllabus?.substantive
540
+ ? 'The syllabus text below is the source. Cite it.'
541
+ : result.requirementItems?.length
542
+ ? 'Read these items before answering. canvas_import_remote_course with the containing module downloads them locally, where a File can be opened and a Page is saved as readable HTML.'
543
+ : 'Neither a syllabus nor a requirements document is published for this course yet. Say so; do not infer rules from convention or from another course.',
544
+ moduleCount: result.modules?.length ?? 0
545
+ }
546
+ }))
547
+
510
548
  server.tool('canvas_list_remote_courses', 'List current and concluded Canvas courses from the caller’s encrypted Wicker Study Canvas connection. Search title, course code, term, or title initials (for example “IUI”). The Canvas PAT is never returned to the agent.', {
511
549
  canvasUrl: z.string().url().default('https://canvas.maastrichtuniversity.nl'), query: z.string().max(240).optional()
512
550
  }, run(listRemoteCanvasCourses))
513
- server.tool('canvas_list_remote_course_modules', 'List modules for a Canvas course using the caller’s encrypted Wicker Study Canvas connection. Use this before importing a chosen subset.', {
551
+ server.tool('canvas_list_remote_course_modules', 'List modules and their items for a Canvas course, plus its syllabus and any item that looks like the course manual. Use this before importing a chosen subset; for rules and assessment specifically, canvas_course_requirements returns the same data already narrowed down.', {
514
552
  courseUrl: z.string().url()
515
553
  }, run(listRemoteCanvasCourseModules))
516
554
  server.tool('canvas_import_remote_course', 'Download an entire Canvas course or selected modules into a private local folder through Wicker’s authenticated Canvas proxy. The destination is local to this MCP process, so Claude/Codex can analyse it using its own subscription; it never receives the Canvas PAT. Canvas pages are followed recursively within the course, linked files download when accessible, and URLs are compiled into link indexes.', {
@@ -36,6 +36,41 @@ function filename(value, fallback = 'material') {
36
36
  return raw || fallback
37
37
  }
38
38
 
39
+ // The document that carries assessment rules, attendance requirements, and
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
+ export const COURSE_REQUIREMENTS_PATTERN = /(syllabus|course\s*manual|coursemanual|course\s*outline|course\s*information|study\s*guide|handbook|assessment)/i
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
+
39
74
  function fileCategory(value) {
40
75
  const name = text(value, 240).toLowerCase()
41
76
  if (/(syllabus|course manual|course outline|study guide|course information)/.test(name)) return 'course-information'
@@ -289,15 +324,68 @@ export async function listCanvasCourseModules({ courseUrl, accessToken, fetchImp
289
324
  api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}?include[]=syllabus_body`),
290
325
  api.getPaged(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/modules?include[]=items&per_page=100`)
291
326
  ])
327
+ const mapped = modules.sort((left, right) => number(left.position) - number(right.position)).map((module) => ({
328
+ id: String(module.id || ''),
329
+ name: text(module.name, 300) || 'Untitled module',
330
+ position: number(module.position),
331
+ items: Array.isArray(module.items) ? module.items.map((item) => ({
332
+ id: String(item.id || ''),
333
+ title: text(item.title, 300) || item.type || 'Untitled item',
334
+ type: text(item.type, 80) || 'Unknown',
335
+ indent: number(item.indent),
336
+ contentId: item.content_id ? String(item.content_id) : null,
337
+ pageSlug: item.page_url ? text(item.page_url, 200) : null,
338
+ url: item.html_url ? text(item.html_url, 500) : null
339
+ })) : []
340
+ })).filter((module) => module.id)
341
+
342
+ // The syllabus field was fetched above; hand it back rather than dropping it,
343
+ // and say plainly when it is only a pointer. A field this short is a filename
344
+ // or an unfilled placeholder, not the rules — the real document is the module
345
+ // item flagged below, and it still has to be read.
346
+ const rawSyllabus = String(course.syllabus_body || '')
347
+ const syllabusHtml = sanitizeCanvasHtml(rawSyllabus)
348
+ const syllabusText = text(String(syllabusHtml).replace(/<[^>]*>/g, ' '), 20_000)
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
+ })
370
+
292
371
  return {
293
372
  origin: canvas.origin,
294
373
  course: { id: String(course.id || canvas.courseId), name: text(course.name, 300) || `Canvas course ${canvas.courseId}`, courseCode: text(course.course_code, 160) || null, workflowState: text(course.workflow_state, 80) || null, courseUrl: canvas.courseUrl },
295
- modules: modules.sort((left, right) => number(left.position) - number(right.position)).map((module) => ({
296
- id: String(module.id || ''),
297
- name: text(module.name, 300) || 'Untitled module',
298
- position: number(module.position),
299
- items: Array.isArray(module.items) ? module.items.map((item) => ({ id: String(item.id || ''), title: text(item.title, 300) || item.type || 'Untitled item', type: text(item.type, 80) || 'Unknown', indent: number(item.indent), contentId: item.content_id ? String(item.content_id) : null })) : []
300
- })).filter((module) => module.id)
374
+ syllabus: {
375
+ html: syllabusHtml || null,
376
+ text: syllabusText || null,
377
+ // 200 characters of rich text is not a syllabus. Say so rather than
378
+ // letting a reader treat a filename as the course requirements.
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.'
386
+ },
387
+ requirementItems,
388
+ modules: mapped
301
389
  }
302
390
  }
303
391