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 +5 -2
- package/package.json +10 -3
- package/server.mjs +41 -3
- package/vendor/canvas-course-import.mjs +94 -6
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`, `
|
|
69
|
-
|
|
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.
|
|
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
|
|
5
|
-
"keywords": [
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|