wicker-study-mcp 2.5.0 → 2.7.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 +14 -2
- package/package.json +1 -1
- package/server.mjs +47 -5
- package/vendor/canvas-course-import.mjs +19 -3
package/README.md
CHANGED
|
@@ -9,6 +9,17 @@ Runs from anywhere. Nothing here needs a checkout of the application.
|
|
|
9
9
|
|
|
10
10
|
## Use it
|
|
11
11
|
|
|
12
|
+
The authenticated **Docs** page in Wicker Study can mint a scoped key and place it directly inside
|
|
13
|
+
one copy-ready Codex or Claude Code installation block. There is no separate credential field to
|
|
14
|
+
copy. The first line stores the embedded key with owner-only permissions and the second registers
|
|
15
|
+
the MCP server.
|
|
16
|
+
|
|
17
|
+
For a manually supplied key, the same secure bootstrap is available as:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
WICKER_STUDY_URL='https://study.wicker.life' WICKER_STUDY_API_KEY='wsk_…' npx -y wicker-study-mcp@2.7.0 configure
|
|
21
|
+
```
|
|
22
|
+
|
|
12
23
|
```jsonc
|
|
13
24
|
// Claude Desktop / Claude Code / Cursor MCP config
|
|
14
25
|
{
|
|
@@ -59,10 +70,11 @@ they are how a key is obtained. Everything else needs one.
|
|
|
59
70
|
- **Reading** — `list_courses`, `get_course`, `get_chapter`, `get_course_outline`, `search_course`,
|
|
60
71
|
`list_materials`, `list_questions`, `get_practice_queue`, `get_progress`, `list_flashcards`,
|
|
61
72
|
`list_due_cards`, `list_mistakes`, `list_mock_sessions`, `get_mock_session`, `get_academic_plan`,
|
|
62
|
-
`list_known_programmes`, `get_calendar`, `get_activity`,
|
|
73
|
+
`get_planning_context`, `list_known_programmes`, `get_calendar`, `get_activity`,
|
|
74
|
+
`get_account_summary`, `whoami`
|
|
63
75
|
- **Studying** — `submit_answer`, `set_mastery`, `review_card`, `add_to_deck`, `create_flashcard`,
|
|
64
76
|
`review_flashcard`, `resolve_mistake`, `record_chapter_read`, `save_academic_plan`,
|
|
65
|
-
`set_course_visibility`, `join_programme`
|
|
77
|
+
`update_planning_objective`, `set_course_visibility`, `join_programme`
|
|
66
78
|
- **Documents and calendars** — `analyze_documents`, `apply_changes`, `preview_calendar`,
|
|
67
79
|
`save_calendar_link`, `sync_calendar_link`, `remove_calendar_link`
|
|
68
80
|
- **Canvas** — `canvas_connect`, `canvas_updates` (announcements, assignments with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicker-study-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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
|
@@ -25,6 +25,24 @@ import { getSavedCanvasAccessToken, promptForLocalCanvasImport, saveCanvasAccess
|
|
|
25
25
|
import { beginAuthorization } from './authorize.mjs'
|
|
26
26
|
import { configPath, forgetApiKey, listSavedServers, normaliseServerUrl, resolveApiKey, saveApiKey } from './config.mjs'
|
|
27
27
|
|
|
28
|
+
// A copy-ready installation block can provision the credential before an MCP
|
|
29
|
+
// client launches the stdio server. Persist it through the same hardened
|
|
30
|
+
// config writer as browser authorization (0700 directory, 0600 file), then
|
|
31
|
+
// discard the environment value with this short-lived process.
|
|
32
|
+
if (process.argv[2] === 'configure') {
|
|
33
|
+
try {
|
|
34
|
+
const serverUrl = normaliseServerUrl(process.env.WICKER_STUDY_URL || 'https://study.wicker.life')
|
|
35
|
+
const apiKey = String(process.env.WICKER_STUDY_API_KEY || '').trim()
|
|
36
|
+
if (!apiKey) throw new Error('WICKER_STUDY_API_KEY is required for configuration.')
|
|
37
|
+
const saved = await saveApiKey(serverUrl, apiKey, { name: 'Terminal installation', scopes: ['read', 'write'] })
|
|
38
|
+
process.stdout.write(`Wicker Study access saved securely for ${saved.server} at ${saved.path}.\n`)
|
|
39
|
+
process.exit(0)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
process.stderr.write(`${error instanceof Error ? error.message : 'Wicker Study could not be configured.'}\n`)
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
const baseUrl = normaliseServerUrl(process.env.WICKER_STUDY_URL || 'https://study.wicker.life')
|
|
29
47
|
const DEFAULT_CANVAS_URL = process.env.WICKER_CANVAS_URL || 'https://canvas.maastrichtuniversity.nl'
|
|
30
48
|
|
|
@@ -71,7 +89,7 @@ const json = (value) => ({ content: [{ type: 'text', text: typeof value === 'str
|
|
|
71
89
|
const failed = (error) => ({ isError: true, content: [{ type: 'text', text: error.message }] })
|
|
72
90
|
const run = (fn) => async (args) => { try { return json(await fn(args)) } catch (error) { return failed(error) } }
|
|
73
91
|
|
|
74
|
-
const server = new McpServer({ name: 'wicker-study', version: '2.
|
|
92
|
+
const server = new McpServer({ name: 'wicker-study', version: '2.7.0' })
|
|
75
93
|
const courseId = z.string().describe('Course id (e.g. "sec"). Use list_courses to discover ids.')
|
|
76
94
|
const chapterId = z.string().describe('Chapter id (e.g. "02").')
|
|
77
95
|
|
|
@@ -417,7 +435,7 @@ server.tool('wicker_sign_out',
|
|
|
417
435
|
stillConnected: Boolean(credential.apiKey),
|
|
418
436
|
note: credential.apiKey
|
|
419
437
|
? 'WICKER_STUDY_API_KEY is set in this process’s environment and still applies; unset it to disconnect fully.'
|
|
420
|
-
: `Revoke the key itself at ${baseUrl}/app
|
|
438
|
+
: `Revoke the key itself at ${baseUrl}/app/settings?tab=api if it should stop working everywhere.`
|
|
421
439
|
}
|
|
422
440
|
}))
|
|
423
441
|
|
|
@@ -426,7 +444,7 @@ server.tool('canvas_connect',
|
|
|
426
444
|
{ canvasUrl: z.string().optional().describe(`Canvas origin. Default ${DEFAULT_CANVAS_URL}.`) },
|
|
427
445
|
run(async ({ canvasUrl }) => {
|
|
428
446
|
const origin = new URL(canvasUrl || DEFAULT_CANVAS_URL).origin
|
|
429
|
-
const settings = `${baseUrl}/app
|
|
447
|
+
const settings = `${baseUrl}/app/settings?tab=connections`
|
|
430
448
|
const connections = (await api('/api/account/integrations/canvas')).connections || []
|
|
431
449
|
const match = connections.find((connection) => connection.origin === origin) || null
|
|
432
450
|
if (match) {
|
|
@@ -460,8 +478,23 @@ server.tool('get_chapter', 'Chapter markdown content. relPath opens a linked fil
|
|
|
460
478
|
run(({ courseId, chapterId, relPath }) => api(`/api/chapter/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}${relPath ? '/' + relPath.split('/').map(encodeURIComponent).join('/') : ''}`)))
|
|
461
479
|
server.tool('get_course_outline', 'Heading outline of every chapter in a course.', { courseId }, run(({ courseId }) => api(`/api/course-toc/${encodeURIComponent(courseId)}`)))
|
|
462
480
|
server.tool('list_materials', 'Files in a course knowledge base (markdown, PDFs, images, code).', { courseId }, run(({ courseId }) => api('/api/materials', { query: { courseId } })))
|
|
463
|
-
server.tool('search_course', '
|
|
464
|
-
|
|
481
|
+
server.tool('search_course', 'Hybrid full-text and embedding retrieval across published material and authorised Canvas snapshots. Results identify the exact academic-year edition and source path. Specify academicYear for a strict edition query; otherwise current and historical editions may be searched, with newer editions preferred.', {
|
|
482
|
+
courseId: courseId.optional(),
|
|
483
|
+
courseCode: z.string().optional().describe('Stable course code, for example BCS1540. Use this when querying Canvas editions.'),
|
|
484
|
+
canonicalCourseId: z.string().optional().describe('Stable corpus course identity returned by an earlier search.'),
|
|
485
|
+
academicYear: z.string().optional().describe('Exact edition such as 2025-2026.'),
|
|
486
|
+
sourceType: z.enum(['syllabus', 'requirements', 'slides', 'pages', 'assessments', 'activities', 'readings', 'materials']).optional(),
|
|
487
|
+
includeHistorical: z.boolean().optional().describe('Search older editions when no exact year is requested; defaults to true.'),
|
|
488
|
+
query: z.string(),
|
|
489
|
+
limit: z.number().int().min(1).max(20).optional()
|
|
490
|
+
}, run((args) => api('/api/retrieve', { method: 'POST', body: args })))
|
|
491
|
+
server.tool('canvas_corpus_status', 'Material collection consent, background sync jobs, versioned course editions, source counts, and last/next scrape times for the connected account.', {
|
|
492
|
+
canvasUrl: z.string().url().optional()
|
|
493
|
+
}, run(({ canvasUrl }) => api('/api/account/integrations/canvas/corpus', { query: { canvasUrl: canvasUrl || DEFAULT_CANVAS_URL } })))
|
|
494
|
+
server.tool('canvas_corpus_sync', 'Queue a server-side refresh of authorised Canvas material. Collection must first be enabled by the user in the signed-in Wicker Study browser; an MCP key cannot grant or expand consent.', {
|
|
495
|
+
canvasUrl: z.string().url().optional(),
|
|
496
|
+
force: z.boolean().optional()
|
|
497
|
+
}, run(({ canvasUrl, force }) => api('/api/integrations/canvas/corpus/sync', { method: 'POST', body: { canvasUrl: canvasUrl || DEFAULT_CANVAS_URL, force } })))
|
|
465
498
|
server.tool('list_questions', 'Published questions for a chapter plus the student’s personal extra exercises.', { courseId, chapterId },
|
|
466
499
|
run(({ courseId, chapterId }) => api(`/api/questions/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}`)))
|
|
467
500
|
server.tool('get_practice_queue', 'Every published question across active courses (optionally one course).', { courseId: courseId.optional(), limit: z.number().int().min(1).max(500).optional() },
|
|
@@ -474,6 +507,7 @@ server.tool('list_mistakes', 'Mistake bank.', { open: z.boolean().optional().des
|
|
|
474
507
|
server.tool('list_mock_sessions', 'Completed mock sessions.', {}, run(() => api('/api/mocks')))
|
|
475
508
|
server.tool('get_mock_session', 'One mock session with every answer and correction.', { sessionId: z.string() }, run(({ sessionId }) => api(`/api/mocks/${encodeURIComponent(sessionId)}`)))
|
|
476
509
|
server.tool('get_academic_plan', 'Active academic programme: courses, attempts, exam dates, events, gates, summary.', {}, run(() => api('/api/academics')))
|
|
510
|
+
server.tool('get_planning_context', 'Read the student’s saved exam scenario as a compact planning model. Recorded attempts and grades are explicitly separated from private choices such as a resit, following-year deferral, expected grade, or what-if outcome. Actual academic-calendar records are grouped into dated examination windows, so one window can contain a period’s primary exams and another period’s resits. Each course includes allowed session ids and course-specific roles derived from its teaching period, calendar, transcript fallback, and verified resit rules. Returns stable session ids and a revision; call this before suggesting or changing the plan.', {}, run(() => api('/api/planning/context')))
|
|
477
511
|
server.tool('list_known_programmes', 'The catalogue of known bachelor programmes.', {}, run(() => api('/api/editorial-programmes')))
|
|
478
512
|
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
513
|
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 } }))
|
|
@@ -503,6 +537,14 @@ server.tool('record_chapter_read', 'Record that the student read a chapter.', {
|
|
|
503
537
|
run(({ courseId, chapterId, label }) => api('/api/activity', { method: 'POST', body: { type: 'read', courseId, chapterId, label } })))
|
|
504
538
|
server.tool('save_academic_plan', 'Save the active academic programme workspace. Pass the revision you read to avoid overwriting concurrent edits.', { workspace: z.record(z.any()), expectedRevision: z.number().int() },
|
|
505
539
|
run(({ workspace, expectedRevision }) => api('/api/academics', { method: 'PUT', body: { workspace, expectedRevision } })))
|
|
540
|
+
server.tool('update_planning_objective', 'Update one course in the student’s private exam scenario without replacing the rest of the academic record. Call get_planning_context first, use a session id from that course’s planningRules.allowedSessionIds, inspect allowedDestinations for whether the shared window is a primary or resit route for that course, explain the exact change to the student, and pass the revision you read. Invalid sittings and stale revisions are rejected.', {
|
|
541
|
+
courseId: z.string(),
|
|
542
|
+
expectedRevision: z.number().int(),
|
|
543
|
+
mode: z.enum(['current', 'resit', 'none']).optional(),
|
|
544
|
+
targetSession: z.string().max(140).nullable().optional(),
|
|
545
|
+
expectedGrade: z.number().min(0).max(100).nullable().optional(),
|
|
546
|
+
outcome: z.enum(['actual', 'pass', 'fail']).optional()
|
|
547
|
+
}, run(({ courseId: id, expectedRevision, ...objective }) => api(`/api/planning/objectives/${encodeURIComponent(id)}`, { method: 'PATCH', body: { objective, expectedRevision } })))
|
|
506
548
|
server.tool('set_course_visibility', 'Archive/unarchive or reorder a course for the student.', { courseId, archived: z.boolean().optional(), order: z.number().int().optional() },
|
|
507
549
|
run(({ courseId, archived, order }) => api(`/api/courses/${encodeURIComponent(courseId)}`, { method: 'PATCH', body: { archived, order } })))
|
|
508
550
|
|
|
@@ -715,10 +715,11 @@ export async function importCanvasCourse({ courseUrl, accessToken, outputFolder,
|
|
|
715
715
|
}
|
|
716
716
|
}
|
|
717
717
|
|
|
718
|
-
const [courseAssignments, courseQuizzes, courseDiscussions] = await Promise.all([
|
|
718
|
+
const [courseAssignments, courseQuizzes, courseDiscussions, coursePages] = await Promise.all([
|
|
719
719
|
optionalCourseCollection('Course-wide assignments listing', `/api/v1/courses/${encodeURIComponent(canvas.courseId)}/assignments?per_page=100`),
|
|
720
720
|
optionalCourseCollection('Course-wide quizzes listing', `/api/v1/courses/${encodeURIComponent(canvas.courseId)}/quizzes?per_page=100`),
|
|
721
|
-
optionalCourseCollection('Course-wide discussions listing', `/api/v1/courses/${encodeURIComponent(canvas.courseId)}/discussion_topics?per_page=100`)
|
|
721
|
+
optionalCourseCollection('Course-wide discussions listing', `/api/v1/courses/${encodeURIComponent(canvas.courseId)}/discussion_topics?per_page=100`),
|
|
722
|
+
requestedModuleIds ? Promise.resolve([]) : optionalCourseCollection('Course-wide Pages listing', `/api/v1/courses/${encodeURIComponent(canvas.courseId)}/pages?per_page=100`)
|
|
722
723
|
])
|
|
723
724
|
for (const [index, assignment] of courseAssignments.entries()) {
|
|
724
725
|
const id = String(assignment.id || '')
|
|
@@ -736,6 +737,21 @@ export async function importCanvasCourse({ courseUrl, accessToken, outputFolder,
|
|
|
736
737
|
await importDiscussion({ discussionId: id, base: join(root, 'course-communications'), position: index + 1, source: { moduleId: null, moduleName: null, itemId: id, itemType: 'Course discussion', title: text(discussion.title, 300) }, initial: discussion })
|
|
737
738
|
}
|
|
738
739
|
|
|
740
|
+
// Lecturers often publish wiki pages through the Pages navigation without
|
|
741
|
+
// adding them to a module. Enumerate that collection during a full archive;
|
|
742
|
+
// importPage deduplicates anything already reached through modules or links.
|
|
743
|
+
for (const [index, page] of coursePages.entries()) {
|
|
744
|
+
const pageSlug = text(page.url || page.page_url, 300)
|
|
745
|
+
if (!pageSlug || importedPageSlugs.has(pageSlug)) continue
|
|
746
|
+
await importPage({
|
|
747
|
+
slug: pageSlug,
|
|
748
|
+
base: join(root, 'course-pages'),
|
|
749
|
+
position: index + 1,
|
|
750
|
+
title: page.title || pageSlug,
|
|
751
|
+
source: { moduleId: null, moduleName: null, itemId: pageSlug, itemType: 'Course page', title: text(page.title, 300) }
|
|
752
|
+
})
|
|
753
|
+
}
|
|
754
|
+
|
|
739
755
|
for (const [index, file] of courseFiles.entries()) {
|
|
740
756
|
if (downloadedFileIds.has(String(file.id))) continue
|
|
741
757
|
await importFile(file.id, join(root, 'unassigned-files'), index + 1, { moduleId: null, moduleName: null, itemId: String(file.id), itemType: 'File', title: file.display_name || file.filename })
|
|
@@ -757,7 +773,7 @@ export async function importCanvasCourse({ courseUrl, accessToken, outputFolder,
|
|
|
757
773
|
limits: { maxResources, maxFileBytes }
|
|
758
774
|
}
|
|
759
775
|
await writeFile(manifestPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8')
|
|
760
|
-
await writeFile(join(root, 'README.md'), `# ${courseName}\n\nImported privately from Canvas on ${summary.importedAt.slice(0, 10)}.\n\n- Canvas course: ${canvas.courseUrl}\n- Modules included: ${selectedModules.length}${requestedModuleIds ? ' (chosen subset)' : ''}\n- Resources written: ${records.length}\n- Resources skipped: ${skipped.length}\n- Previous imported paths no longer found: ${staleLocalResources.length}\n\nThe snapshot includes the Canvas rich-text syllabus when the account can read it, plus separately uploaded course files (including syllabus/course-manual files), module material, accessible course-wide assignments, quizzes, discussions, and question banks where Canvas permits question access. Canvas pages are followed recursively when they link to another page in this same course. File links in rich-text records are downloaded when accessible; every HTTP(S) reference is compiled into a nearby \`link-index\` file and the hidden manifest. External sites are recorded, never crawled.\n\nThis folder is a source snapshot. Keep it local until the administrator confirms they are authorised to submit the materials for editorial review. The hidden \`.wicker-canvas-import.json\` file records exactly what was found. Re-run the importer into this same folder to refresh changed or newly published Canvas material. Paths no longer returned by Canvas are listed in that manifest for review; they are never deleted automatically.\n`, 'utf8')
|
|
776
|
+
await writeFile(join(root, 'README.md'), `# ${courseName}\n\nImported privately from Canvas on ${summary.importedAt.slice(0, 10)}.\n\n- Canvas course: ${canvas.courseUrl}\n- Modules included: ${selectedModules.length}${requestedModuleIds ? ' (chosen subset)' : ''}\n- Resources written: ${records.length}\n- Resources skipped: ${skipped.length}\n- Previous imported paths no longer found: ${staleLocalResources.length}\n\nThe snapshot includes the Canvas rich-text syllabus when the account can read it, plus separately uploaded course files (including syllabus/course-manual files), module material, standalone course Pages, accessible course-wide assignments, quizzes, discussions, and question banks where Canvas permits question access. Canvas pages are followed recursively when they link to another page in this same course. File links in rich-text records are downloaded when accessible; every HTTP(S) reference is compiled into a nearby \`link-index\` file and the hidden manifest. External sites are recorded, never crawled.\n\nThis folder is a source snapshot. Keep it local until the administrator confirms they are authorised to submit the materials for editorial review. The hidden \`.wicker-canvas-import.json\` file records exactly what was found. Re-run the importer into this same folder to refresh changed or newly published Canvas material. Paths no longer returned by Canvas are listed in that manifest for review; they are never deleted automatically.\n`, 'utf8')
|
|
761
777
|
|
|
762
778
|
return {
|
|
763
779
|
root,
|