wicker-study-mcp 2.1.0 → 2.3.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 +1 -1
- package/server.mjs +1 -1
- package/vendor/canvas-course-import.mjs +62 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicker-study-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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.
|
|
74
|
+
const server = new McpServer({ name: 'wicker-study', version: '2.3.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.
|
|
41
|
-
// page —
|
|
42
|
-
//
|
|
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
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
: 'This course has
|
|
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
|