wicker-study-mcp 2.5.0 → 2.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicker-study-mcp",
3
- "version": "2.5.0",
3
+ "version": "2.6.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.5.0' })
74
+ const server = new McpServer({ name: 'wicker-study', version: '2.6.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
 
@@ -417,7 +417,7 @@ server.tool('wicker_sign_out',
417
417
  stillConnected: Boolean(credential.apiKey),
418
418
  note: credential.apiKey
419
419
  ? '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.`
420
+ : `Revoke the key itself at ${baseUrl}/app/account?tab=api if it should stop working everywhere.`
421
421
  }
422
422
  }))
423
423
 
@@ -426,7 +426,7 @@ server.tool('canvas_connect',
426
426
  { canvasUrl: z.string().optional().describe(`Canvas origin. Default ${DEFAULT_CANVAS_URL}.`) },
427
427
  run(async ({ canvasUrl }) => {
428
428
  const origin = new URL(canvasUrl || DEFAULT_CANVAS_URL).origin
429
- const settings = `${baseUrl}/app#/account/connections`
429
+ const settings = `${baseUrl}/app/account?tab=connections`
430
430
  const connections = (await api('/api/account/integrations/canvas')).connections || []
431
431
  const match = connections.find((connection) => connection.origin === origin) || null
432
432
  if (match) {
@@ -460,8 +460,23 @@ server.tool('get_chapter', 'Chapter markdown content. relPath opens a linked fil
460
460
  run(({ courseId, chapterId, relPath }) => api(`/api/chapter/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}${relPath ? '/' + relPath.split('/').map(encodeURIComponent).join('/') : ''}`)))
461
461
  server.tool('get_course_outline', 'Heading outline of every chapter in a course.', { courseId }, run(({ courseId }) => api(`/api/course-toc/${encodeURIComponent(courseId)}`)))
462
462
  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 } })))
463
+ 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.', {
464
+ courseId: courseId.optional(),
465
+ courseCode: z.string().optional().describe('Stable course code, for example BCS1540. Use this when querying Canvas editions.'),
466
+ canonicalCourseId: z.string().optional().describe('Stable corpus course identity returned by an earlier search.'),
467
+ academicYear: z.string().optional().describe('Exact edition such as 2025-2026.'),
468
+ sourceType: z.enum(['syllabus', 'requirements', 'slides', 'pages', 'assessments', 'activities', 'readings', 'materials']).optional(),
469
+ includeHistorical: z.boolean().optional().describe('Search older editions when no exact year is requested; defaults to true.'),
470
+ query: z.string(),
471
+ limit: z.number().int().min(1).max(20).optional()
472
+ }, run((args) => api('/api/retrieve', { method: 'POST', body: args })))
473
+ 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.', {
474
+ canvasUrl: z.string().url().optional()
475
+ }, run(({ canvasUrl }) => api('/api/account/integrations/canvas/corpus', { query: { canvasUrl: canvasUrl || DEFAULT_CANVAS_URL } })))
476
+ 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.', {
477
+ canvasUrl: z.string().url().optional(),
478
+ force: z.boolean().optional()
479
+ }, run(({ canvasUrl, force }) => api('/api/integrations/canvas/corpus/sync', { method: 'POST', body: { canvasUrl: canvasUrl || DEFAULT_CANVAS_URL, force } })))
465
480
  server.tool('list_questions', 'Published questions for a chapter plus the student’s personal extra exercises.', { courseId, chapterId },
466
481
  run(({ courseId, chapterId }) => api(`/api/questions/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}`)))
467
482
  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() },
@@ -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,