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 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`, `get_account_summary`, `whoami`
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.5.0",
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.5.0' })
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#/account/api if it should stop working everywhere.`
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#/account/connections`
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', 'Full-text retrieval over course material (hosted deployments).', { courseId, query: z.string(), limit: z.number().int().min(1).max(20).optional() },
464
- run(({ courseId, query, limit }) => api('/api/retrieve', { method: 'POST', body: { courseId, query, limit } })))
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,