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 +5 -2
- package/package.json +10 -3
- package/server.mjs +41 -3
- package/vendor/canvas-course-import.mjs +43 -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.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.
|
|
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:
|
|
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,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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|