wicker-study-mcp 2.0.0 → 2.1.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.1.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.1.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,12 @@ 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. 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.
43
+ export const COURSE_REQUIREMENTS_PATTERN = /(syllabus|course\s*manual|coursemanual|course\s*outline|course\s*information|study\s*guide|handbook|assessment)/i
44
+
39
45
  function fileCategory(value) {
40
46
  const name = text(value, 240).toLowerCase()
41
47
  if (/(syllabus|course manual|course outline|study guide|course information)/.test(name)) return 'course-information'
@@ -289,15 +295,46 @@ export async function listCanvasCourseModules({ courseUrl, accessToken, fetchImp
289
295
  api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}?include[]=syllabus_body`),
290
296
  api.getPaged(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/modules?include[]=items&per_page=100`)
291
297
  ])
298
+ const mapped = modules.sort((left, right) => number(left.position) - number(right.position)).map((module) => ({
299
+ id: String(module.id || ''),
300
+ name: text(module.name, 300) || 'Untitled module',
301
+ position: number(module.position),
302
+ items: Array.isArray(module.items) ? module.items.map((item) => ({
303
+ id: String(item.id || ''),
304
+ title: text(item.title, 300) || item.type || 'Untitled item',
305
+ type: text(item.type, 80) || 'Unknown',
306
+ indent: number(item.indent),
307
+ contentId: item.content_id ? String(item.content_id) : null,
308
+ pageSlug: item.page_url ? text(item.page_url, 200) : null,
309
+ url: item.html_url ? text(item.html_url, 500) : null
310
+ })) : []
311
+ })).filter((module) => module.id)
312
+
313
+ // The syllabus field was fetched above; hand it back rather than dropping it,
314
+ // and say plainly when it is only a pointer. A field this short is a filename
315
+ // or an unfilled placeholder, not the rules — the real document is the module
316
+ // item flagged below, and it still has to be read.
317
+ const syllabusHtml = sanitizeCanvasHtml(course.syllabus_body || '')
318
+ 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 })))
322
+
292
323
  return {
293
324
  origin: canvas.origin,
294
325
  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)
326
+ syllabus: {
327
+ html: syllabusHtml || null,
328
+ text: syllabusText || null,
329
+ // 200 characters of rich text is not a syllabus. Say so rather than
330
+ // 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.'
335
+ },
336
+ requirementItems,
337
+ modules: mapped
301
338
  }
302
339
  }
303
340