wicker-study-mcp 2.0.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/server.mjs ADDED
@@ -0,0 +1,621 @@
1
+ #!/usr/bin/env node
2
+ // Wicker Study MCP server — a thin stdio wrapper over the HTTP API so agents
3
+ // (Claude Desktop, Claude Code, Codex, Cursor, …) can read course material and
4
+ // a student's record, record study activity, collect a private Canvas course
5
+ // snapshot, and — with an admin key — maintain editorial content.
6
+ //
7
+ // npx wicker-study-mcp uses the saved key, or asks for one
8
+ // WICKER_STUDY_URL=http://localhost:4177 npx wicker-study-mcp
9
+ // WICKER_STUDY_API_KEY=wsk_… npx wicker-study-mcp
10
+ //
11
+ // It runs from anywhere: nothing here needs the application checkout. When no
12
+ // key is available the server still starts, so the agent can call
13
+ // `wicker_authorize` and walk the user through a browser approval rather than
14
+ // failing at launch with an environment variable the user has never heard of.
15
+
16
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
17
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
18
+ import { z } from 'zod'
19
+ import { createHash } from 'node:crypto'
20
+ import { lstat, mkdir, readFile, readdir, realpath } from 'node:fs/promises'
21
+ import { extname, join, relative, resolve, sep } from 'node:path'
22
+ import { CANVAS_IMPORT_LIMITS, canvasCourseFolderName, filterCanvasCourses, importCanvasCourse, listCanvasCourseModules, listCanvasCourses, parseCanvasCourseUrl } from './vendor/canvas-course-import.mjs'
23
+ import { exportCanvasCourseZip } from './vendor/canvas-course-export.mjs'
24
+ import { getSavedCanvasAccessToken, promptForLocalCanvasImport, saveCanvasAccessTokenFromClipboard } from './vendor/local-canvas-prompts.mjs'
25
+ import { beginAuthorization } from './authorize.mjs'
26
+ import { configPath, forgetApiKey, listSavedServers, normaliseServerUrl, resolveApiKey, saveApiKey } from './config.mjs'
27
+
28
+ const baseUrl = normaliseServerUrl(process.env.WICKER_STUDY_URL || 'https://study.wicker.life')
29
+ const DEFAULT_CANVAS_URL = process.env.WICKER_CANVAS_URL || 'https://canvas.maastrichtuniversity.nl'
30
+
31
+ // Resolved once at startup and again after an authorization, so a key granted
32
+ // mid-session takes effect without restarting the agent.
33
+ let credential = await resolveApiKey(baseUrl)
34
+
35
+ const NEEDS_AUTHORIZATION = `Not connected to ${baseUrl}. Call wicker_authorize to get a key: it opens a Wicker Study page the user approves in their browser, and the key is delivered straight to this machine — never through the conversation. Set WICKER_STUDY_URL first if this is the wrong server.`
36
+
37
+ function requireKey() {
38
+ if (!credential.apiKey) throw new Error(NEEDS_AUTHORIZATION)
39
+ return credential.apiKey
40
+ }
41
+
42
+ async function apiResponse(path, { method = 'GET', body, query } = {}) {
43
+ const url = new URL(baseUrl + path)
44
+ for (const [key, value] of Object.entries(query || {})) if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value))
45
+ const response = await fetch(url, {
46
+ method,
47
+ headers: { authorization: `Bearer ${requireKey()}`, accept: 'application/json', ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
48
+ body: body !== undefined ? JSON.stringify(body) : undefined
49
+ })
50
+ if (!response.ok) {
51
+ const text = await response.text()
52
+ let data
53
+ try { data = text ? JSON.parse(text) : null } catch { data = { raw: text } }
54
+ // A revoked or expired key is the same dead end as no key at all, so say
55
+ // the same thing rather than leaving the agent to interpret a 401.
56
+ if (response.status === 401) throw new Error(`${baseUrl} rejected this API key. It may have been revoked or expired. Call wicker_authorize to replace it.`)
57
+ throw new Error(`${method} ${path} → ${response.status}: ${data?.error || text.slice(0, 300)}`)
58
+ }
59
+ return response
60
+ }
61
+
62
+ async function api(path, options = {}) {
63
+ const response = await apiResponse(path, options)
64
+ const text = await response.text()
65
+ let data
66
+ try { data = text ? JSON.parse(text) : null } catch { data = { raw: text } }
67
+ return data
68
+ }
69
+
70
+ const json = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] })
71
+ const failed = (error) => ({ isError: true, content: [{ type: 'text', text: error.message }] })
72
+ const run = (fn) => async (args) => { try { return json(await fn(args)) } catch (error) { return failed(error) } }
73
+
74
+ const server = new McpServer({ name: 'wicker-study', version: '2.0.0' })
75
+ const courseId = z.string().describe('Course id (e.g. "sec"). Use list_courses to discover ids.')
76
+ const chapterId = z.string().describe('Chapter id (e.g. "02").')
77
+
78
+ const COURSE_SOURCE_EXTENSIONS = new Set(['.pdf', '.ppt', '.pptx', '.doc', '.docx', '.txt', '.md', '.csv', '.tex', '.m', '.py', '.r', '.html', '.htm', '.png', '.jpg', '.jpeg', '.webp'])
79
+ const SOURCE_MIME = { '.pdf': 'application/pdf', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.tex': 'text/x-tex', '.m': 'text/x-matlab', '.py': 'text/x-python', '.r': 'text/x-r', '.html': 'text/html', '.htm': 'text/html', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }
80
+ const EDITORIAL_CHUNK_BYTES = 512 * 1024
81
+ const MAX_EDITORIAL_FILE_BYTES = 100 * 1024 * 1024
82
+
83
+ async function inventoryCourseFolder(folderPath) {
84
+ const root = await realpath(resolve(folderPath))
85
+ const rootStat = await lstat(root)
86
+ if (!rootStat.isDirectory()) throw new Error('folderPath must point to a directory.')
87
+ const files = []
88
+ const ignored = []
89
+ async function visit(directory) {
90
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
91
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
92
+ const path = resolve(directory, entry.name)
93
+ if (!path.startsWith(`${root}${sep}`)) throw new Error('A folder entry resolved outside the selected course directory.')
94
+ if (entry.isSymbolicLink()) { ignored.push({ path: relative(root, path), reason: 'symbolic link' }); continue }
95
+ if (entry.isDirectory()) { await visit(path); continue }
96
+ if (!entry.isFile()) continue
97
+ const extension = extname(entry.name).toLowerCase()
98
+ const sourcePath = relative(root, path).split(sep).join('/')
99
+ if (!COURSE_SOURCE_EXTENSIONS.has(extension)) { ignored.push({ path: sourcePath, reason: 'unsupported type' }); continue }
100
+ const details = await lstat(path)
101
+ if (!details.size || details.size > MAX_EDITORIAL_FILE_BYTES) { ignored.push({ path: sourcePath, reason: details.size ? 'over 100 MB' : 'empty file' }); continue }
102
+ const bytes = await readFile(path)
103
+ files.push({ path, relativePath: sourcePath, name: entry.name, type: SOURCE_MIME[extension] || 'application/octet-stream', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') })
104
+ if (files.length > 250) throw new Error('A course-folder sync is limited to 250 supported files.')
105
+ }
106
+ }
107
+ await visit(root)
108
+ files.sort((left, right) => left.relativePath.localeCompare(right.relativePath, undefined, { numeric: true }))
109
+ return { root, files, ignored }
110
+ }
111
+
112
+ function publicInventory(inventory) {
113
+ return { root: inventory.root, files: inventory.files.map(({ path: _path, ...file }) => file), ignored: inventory.ignored, totals: { files: inventory.files.length, bytes: inventory.files.reduce((sum, file) => sum + file.size, 0) } }
114
+ }
115
+
116
+ async function syncCourseFolder(args) {
117
+ const inventory = await inventoryCourseFolder(args.folderPath)
118
+ let edition = null
119
+ let editionWorkspace = null
120
+ if (args.editionId) {
121
+ editionWorkspace = await api(`/api/admin/editorial-editions/${encodeURIComponent(args.editionId)}`)
122
+ edition = editionWorkspace.editions?.[0]
123
+ if (!edition) throw new Error(`Unknown course edition: ${args.editionId}`)
124
+ }
125
+ if (!edition && (!args.courseName || !args.courseCode)) throw new Error('courseCode and courseName are required when creating a new edition.')
126
+ const workspace = await api('/api/admin/editorial-workspace')
127
+ const matching = edition || workspace.editions?.find((candidate) => candidate.courseCode === String(args.courseCode || '').toUpperCase() && candidate.academicYear === String(args.academicYear || '') && candidate.period === String(args.period || '')) || null
128
+ if (matching && !editionWorkspace) editionWorkspace = await api(`/api/admin/editorial-editions/${encodeURIComponent(matching.id)}`)
129
+ const currentSources = matching ? (editionWorkspace?.sources || []).filter((source) => source.contribution.editionId === matching.id && ['accepted', 'candidate'].includes(source.contribution.consentStatus)) : []
130
+ const currentByPath = new Map(currentSources.map((source) => [source.contribution.sourcePath, source]))
131
+ const localPaths = new Set(inventory.files.map((file) => file.relativePath))
132
+ const plan = {
133
+ edition: matching,
134
+ add: inventory.files.filter((file) => !currentByPath.has(file.relativePath)).map((file) => file.relativePath),
135
+ replace: inventory.files.filter((file) => currentByPath.has(file.relativePath) && currentByPath.get(file.relativePath).sha256 !== file.sha256).map((file) => file.relativePath),
136
+ reuse: inventory.files.filter((file) => currentByPath.get(file.relativePath)?.sha256 === file.sha256).map((file) => file.relativePath),
137
+ retire: currentSources.filter((source) => source.contribution.sourcePath && !localPaths.has(source.contribution.sourcePath)).map((source) => source.contribution.sourcePath),
138
+ inventory: publicInventory(inventory)
139
+ }
140
+ if (args.dryRun !== false) return { dryRun: true, ...plan, consentStatus: args.consentStatus || 'accepted', rightsBasis: args.rightsBasis || 'admin-supplied', next: 'Run again with dryRun=false after reviewing add/replace/retire. Set replaceManifest=true only if this folder is the authoritative complete source set.' }
141
+ if (!edition) {
142
+ edition = await api('/api/admin/editorial-editions', { method: 'POST', body: { programmeId: args.programmeId, canonicalCourseId: args.canonicalCourseId, institution: args.institution, courseCode: args.courseCode, courseName: args.courseName, academicYear: args.academicYear, period: args.period } })
143
+ }
144
+ const registered = await api(`/api/admin/editorial-editions/${encodeURIComponent(edition.id)}/sources`, {
145
+ method: 'POST',
146
+ body: {
147
+ rightsBasis: args.rightsBasis || 'admin-supplied',
148
+ consentStatus: args.consentStatus || 'accepted',
149
+ replaceManifest: args.replaceManifest === true,
150
+ sources: inventory.files.map(({ path: _path, ...file }) => file)
151
+ }
152
+ })
153
+ let uploaded = 0
154
+ let reused = 0
155
+ for (const source of registered.sources || []) {
156
+ const file = inventory.files.find((candidate) => candidate.sha256 === source.sha256)
157
+ if (!file) continue
158
+ if (!source.uploadRequired) { reused++; continue }
159
+ const bytes = await readFile(file.path)
160
+ for (let offset = 0, chunkIndex = 0; offset < bytes.length; offset += EDITORIAL_CHUNK_BYTES, chunkIndex++) {
161
+ const chunk = bytes.subarray(offset, Math.min(offset + EDITORIAL_CHUNK_BYTES, bytes.length))
162
+ await api(`/api/admin/editorial-editions/${encodeURIComponent(edition.id)}/sources/${encodeURIComponent(source.id)}/chunks`, { method: 'POST', body: { chunkIndex, base64: chunk.toString('base64') } })
163
+ }
164
+ uploaded++
165
+ }
166
+ return { dryRun: false, edition, uploaded, reused, replaceManifest: args.replaceManifest === true, consentStatus: args.consentStatus || 'accepted', rightsBasis: args.rightsBasis || 'admin-supplied', plan }
167
+ }
168
+
169
+ function localEnvironmentName(value) {
170
+ const name = String(value || '').trim()
171
+ if (!name) return null
172
+ if (!/^[A-Z][A-Z0-9_]{0,127}$/.test(name)) throw new Error('accessTokenEnv must name a local environment variable, for example CANVAS_ACCESS_TOKEN.')
173
+ return name
174
+ }
175
+
176
+ async function importCanvasCourseAndMaybeSync(args) {
177
+ if (args.syncToWicker === true && args.rightsConfirmed !== true) throw new Error('Set rightsConfirmed=true only after confirming that you are authorised to submit these Canvas materials for editorial review.')
178
+ const accessTokenEnv = localEnvironmentName(args.accessTokenEnv)
179
+ const input = await promptForLocalCanvasImport({ courseUrl: args.courseUrl, outputFolder: args.outputFolder, accessToken: accessTokenEnv ? process.env[accessTokenEnv] : undefined })
180
+ const imported = await importCanvasCourse({
181
+ courseUrl: input.courseUrl,
182
+ accessToken: input.accessToken,
183
+ outputFolder: input.outputFolder,
184
+ moduleIds: args.moduleIds,
185
+ maxResources: args.maxResources,
186
+ maxFileBytes: args.maxFileBytes
187
+ })
188
+ if (args.syncToWicker !== true) return {
189
+ imported,
190
+ next: 'Review the local source snapshot first. To submit it to the private editorial workspace, rerun with syncToWicker=true, rightsConfirmed=true, and dryRun=false. Sources will still arrive as review candidates; accepting them, extraction, generation, and publication remain separate decisions.'
191
+ }
192
+ const sync = await syncCourseFolder({
193
+ ...args,
194
+ folderPath: imported.root,
195
+ courseCode: args.courseCode || imported.course.code || undefined,
196
+ courseName: args.courseName || imported.course.name,
197
+ rightsBasis: 'authorised-course-material',
198
+ consentStatus: 'candidate',
199
+ replaceManifest: false
200
+ })
201
+ return {
202
+ imported,
203
+ sync,
204
+ next: sync.dryRun ? 'Inspect the proposed folder sync, then explicitly rerun with dryRun=false. Imported Canvas sources remain candidates for rights review.' : 'Open Course production, review and accept the candidate sources you are authorised to use, then extract and map the course. Nothing has been published.'
205
+ }
206
+ }
207
+
208
+ async function localCanvasAccessToken(canvasUrl, accessTokenEnv) {
209
+ const environmentName = localEnvironmentName(accessTokenEnv)
210
+ if (environmentName) {
211
+ const token = String(process.env[environmentName] || '').trim()
212
+ if (!token) throw new Error(`${environmentName} is not set in this local MCP process.`)
213
+ return token
214
+ }
215
+ return getSavedCanvasAccessToken(canvasUrl)
216
+ }
217
+
218
+ async function listLocalCanvasCourses({ canvasUrl, accessTokenEnv, query }) {
219
+ const result = await listCanvasCourses({ canvasUrl, accessToken: await localCanvasAccessToken(canvasUrl, accessTokenEnv) })
220
+ const courses = filterCanvasCourses(result.courses, query)
221
+ return { ...result, total: result.courses.length, query: query || null, matched: courses.length, courses }
222
+ }
223
+
224
+ async function importLocalCanvasCourseSet(args) {
225
+ const catalog = await listLocalCanvasCourses(args)
226
+ if (!catalog.courses.length) throw new Error(`No Canvas courses matched ${JSON.stringify(args.query || '')}. Use admin_list_canvas_courses first to inspect the available names, terms, and course codes.`)
227
+ const maximum = Math.min(args.maxCourses, catalog.courses.length)
228
+ const selected = catalog.courses.slice(0, maximum)
229
+ const root = resolve(args.outputFolder)
230
+ await mkdir(root, { recursive: true })
231
+ const accessToken = await localCanvasAccessToken(args.canvasUrl, args.accessTokenEnv)
232
+ const imports = []
233
+ for (const course of selected) {
234
+ const outputFolder = join(root, canvasCourseFolderName(course))
235
+ const imported = await importCanvasCourse({ courseUrl: course.courseUrl, accessToken, outputFolder, maxResources: args.maxResources, maxFileBytes: args.maxFileBytes })
236
+ imports.push({ course, imported })
237
+ }
238
+ return { root, query: args.query || null, matched: catalog.matched, imported: imports.length, omittedByMaxCourses: catalog.matched - imports.length, imports }
239
+ }
240
+
241
+ async function exportLocalCanvasCourseZip(args) {
242
+ const accessToken = await localCanvasAccessToken(args.courseUrl, args.accessTokenEnv)
243
+ return exportCanvasCourseZip({ courseUrl: args.courseUrl, accessToken, moduleIds: args.moduleIds, outputPath: args.zipPath, maxResources: args.maxResources, maxFileBytes: args.maxFileBytes })
244
+ }
245
+
246
+ // A remote Canvas connection is deliberately proxied through the Wicker API.
247
+ // The local MCP receives course bytes, not the user’s Canvas PAT, so Codex or
248
+ // Claude can analyse the snapshot in its own workspace without seeing or
249
+ // retaining that third-party credential.
250
+ function remoteCanvasFetch(courseUrl) {
251
+ const canvas = parseCanvasCourseUrl(courseUrl)
252
+ const platformOrigin = new URL(baseUrl).origin
253
+ return async (input) => {
254
+ const target = new URL(String(input))
255
+ if (target.origin === canvas.origin && target.pathname.startsWith('/api/v1/')) {
256
+ const response = await apiResponse('/api/integrations/canvas/proxy', {
257
+ query: { canvasUrl: canvas.origin, path: `${target.pathname}${target.search}` }
258
+ })
259
+ // The server intentionally replaces Canvas file URLs with a relative,
260
+ // authenticated Wicker proxy path. The local importer needs an absolute
261
+ // URL to stream those bytes, but still never receives the Canvas PAT.
262
+ if (!/^\/api\/v1\/courses\/\d+\/files(?:\/\d+)?$/.test(target.pathname)) return response
263
+ const payload = await response.json()
264
+ const absoluteFileUrl = (file) => file && typeof file === 'object' && typeof file.url === 'string' && file.url.startsWith('/api/integrations/canvas/')
265
+ ? { ...file, url: new URL(file.url, platformOrigin).toString() }
266
+ : file
267
+ const rewritten = Array.isArray(payload) ? payload.map(absoluteFileUrl) : absoluteFileUrl(payload)
268
+ return new Response(JSON.stringify(rewritten), {
269
+ status: 200,
270
+ headers: {
271
+ 'content-type': 'application/json; charset=utf-8',
272
+ ...(response.headers.get('link') ? { link: response.headers.get('link') } : {})
273
+ }
274
+ })
275
+ }
276
+ if (target.origin === platformOrigin && /^\/api\/integrations\/canvas\/courses\/\d+\/files\/\d+\/download$/.test(target.pathname)) {
277
+ return apiResponse(`${target.pathname}${target.search}`)
278
+ }
279
+ throw new Error('The remote Canvas importer refused an unexpected download URL.')
280
+ }
281
+ }
282
+
283
+ async function listRemoteCanvasCourses({ canvasUrl, query }) {
284
+ const catalog = await api('/api/integrations/canvas/courses', { query: { canvasUrl } })
285
+ const courses = filterCanvasCourses(catalog.courses || [], query)
286
+ return { ...catalog, total: (catalog.courses || []).length, query: query || null, matched: courses.length, courses }
287
+ }
288
+
289
+ async function listRemoteCanvasCourseModules({ courseUrl }) {
290
+ const canvas = parseCanvasCourseUrl(courseUrl)
291
+ return api(`/api/integrations/canvas/courses/${encodeURIComponent(canvas.courseId)}/modules`, { query: { canvasUrl: canvas.origin } })
292
+ }
293
+
294
+ async function importRemoteCanvasCourse(args) {
295
+ const imported = await importCanvasCourse({
296
+ courseUrl: args.courseUrl,
297
+ // This satisfies the local importer’s no-empty-token guard. The fetch
298
+ // adapter above discards it; only Wicker’s server holds the real PAT.
299
+ accessToken: 'stored-remotely-by-wicker',
300
+ outputFolder: args.outputFolder,
301
+ moduleIds: args.moduleIds,
302
+ maxResources: args.maxResources,
303
+ maxFileBytes: args.maxFileBytes,
304
+ fetchImpl: remoteCanvasFetch(args.courseUrl)
305
+ })
306
+ return {
307
+ imported,
308
+ next: 'Analyse this private local snapshot with your Claude/Codex subscription. If you are authorised to propose shared material, use the separate rights-reviewed editorial sync; importing from Canvas never publishes anything automatically.'
309
+ }
310
+ }
311
+
312
+ async function importRemoteCanvasCourseSet(args) {
313
+ const catalog = await listRemoteCanvasCourses(args)
314
+ if (!catalog.courses.length) throw new Error(`No remote Canvas courses matched ${JSON.stringify(args.query || '')}. Use canvas_list_remote_courses first to inspect the available titles and terms.`)
315
+ const root = resolve(args.outputFolder)
316
+ await mkdir(root, { recursive: true })
317
+ const selected = catalog.courses.slice(0, Math.min(args.maxCourses, catalog.courses.length))
318
+ const imports = []
319
+ for (const course of selected) {
320
+ imports.push({ course, ...(await importRemoteCanvasCourse({
321
+ courseUrl: course.courseUrl,
322
+ outputFolder: join(root, canvasCourseFolderName(course)),
323
+ maxResources: args.maxResources,
324
+ maxFileBytes: args.maxFileBytes
325
+ })) })
326
+ }
327
+ return { root, query: args.query || null, matched: catalog.matched, imported: imports.length, omittedByMaxCourses: catalog.matched - imports.length, imports }
328
+ }
329
+
330
+ // ── Read ─────────────────────────────────────────────────────────────────
331
+ // ── Connecting ────────────────────────────────────────────────────────────
332
+ // These four are the only tools that work without a key, because they are how
333
+ // a key is obtained. Everything else answers with NEEDS_AUTHORIZATION until
334
+ // one exists.
335
+
336
+ let pendingAuthorization = null
337
+
338
+ server.tool('wicker_status',
339
+ 'Whether this agent is connected to Wicker Study, which account it acts as, and whether that account has Canvas connected. Call this first in a new session — it is the cheapest way to find out what still needs setting up. Never returns an API key or a Canvas token.',
340
+ {},
341
+ run(async () => {
342
+ const status = {
343
+ server: baseUrl,
344
+ connected: Boolean(credential.apiKey),
345
+ keySource: credential.source,
346
+ configFile: configPath(),
347
+ otherSavedServers: (await listSavedServers()).map((entry) => entry.server).filter((server) => server !== baseUrl)
348
+ }
349
+ if (pendingAuthorization) status.pendingAuthorization = { url: pendingAuthorization.url, startedAt: pendingAuthorization.startedAt, note: 'Waiting for the user to approve in their browser.' }
350
+ if (!status.connected) return { ...status, next: NEEDS_AUTHORIZATION }
351
+ try {
352
+ const me = await api('/api/me')
353
+ status.account = { userId: me.userId, email: me.email ?? null, scopes: me.scopes, admin: Boolean(me.admin) }
354
+ } catch (error) {
355
+ return { ...status, connected: false, problem: error.message }
356
+ }
357
+ try {
358
+ const canvas = await api('/api/account/integrations/canvas')
359
+ const connections = canvas.connections || []
360
+ status.canvas = connections.length
361
+ ? { connected: true, origins: connections.map((connection) => connection.origin) }
362
+ : { connected: false, next: 'Call canvas_connect for the page the student uses to add their Canvas Personal Access Token. Canvas material is unavailable until then.' }
363
+ } catch (error) {
364
+ status.canvas = { connected: false, problem: error.message }
365
+ }
366
+ return status
367
+ }))
368
+
369
+ server.tool('wicker_authorize',
370
+ 'Get an API key for this machine. Returns a Wicker Study URL: show it to the user and ask them to open it and approve. The key is delivered straight back to this computer over loopback and saved globally, so it never appears in the conversation and every later session on this machine reuses it. Poll wicker_status to see when it has landed. Requires a browser on this machine.',
371
+ {
372
+ scopes: z.array(z.enum(['read', 'write', 'admin'])).optional().describe('Default ["read","write"]. Ask for "admin" only when the user maintains course content; only administrators can approve it.'),
373
+ name: z.string().max(80).optional().describe('How this agent should appear in the approval screen and the key list, e.g. "Claude Code on David’s MacBook".')
374
+ },
375
+ run(async ({ scopes, name }) => {
376
+ if (credential.apiKey) {
377
+ return {
378
+ alreadyConnected: true,
379
+ server: baseUrl,
380
+ keySource: credential.source,
381
+ note: 'A key is already available. Call wicker_sign_out first if you need to replace it.'
382
+ }
383
+ }
384
+ if (pendingAuthorization) return { ...pendingAuthorization, note: 'An authorization is already waiting. Show this URL again, or call wicker_sign_out to abandon it.' }
385
+
386
+ const flow = beginAuthorization(baseUrl, { name: name || 'Agent (MCP)', scopes: scopes?.length ? scopes : ['read', 'write'] })
387
+ const { url } = await flow.ready
388
+ pendingAuthorization = { url, startedAt: new Date().toISOString() }
389
+ flow.completed
390
+ .then(async () => { credential = await resolveApiKey(baseUrl) })
391
+ .catch(() => {})
392
+ .finally(() => { pendingAuthorization = null })
393
+
394
+ return {
395
+ url,
396
+ server: baseUrl,
397
+ expiresInMinutes: 5,
398
+ instructions: [
399
+ `Ask the user to open ${url} and approve.`,
400
+ 'They must be signed in to Wicker Study; the page will offer sign-in if not.',
401
+ 'Then call wicker_status. Once it reports connected, every other tool works.',
402
+ 'Do not ask the user to paste a key into the chat — this flow exists so that is never necessary.'
403
+ ]
404
+ }
405
+ }))
406
+
407
+ server.tool('wicker_sign_out',
408
+ 'Forget the API key saved on this machine for this server, and abandon any authorization waiting for approval. The key itself stays valid until revoked under Account → API access in the web app.',
409
+ {},
410
+ run(async () => {
411
+ pendingAuthorization = null
412
+ const removed = await forgetApiKey(baseUrl)
413
+ credential = await resolveApiKey(baseUrl)
414
+ return {
415
+ server: baseUrl,
416
+ removed,
417
+ stillConnected: Boolean(credential.apiKey),
418
+ note: credential.apiKey
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.`
421
+ }
422
+ }))
423
+
424
+ server.tool('canvas_connect',
425
+ 'Check whether the connected Wicker Study account has a Canvas connection, and if not, return the page where the student adds one. Call this before any canvas_* tool. The Canvas Personal Access Token is entered in the browser and encrypted for the account — it is never given to an agent, and must never be requested in chat.',
426
+ { canvasUrl: z.string().optional().describe(`Canvas origin. Default ${DEFAULT_CANVAS_URL}.`) },
427
+ run(async ({ canvasUrl }) => {
428
+ const origin = new URL(canvasUrl || DEFAULT_CANVAS_URL).origin
429
+ const settings = `${baseUrl}/app#/account/connections`
430
+ const connections = (await api('/api/account/integrations/canvas')).connections || []
431
+ const match = connections.find((connection) => connection.origin === origin) || null
432
+ if (match) {
433
+ return {
434
+ connected: true,
435
+ origin: match.origin,
436
+ connectedAt: match.createdAt,
437
+ lastUsedAt: match.lastUsedAt,
438
+ next: 'Use canvas_list_remote_courses to see what is available, then canvas_import_remote_course.'
439
+ }
440
+ }
441
+ return {
442
+ connected: false,
443
+ origin,
444
+ otherConnections: connections.map((connection) => connection.origin),
445
+ authorizationUrl: settings,
446
+ instructions: [
447
+ `Ask the user to open ${settings}.`,
448
+ `They create a Personal Access Token in Canvas (${origin}/profile/settings), then paste it there — into Wicker Study, in their browser, not into this conversation.`,
449
+ 'Wicker Study encrypts it for their account. Agents receive proxied course data and never the token.',
450
+ 'Then call canvas_connect again to confirm.'
451
+ ]
452
+ }
453
+ }))
454
+
455
+ server.tool('whoami', 'Who this key acts as, its scopes, programme memberships, and whether it is an administrator.', {}, run(() => api('/api/me')))
456
+ server.tool('join_programme', 'Join a maintained programme (organisation). Only programmes whose institution domains match the student’s email can be joined.', { programmeId: z.string() }, run(({ programmeId }) => api('/api/account/programme', { method: 'POST', body: { programmeId } })))
457
+ server.tool('list_courses', 'Courses with chapters and progress counts.', {}, run(() => api('/api/courses')))
458
+ server.tool('get_course', 'One course: chapters, mastery items with the student’s mastery, exam papers.', { courseId }, run(({ courseId }) => api(`/api/courses/${encodeURIComponent(courseId)}`)))
459
+ server.tool('get_chapter', 'Chapter markdown content. relPath opens a linked file or sub-page inside the chapter folder.', { courseId, chapterId, relPath: z.string().optional() },
460
+ run(({ courseId, chapterId, relPath }) => api(`/api/chapter/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}${relPath ? '/' + relPath.split('/').map(encodeURIComponent).join('/') : ''}`)))
461
+ server.tool('get_course_outline', 'Heading outline of every chapter in a course.', { courseId }, run(({ courseId }) => api(`/api/course-toc/${encodeURIComponent(courseId)}`)))
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 } })))
465
+ server.tool('list_questions', 'Published questions for a chapter plus the student’s personal extra exercises.', { courseId, chapterId },
466
+ run(({ courseId, chapterId }) => api(`/api/questions/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}`)))
467
+ 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() },
468
+ run(async ({ courseId, limit }) => { const data = await api('/api/practice'); const questions = (data.questions || []).filter((q) => !courseId || q.courseId === courseId); return { courses: data.courses, total: questions.length, questions: questions.slice(0, limit || 50) } }))
469
+ server.tool('get_progress', 'Mastery per course and item for the student.', {},
470
+ run(async () => { const state = await api('/api/state'); return { doneThreshold: state.meta?.doneThreshold ?? 3, courses: state.courses.map((c) => ({ id: c.id, code: c.code, name: c.name, archived: Boolean(c.archived), items: (c.items || []).map((i) => ({ id: i.id, title: i.title, mastery: i.mastery ?? 0, updatedAt: i.masteryUpdatedAt || null })) })) } }))
471
+ server.tool('list_flashcards', 'Flashcards for a course by chapter, with spaced-repetition state.', { courseId }, run(({ courseId }) => api(`/api/flashcards/${encodeURIComponent(courseId)}`)))
472
+ server.tool('list_due_cards', 'Question-level spaced-repetition cards that are due now.', {}, run(() => api('/api/sr/due')))
473
+ server.tool('list_mistakes', 'Mistake bank.', { open: z.boolean().optional().describe('Only unresolved mistakes (default true).') }, run(({ open }) => api('/api/mistakes', { query: { open: open === false ? undefined : 'true' } })))
474
+ server.tool('list_mock_sessions', 'Completed mock sessions.', {}, run(() => api('/api/mocks')))
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
+ server.tool('get_academic_plan', 'Active academic programme: courses, attempts, exam dates, events, gates, summary.', {}, run(() => api('/api/academics')))
477
+ server.tool('list_known_programmes', 'The catalogue of known bachelor programmes.', {}, run(() => api('/api/editorial-programmes')))
478
+ server.tool('get_calendar', 'Unified calendar: exams, deadlines, registration windows, institution dates, and timetable feed events.', { from: z.string().optional().describe('ISO date; omit for everything'), to: z.string().optional() },
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
+ 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
+ server.tool('get_account_summary', 'What is stored for the account, per record family.', {}, run(() => api('/api/account/summary')))
482
+
483
+ // ── Write ────────────────────────────────────────────────────────────────
484
+ server.tool('submit_answer', 'Grade an answer to a published question (uses the student’s AI allowance) and record it.', { courseId, chapterId, questionId: z.string(), attempt: z.string() },
485
+ run(async ({ courseId, chapterId, questionId, attempt }) => {
486
+ const [course, bank] = await Promise.all([api(`/api/courses/${encodeURIComponent(courseId)}`), api(`/api/questions/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}`)])
487
+ const question = (bank.questions || []).find((q) => q.id === questionId)
488
+ if (!question) throw new Error(`Unknown question ${questionId} in ${courseId}/${chapterId}`)
489
+ const chapter = (course.chapters || []).find((c) => c.id === chapterId)
490
+ return api('/api/grade', { method: 'POST', body: { courseCode: course.code, chapterName: chapter?.name || chapterId, question, attempt, _meta: { courseId, chapterId } } })
491
+ }))
492
+ server.tool('set_mastery', 'Set mastery (0–4) on a study item.', { itemId: z.string(), mastery: z.number().int().min(0).max(4), note: z.string().optional() },
493
+ run(({ itemId, mastery, note }) => api(`/api/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: { mastery, note } })))
494
+ server.tool('review_card', 'Review a question-level spaced-repetition card (quality 0–5).', { questionId: z.string(), quality: z.number().int().min(0).max(5) },
495
+ run(({ questionId, quality }) => api('/api/sr/review', { method: 'POST', body: { questionId, quality } })))
496
+ server.tool('add_to_deck', 'Add a question to the spaced-repetition deck.', { questionId: z.string() }, run(({ questionId }) => api('/api/sr/add', { method: 'POST', body: { questionId } })))
497
+ server.tool('create_flashcard', 'Create a personal flashcard in a chapter.', { courseId, chapterId, front: z.string(), back: z.string() },
498
+ run(({ courseId, chapterId, front, back }) => api(`/api/flashcards/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}`, { method: 'POST', body: { front, back } })))
499
+ server.tool('review_flashcard', 'Review a flashcard (quality 0–5).', { courseId, chapterId, cardId: z.string(), quality: z.number().int().min(0).max(5) },
500
+ run(({ courseId, chapterId, cardId, quality }) => api(`/api/flashcards/${encodeURIComponent(courseId)}/${encodeURIComponent(chapterId)}/${encodeURIComponent(cardId)}/review`, { method: 'POST', body: { quality } })))
501
+ server.tool('resolve_mistake', 'Mark a mistake as resolved.', { mistakeId: z.string() }, run(({ mistakeId }) => api(`/api/mistakes/${encodeURIComponent(mistakeId)}/resolve`, { method: 'POST', body: {} })))
502
+ server.tool('record_chapter_read', 'Record that the student read a chapter.', { courseId, chapterId, label: z.string().optional() },
503
+ run(({ courseId, chapterId, label }) => api('/api/activity', { method: 'POST', body: { type: 'read', courseId, chapterId, label } })))
504
+ 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
+ run(({ workspace, expectedRevision }) => api('/api/academics', { method: 'PUT', body: { workspace, expectedRevision } })))
506
+ 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
+ run(({ courseId, archived, order }) => api(`/api/courses/${encodeURIComponent(courseId)}`, { method: 'PATCH', body: { archived, order } })))
508
+
509
+ // ── Canvas through the account connection (no local PAT) ──────────────────
510
+ 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
+ canvasUrl: z.string().url().default('https://canvas.maastrichtuniversity.nl'), query: z.string().max(240).optional()
512
+ }, run(listRemoteCanvasCourses))
513
+ server.tool('canvas_list_remote_course_modules', 'List modules for a Canvas course using the caller’s encrypted Wicker Study Canvas connection. Use this before importing a chosen subset.', {
514
+ courseUrl: z.string().url()
515
+ }, run(listRemoteCanvasCourseModules))
516
+ 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.', {
517
+ courseUrl: z.string().url(), outputFolder: z.string().min(1), moduleIds: z.array(z.string().min(1)).max(500).optional(), maxResources: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxResources).default(CANVAS_IMPORT_LIMITS.maxResources), maxFileBytes: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxFileBytes).default(CANVAS_IMPORT_LIMITS.maxFileBytes)
518
+ }, run(importRemoteCanvasCourse))
519
+ server.tool('canvas_import_remote_course_set', 'Find every remotely connected Canvas course matching a title, course code, term, or initials, then create separate local snapshots for each. Use for requests such as “import all IUI courses across the years”; preserve each Canvas course id and academic term as a separate source edition.', {
520
+ canvasUrl: z.string().url().default('https://canvas.maastrichtuniversity.nl'), query: z.string().min(1).max(240), outputFolder: z.string().min(1), maxCourses: z.number().int().min(1).max(100).default(25), maxResources: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxResources).default(CANVAS_IMPORT_LIMITS.maxResources), maxFileBytes: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxFileBytes).default(CANVAS_IMPORT_LIMITS.maxFileBytes)
521
+ }, run(importRemoteCanvasCourseSet))
522
+
523
+ server.tool('analyze_documents', 'Analyse supporting documents (transcript, exam schedule, timetable, academic calendar, curriculum) with AI and return a reviewable change set against the student’s plan. Uses the student’s intake allowance. Follow with apply_changes.', { kind: z.enum(['auto', 'transcript', 'exam-schedule', 'timetable', 'academic-calendar', 'curriculum']).optional(), description: z.string().optional(), documents: z.array(z.object({ name: z.string(), type: z.string().optional(), text: z.string().optional(), images: z.array(z.string()).optional() })) },
524
+ run((body) => api('/api/academics/documents/analyze', { method: 'POST', body })))
525
+ server.tool('apply_changes', 'Apply accepted change objects (from analyze_documents or a calendar preview) to the active plan.', { changes: z.array(z.record(z.any())), expectedRevision: z.number().int() },
526
+ run((body) => api('/api/academics/documents/apply', { method: 'POST', body })))
527
+ server.tool('preview_calendar', 'Parse an iCalendar link or pasted .ics text into a change set without saving.', { url: z.string().optional(), ics: z.string().optional() }, run((body) => api('/api/academics/calendars/preview', { method: 'POST', body })))
528
+ server.tool('save_calendar_link', 'Save a timetable/exam-schedule calendar link to the plan and get its events as a change set.', { url: z.string(), label: z.string().optional() }, run((body) => api('/api/academics/calendars', { method: 'POST', body })))
529
+ server.tool('sync_calendar_link', 'Re-fetch a saved calendar link and get new events as a change set.', { id: z.string() }, run(({ id }) => api(`/api/academics/calendars/${encodeURIComponent(id)}/sync`, { method: 'POST', body: {} })))
530
+ server.tool('remove_calendar_link', 'Remove a saved calendar link.', { id: z.string() }, run(({ id }) => api(`/api/academics/calendars/${encodeURIComponent(id)}`, { method: 'DELETE' })))
531
+
532
+ // ── Admin (editorial content; requires an admin key) ─────────────────────
533
+ const adminCourse = (courseId) => `/api/admin/courses/${encodeURIComponent(courseId)}`
534
+ server.tool('admin_status', 'Active release and content counts.', {}, run(() => api('/api/admin/status')))
535
+ server.tool('admin_inventory_course_folder', 'Read a local course-material folder without changing Wicker Study. Returns supported files, SHA-256 hashes, ignored files, and byte totals.', { folderPath: z.string() }, run(({ folderPath }) => inventoryCourseFolder(folderPath).then(publicInventory)))
536
+ server.tool('admin_upsert_course_edition', 'Create or update a private, versioned course edition before sources or drafts are published.', { id: z.string().optional(), programmeId: z.string().optional(), canonicalCourseId: z.string().optional(), institution: z.string().optional(), courseCode: z.string(), courseName: z.string(), academicYear: z.string().optional(), period: z.string().optional(), editionKey: z.string().optional() }, run((body) => api('/api/admin/editorial-editions', { method: 'POST', body })))
537
+ server.tool('admin_register_course_urls', 'Register public web sources for an existing edition. They are fetched with SSRF protection during extraction.', { editionId: z.string(), urls: z.array(z.string().url()).min(1).max(30), rightsBasis: z.enum(['public-source', 'authorised-course-material', 'admin-supplied']).default('public-source') }, run(({ editionId, urls, rightsBasis }) => api(`/api/admin/editorial-editions/${encodeURIComponent(editionId)}/sources`, { method: 'POST', body: { rightsBasis, sources: urls.map((url, index) => ({ url, name: `linked-source-${index + 1}.html`, relativePath: url })) } })))
538
+ server.tool('admin_sync_course_folder', 'Create or update a versioned course edition from a local folder. Defaults to a dry run. Unchanged files are reused by hash; changed paths supersede older sources. Set replaceManifest only when the folder is the authoritative complete source set.', {
539
+ folderPath: z.string(), editionId: z.string().optional(), programmeId: z.string().optional(), canonicalCourseId: z.string().optional(), institution: z.string().optional(), courseCode: z.string().optional(), courseName: z.string().optional(), academicYear: z.string().optional(), period: z.string().optional(), dryRun: z.boolean().default(true), replaceManifest: z.boolean().default(false), rightsBasis: z.enum(['authorised-course-material', 'admin-supplied']).default('admin-supplied'), consentStatus: z.enum(['accepted', 'candidate']).default('accepted')
540
+ }, run(syncCourseFolder))
541
+ server.tool('admin_save_canvas_token_from_clipboard', 'Store a Canvas Personal Access Token from this Mac’s clipboard in macOS Keychain for the Canvas host. Ask the administrator to copy the token in Canvas and confirm it is on the clipboard; never ask them to paste it into chat or a tool argument. The token value is never returned. Replaces the saved token for this host.', {
542
+ courseUrl: z.string().url()
543
+ }, run(async ({ courseUrl }) => {
544
+ const saved = await saveCanvasAccessTokenFromClipboard(courseUrl)
545
+ return { saved: true, host: saved.host, next: 'Use admin_import_canvas_course with the course URL and an output folder. Future local MCP sessions on this Mac reuse this host-scoped Keychain token.' }
546
+ }))
547
+ server.tool('admin_list_canvas_courses', 'List every Canvas course available to this account, including concluded and prior-year enrolments. Query matches course name, code, term, and title initials (for example “IUI” matches Intelligent User Interfaces). Uses the host-scoped local Keychain token and returns no credential.', {
548
+ canvasUrl: z.string().url().default('https://canvas.maastrichtuniversity.nl'), query: z.string().max(240).optional(), accessTokenEnv: z.string().optional()
549
+ }, run(listLocalCanvasCourses))
550
+ server.tool('admin_list_canvas_course_modules', 'List a Canvas course’s modules and contained item counts so the administrator can choose the whole course or a precise module subset. Uses the local Keychain token and returns no credential.', {
551
+ courseUrl: z.string().url(), accessTokenEnv: z.string().optional()
552
+ }, run(async ({ courseUrl, accessTokenEnv }) => listCanvasCourseModules({ courseUrl, accessToken: await localCanvasAccessToken(courseUrl, accessTokenEnv) })))
553
+ server.tool('admin_import_canvas_course_set', 'Find every Canvas course matching a name, code, term, or title initials and import each into its own deterministic local folder. This is for requests such as “scrape all IUI courses across the years”. It is local-only and sequential; use admin_list_canvas_courses first when the requested match is ambiguous. Never pass Canvas credentials.', {
554
+ canvasUrl: z.string().url().default('https://canvas.maastrichtuniversity.nl'), query: z.string().min(1).max(240), outputFolder: z.string().min(1), accessTokenEnv: z.string().optional(), maxCourses: z.number().int().min(1).max(100).default(25), maxResources: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxResources).default(CANVAS_IMPORT_LIMITS.maxResources), maxFileBytes: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxFileBytes).default(CANVAS_IMPORT_LIMITS.maxFileBytes)
555
+ }, run(importLocalCanvasCourseSet))
556
+ server.tool('admin_export_canvas_course_zip', 'Download a selected Canvas course or module subset into one local ZIP archive. Materials are only held in a temporary local staging folder, then removed after the ZIP succeeds. zipPath must be a new absolute local .zip path; existing files are never overwritten. Never pass Canvas credentials.', {
557
+ courseUrl: z.string().url(), moduleIds: z.array(z.string().min(1)).max(500).optional(), zipPath: z.string().min(1), accessTokenEnv: z.string().optional(), maxResources: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxResources).default(CANVAS_IMPORT_LIMITS.maxResources), maxFileBytes: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxFileBytes).default(CANVAS_IMPORT_LIMITS.maxFileBytes)
558
+ }, run(exportLocalCanvasCourseZip))
559
+ server.tool('admin_import_canvas_course', 'Download every accessible Canvas module item, file, page, assignment, discussion, quiz, and external-link reference into a structured local course folder. Provide courseUrl and outputFolder; on the same Mac it reuses the host-scoped token in the user Keychain. Use admin_save_canvas_token_from_clipboard to provision or replace that local credential without exposing it to the agent. A denied course-wide Files index is recorded as skipped while accessible Module material continues. Large files stream directly to disk, with a 1 GB per-file limit. Never pass a Canvas password, OTP, cookie, or token here. The default only downloads locally. Optional Wicker sync is a separate rights-confirmed candidate review, never publication.', {
560
+ courseUrl: z.string().url().optional(), outputFolder: z.string().optional(), moduleIds: z.array(z.string().min(1)).max(500).optional(), accessTokenEnv: z.string().optional(), maxResources: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxResources).default(CANVAS_IMPORT_LIMITS.maxResources), maxFileBytes: z.number().int().min(1).max(CANVAS_IMPORT_LIMITS.maxFileBytes).default(CANVAS_IMPORT_LIMITS.maxFileBytes), syncToWicker: z.boolean().default(false), rightsConfirmed: z.boolean().default(false), dryRun: z.boolean().default(true), editionId: z.string().optional(), programmeId: z.string().optional(), canonicalCourseId: z.string().optional(), institution: z.string().optional(), courseCode: z.string().optional(), courseName: z.string().optional(), academicYear: z.string().optional(), period: z.string().optional()
561
+ }, run(importCanvasCourseAndMaybeSync))
562
+ server.tool('admin_list_editorial_workspace', 'List compact course-edition summaries, or pass editionId for its sources, rights decisions, topics, jobs, artifacts, estimates, and releases.', { editionId: z.string().optional() }, run(({ editionId }) => api('/api/admin/editorial-workspace', { query: { editionId } })))
563
+ server.tool('admin_prepare_content_request', 'Turn a student request into a candidate shared edition. Fails if the student kept sources private.', { requestId: z.string() }, run(({ requestId }) => api(`/api/admin/content-requests/${encodeURIComponent(requestId)}/prepare`, { method: 'POST', body: {} })))
564
+ server.tool('admin_review_contribution', 'Accept, reject, or withdraw a source contribution after rights review.', { contributionId: z.string(), status: z.enum(['accepted', 'rejected', 'withdrawn']), reviewNote: z.string().optional() }, run(({ contributionId, ...body }) => api(`/api/admin/editorial-contributions/${encodeURIComponent(contributionId)}`, { method: 'PUT', body })))
565
+ server.tool('admin_estimate_course_generation', 'Estimate generation tokens and show cached/reusable artifact counts. This never starts generation.', { editionId: z.string() }, run(({ editionId }) => api(`/api/admin/editorial-editions/${encodeURIComponent(editionId)}/estimate`)))
566
+ server.tool('admin_queue_course_generation', 'Queue study pages, exercises, flashcards, and/or quality review. Requires an explicit confirmed=true after showing the token estimate.', { editionId: z.string(), types: z.array(z.enum(['study-pages', 'exercises', 'flashcards', 'quality'])).optional(), confirmed: z.literal(true) }, run(({ editionId, types }) => api(`/api/admin/editorial-editions/${encodeURIComponent(editionId)}/generate`, { method: 'POST', body: { types } })))
567
+ server.tool('admin_process_course_pipeline', 'Run pending extraction/mapping/generation jobs. useAi must be true for AI mapping or draft generation. untilIdle repeats bounded API calls; inspect failures and artifacts afterward.', { editionId: z.string(), types: z.array(z.enum(['extract', 'map', 'study-pages', 'exercises', 'flashcards', 'quality'])).optional(), useAi: z.boolean().default(false), limit: z.number().int().min(1).max(25).default(5), untilIdle: z.boolean().default(false), maxRuns: z.number().int().min(1).max(40).default(12) }, run(async ({ editionId, types, useAi, limit, untilIdle, maxRuns }) => {
568
+ const runs = []
569
+ for (let index = 0; index < (untilIdle ? maxRuns : 1); index++) {
570
+ const result = await api(`/api/admin/editorial-editions/${encodeURIComponent(editionId)}/process`, { method: 'POST', body: { useAi, limit, types } })
571
+ runs.push(result)
572
+ if (!untilIdle || result.remaining === 0 || result.processed === 0) break
573
+ }
574
+ return { runs, remaining: runs.at(-1)?.remaining ?? null, processed: runs.reduce((sum, runResult) => sum + Number(runResult.processed || 0), 0) }
575
+ }))
576
+ server.tool('admin_review_course_artifact', 'Edit or approve/reject one generated course artifact. Keep review notes for editorial audit.', { artifactId: z.string(), status: z.enum(['draft', 'review', 'approved', 'rejected']).optional(), title: z.string().optional(), definition: z.record(z.any()).optional(), reviewNote: z.string().optional() }, run(({ artifactId, ...body }) => api(`/api/admin/editorial-artifacts/${encodeURIComponent(artifactId)}`, { method: 'PUT', body })))
577
+ server.tool('admin_publish_course_edition', 'Publish only approved, evidence-linked artifacts. confirmation must exactly match the edition course code; publication is not reversible through this tool.', { editionId: z.string(), confirmation: z.string() }, run(({ editionId, confirmation }) => api(`/api/admin/editorial-editions/${encodeURIComponent(editionId)}/publish`, { method: 'POST', body: { confirmation } })))
578
+ server.tool('admin_list_members', 'Members of a programme organisation with roles.', { programmeId: z.string() }, run(({ programmeId }) => api(`/api/admin/programmes/${encodeURIComponent(programmeId)}/members`)))
579
+ server.tool('admin_set_member', 'Add a user to a programme or change their role (member | admin). Granting admin needs a global administrator.', { programmeId: z.string(), userId: z.string(), role: z.enum(['member', 'admin']).default('member') }, run(({ programmeId, userId, role }) => api(`/api/admin/programmes/${encodeURIComponent(programmeId)}/members/${encodeURIComponent(userId)}`, { method: 'PUT', body: { role } })))
580
+ server.tool('admin_remove_member', 'Remove a user from a programme organisation.', { programmeId: z.string(), userId: z.string() }, run(({ programmeId, userId }) => api(`/api/admin/programmes/${encodeURIComponent(programmeId)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' })))
581
+ server.tool('admin_upsert_course', 'Create or update a course.', { courseId, code: z.string().optional(), name: z.string().optional(), shortName: z.string().optional(), exam: z.string().optional(), role: z.string().optional(), accent: z.string().optional(), knowledgeBase: z.string().optional(), visualStyle: z.string().optional(), examProfile: z.string().optional(), position: z.number().int().optional(), extra: z.record(z.any()).optional() },
582
+ run(({ courseId, ...body }) => api(adminCourse(courseId), { method: 'PUT', body })))
583
+ server.tool('admin_delete_course', 'Delete a course and everything under it. Irreversible.', { courseId }, run(({ courseId }) => api(adminCourse(courseId), { method: 'DELETE' })))
584
+ server.tool('admin_upsert_chapter', 'Create or update a chapter. sourcePath is the markdown file inside the course knowledge base (create it with admin_put_material).', { courseId, chapterId, name: z.string().optional(), sourcePath: z.string().optional(), position: z.number().int().optional(), extra: z.record(z.any()).optional() },
585
+ run(({ courseId, chapterId, ...body }) => api(`${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}`, { method: 'PUT', body })))
586
+ server.tool('admin_delete_chapter', 'Delete a chapter and its published questions.', { courseId, chapterId }, run(({ courseId, chapterId }) => api(`${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}`, { method: 'DELETE' })))
587
+ server.tool('admin_list_materials', 'Files in a course knowledge base with sizes and hashes.', { courseId }, run(({ courseId }) => api(`${adminCourse(courseId)}/materials`)))
588
+ server.tool('admin_put_material', 'Create or replace a file in a course knowledge base. Text goes in `content`; binary in `base64`. Text is re-indexed for the tutor.', { courseId, sourcePath: z.string(), content: z.string().optional(), base64: z.string().optional(), mediaType: z.string().optional() },
589
+ run(({ courseId, sourcePath, ...body }) => api(`${adminCourse(courseId)}/materials`, { method: 'PUT', query: { path: sourcePath }, body })))
590
+ server.tool('admin_delete_material', 'Delete a file from a course knowledge base.', { courseId, sourcePath: z.string() }, run(({ courseId, sourcePath }) => api(`${adminCourse(courseId)}/materials`, { method: 'DELETE', query: { path: sourcePath } })))
591
+ server.tool('admin_extract_material', 'Re-extract text from a stored PDF and rebuild its retrieval index.', { courseId, sourcePath: z.string() }, run(({ courseId, sourcePath }) => api(`${adminCourse(courseId)}/materials/extract`, { method: 'POST', query: { path: sourcePath }, body: {} })))
592
+ server.tool('admin_list_flashcards', 'Editorial flashcards for a course or one chapter.', { courseId, chapterId: chapterId.optional() },
593
+ run(({ courseId, chapterId }) => api(chapterId ? `${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}/flashcards` : `${adminCourse(courseId)}/flashcards`)))
594
+ server.tool('admin_replace_flashcards', 'Replace a chapter’s editorial flashcards.', { courseId, chapterId, cards: z.array(z.object({ id: z.string().optional(), front: z.string(), back: z.string(), source: z.string().optional() })) },
595
+ run(({ courseId, chapterId, cards }) => api(`${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}/flashcards`, { method: 'PUT', body: { cards } })))
596
+ server.tool('admin_upsert_flashcard', 'Create or update one editorial flashcard.', { courseId, chapterId, id: z.string().optional(), front: z.string(), back: z.string(), source: z.string().optional() },
597
+ run(({ courseId, chapterId, id, ...card }) => api(`${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}/flashcards/${encodeURIComponent(id || 'new')}`, { method: 'PUT', body: { ...card, ...(id ? { id } : {}) } })))
598
+ server.tool('admin_delete_flashcard', 'Delete one editorial flashcard.', { courseId, cardId: z.string() }, run(({ courseId, cardId }) => api(`${adminCourse(courseId)}/flashcards/${encodeURIComponent(cardId)}`, { method: 'DELETE' })))
599
+ server.tool('admin_upsert_item', 'Create or update a mastery item (topic/skill) in a course.', { courseId, itemId: z.string(), definition: z.record(z.any()).describe('{ title, type?, category?, chapterId?, position?, … }') },
600
+ run(({ courseId, itemId, definition }) => api(`${adminCourse(courseId)}/items/${encodeURIComponent(itemId)}`, { method: 'PUT', body: definition })))
601
+ server.tool('admin_delete_item', 'Delete a mastery item.', { courseId, itemId: z.string() }, run(({ courseId, itemId }) => api(`${adminCourse(courseId)}/items/${encodeURIComponent(itemId)}`, { method: 'DELETE' })))
602
+ server.tool('admin_upsert_paper', 'Register a mock exam or tutorial paper (PDF paths inside the knowledge base).', { courseId, type: z.enum(['mock-exam', 'tutorial']), paperId: z.string(), label: z.string().optional(), questionPath: z.string().optional(), solutionsPath: z.string().optional(), position: z.number().int().optional() },
603
+ run(({ courseId, type, paperId, ...body }) => api(`${adminCourse(courseId)}/papers/${type}/${encodeURIComponent(paperId)}`, { method: 'PUT', body })))
604
+ server.tool('admin_delete_paper', 'Remove a paper.', { courseId, type: z.enum(['mock-exam', 'tutorial']), paperId: z.string() }, run(({ courseId, type, paperId }) => api(`${adminCourse(courseId)}/papers/${type}/${encodeURIComponent(paperId)}`, { method: 'DELETE' })))
605
+ server.tool('admin_list_questions', 'Published question bank of a chapter (editorial only).', { courseId, chapterId }, run(({ courseId, chapterId }) => api(`${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}/questions`)))
606
+ server.tool('admin_replace_questions', 'Replace a chapter’s whole question bank.', { courseId, chapterId, questions: z.array(z.record(z.any())) },
607
+ run(({ courseId, chapterId, questions }) => api(`${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}/questions`, { method: 'PUT', body: { questions } })))
608
+ server.tool('admin_upsert_question', 'Create or update one published question. Shape: { id, type, question, expected?, options?, answer?, difficulty?, source? }.', { courseId, chapterId, question: z.record(z.any()) },
609
+ run(({ courseId, chapterId, question }) => api(`${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}/questions/${encodeURIComponent(question.id || 'new')}`, { method: 'PUT', body: question })))
610
+ server.tool('admin_delete_question', 'Delete one published question.', { courseId, chapterId, questionId: z.string() }, run(({ courseId, chapterId, questionId }) => api(`${adminCourse(courseId)}/chapters/${encodeURIComponent(chapterId)}/questions/${encodeURIComponent(questionId)}`, { method: 'DELETE' })))
611
+ server.tool('admin_list_programmes', 'Programme catalogue as stored.', {}, run(() => api('/api/admin/programmes')))
612
+ server.tool('admin_upsert_programme', 'Create or update a known bachelor programme. Definition: { institution: { name, city?, country? }, name, degree, durationYears, totalEcts, language, versions: [{ id, label, status, courses: [{ id, code, name, ects, yearLevel, period, requirement }], choiceGroups?, pathways?, requirements? }] }.', { programmeId: z.string(), definition: z.record(z.any()) },
613
+ run(({ programmeId, definition }) => api(`/api/admin/programmes/${encodeURIComponent(programmeId)}`, { method: 'PUT', body: definition })))
614
+ server.tool('admin_set_programme_calendar', 'Set the institution-wide academic calendar for a known programme from events, an .ics text, a calendar URL, or documents (AI-analysed).', { programmeId: z.string(), events: z.array(z.record(z.any())).optional(), ics: z.string().optional(), url: z.string().optional(), documents: z.array(z.record(z.any())).optional(), replace: z.boolean().optional() },
615
+ run(({ programmeId, ...body }) => api(`/api/admin/programmes/${encodeURIComponent(programmeId)}/calendar`, { method: 'PUT', body })))
616
+ server.tool('admin_delete_programme', 'Remove a known programme from the catalogue.', { programmeId: z.string() }, run(({ programmeId }) => api(`/api/admin/programmes/${encodeURIComponent(programmeId)}`, { method: 'DELETE' })))
617
+
618
+ server.resource('manifest', 'wicker-study://manifest', { description: 'HTTP API manifest with every endpoint and scope' }, async () => ({ contents: [{ uri: 'wicker-study://manifest', mimeType: 'application/json', text: JSON.stringify(await api('/api/agent/manifest'), null, 2) }] }))
619
+
620
+ const transport = new StdioServerTransport()
621
+ await server.connect(transport)