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.
@@ -0,0 +1,214 @@
1
+ import { execFile, spawn } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { access, mkdir, readFile, rename, unlink } from 'node:fs/promises'
4
+ import { homedir, tmpdir } from 'node:os'
5
+ import { isAbsolute, join, resolve } from 'node:path'
6
+ import { stdin, stdout } from 'node:process'
7
+ import { createInterface } from 'node:readline'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { promisify } from 'node:util'
10
+
11
+ export class LocalCanvasPromptError extends Error {}
12
+
13
+ const keychainService = 'life.wicker.study.canvas-access-token'
14
+ const execFileAsync = promisify(execFile)
15
+
16
+ function clean(value) {
17
+ return String(value ?? '').replace(/\0/g, '').trim()
18
+ }
19
+
20
+ function missingInputError() {
21
+ return new LocalCanvasPromptError('Interactive Canvas import needs a terminal. Supply --course-url and --output, then provide a short-lived token through --token-env CANVAS_ACCESS_TOKEN.')
22
+ }
23
+
24
+ function canvasHost(courseUrl) {
25
+ try {
26
+ const url = new URL(courseUrl)
27
+ return url.protocol === 'https:' ? url.hostname.toLowerCase() : null
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ function normalizedOutputFolder(value) {
34
+ const folder = clean(value)
35
+ if (!folder) return ''
36
+ const expanded = folder === '~' ? homedir() : folder.startsWith('~/') ? join(homedir(), folder.slice(2)) : folder
37
+ return isAbsolute(expanded) ? expanded : resolve(homedir(), expanded)
38
+ }
39
+
40
+ async function nativeKeychainBinary() {
41
+ const scriptPath = fileURLToPath(new URL('../scripts/macos-keychain.swift', import.meta.url))
42
+ const source = await readFile(scriptPath)
43
+ const digest = createHash('sha256').update(source).digest('hex').slice(0, 16)
44
+ const cacheDirectory = join(tmpdir(), 'wicker-study')
45
+ const binaryPath = join(cacheDirectory, `keychain-${digest}`)
46
+ try {
47
+ await access(binaryPath)
48
+ return binaryPath
49
+ } catch {}
50
+ await mkdir(cacheDirectory, { recursive: true })
51
+ const temporaryPath = `${binaryPath}-${process.pid}-${Date.now()}`
52
+ try {
53
+ await execFileAsync('swiftc', [scriptPath, '-o', temporaryPath], { timeout: 120_000, maxBuffer: 1024 * 1024 })
54
+ await rename(temporaryPath, binaryPath)
55
+ return binaryPath
56
+ } catch (error) {
57
+ await unlink(temporaryPath).catch(() => {})
58
+ throw error
59
+ }
60
+ }
61
+
62
+ async function keychainRequest({ operation, courseUrl, value }) {
63
+ const host = canvasHost(courseUrl)
64
+ if (process.platform !== 'darwin' || !host) return { found: false, value: null }
65
+ const binaryPath = await nativeKeychainBinary()
66
+ const request = JSON.stringify({ operation, service: keychainService, account: host, ...(value ? { value } : {}) })
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn(binaryPath, [], { stdio: ['pipe', 'pipe', 'pipe'] })
69
+ let output = ''
70
+ let errorOutput = ''
71
+ child.stdout.setEncoding('utf8')
72
+ child.stderr.setEncoding('utf8')
73
+ child.stdout.on('data', (chunk) => { output += chunk })
74
+ child.stderr.on('data', (chunk) => { errorOutput += chunk })
75
+ child.once('error', reject)
76
+ child.once('close', (code) => {
77
+ if (code !== 0) return reject(new Error(errorOutput || `macOS Keychain helper exited with code ${code}`))
78
+ try { resolve(JSON.parse(output)) }
79
+ catch { reject(new Error('macOS Keychain helper returned an invalid response.')) }
80
+ })
81
+ child.stdin.end(request)
82
+ })
83
+ }
84
+
85
+ async function savedCanvasAccessToken(courseUrl) {
86
+ const host = canvasHost(courseUrl)
87
+ if (process.platform !== 'darwin' || !host) return ''
88
+ try {
89
+ const response = await keychainRequest({ operation: 'get', courseUrl })
90
+ return response.found ? clean(response.value) : ''
91
+ } catch {
92
+ return ''
93
+ }
94
+ }
95
+
96
+ // Used by local MCP tools that already know the Canvas origin. It intentionally
97
+ // never falls back to an interactive prompt: agents must ask the administrator
98
+ // to copy a replacement token and use the dedicated clipboard-only MCP tool.
99
+ export async function getSavedCanvasAccessToken(canvasUrl) {
100
+ const resolvedCanvasUrl = clean(canvasUrl)
101
+ const host = canvasHost(resolvedCanvasUrl)
102
+ if (!host) throw new LocalCanvasPromptError('Provide a valid HTTPS Canvas URL.')
103
+ const accessToken = await savedCanvasAccessToken(resolvedCanvasUrl)
104
+ if (!accessToken) throw new LocalCanvasPromptError(`No Canvas token is saved for ${host}. Ask the administrator to copy a Personal Access Token in Canvas, then use admin_save_canvas_token_from_clipboard.`)
105
+ return accessToken
106
+ }
107
+
108
+ async function saveCanvasAccessToken(courseUrl, accessToken) {
109
+ const host = canvasHost(courseUrl)
110
+ if (process.platform !== 'darwin' || !host || !accessToken) return false
111
+ try {
112
+ // The headless helper receives JSON through stdin, so the token never appears
113
+ // in terminal output, command arguments, history, or app configuration.
114
+ return Boolean((await keychainRequest({ operation: 'set', courseUrl, value: accessToken })).found)
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ export async function forgetSavedCanvasAccessToken(courseUrl) {
121
+ const host = canvasHost(courseUrl)
122
+ if (process.platform !== 'darwin' || !host) return false
123
+ try {
124
+ return Boolean((await keychainRequest({ operation: 'delete', courseUrl })).found)
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+
130
+ function question(terminal, prompt) {
131
+ return new Promise((resolve) => terminal.question(prompt, resolve))
132
+ }
133
+
134
+ async function canvasTokenFromClipboard() {
135
+ if (process.platform !== 'darwin') {
136
+ throw new LocalCanvasPromptError('Copy-to-Keychain is available on macOS. On another platform, provide a local token through --token-env CANVAS_ACCESS_TOKEN.')
137
+ }
138
+ try {
139
+ const { stdout: clipboard } = await execFileAsync('pbpaste', [], { maxBuffer: 1024 * 1024 })
140
+ const token = clean(clipboard)
141
+ if (token) return token
142
+ } catch {}
143
+ throw new LocalCanvasPromptError('No Canvas token was found in the clipboard. Copy the token first, then run the command again.')
144
+ }
145
+
146
+ // This is deliberately a clipboard-only hand-off: an agent can ask the
147
+ // administrator to copy a PAT in Canvas, then invoke this local method without
148
+ // receiving the value in a chat, tool argument, environment variable, or log.
149
+ export async function saveCanvasAccessTokenFromClipboard(courseUrl) {
150
+ const resolvedCourseUrl = clean(courseUrl)
151
+ const host = canvasHost(resolvedCourseUrl)
152
+ if (!host) throw new LocalCanvasPromptError('Provide a valid HTTPS Canvas course URL.')
153
+ const accessToken = await canvasTokenFromClipboard()
154
+ if (!await saveCanvasAccessToken(resolvedCourseUrl, accessToken)) {
155
+ throw new LocalCanvasPromptError('Canvas token could not be saved in the macOS Keychain.')
156
+ }
157
+ return { host }
158
+ }
159
+
160
+ async function promptInTerminal({ courseUrl, outputFolder, accessToken, promptToken = true }) {
161
+ if (!stdin.isTTY || !stdout.isTTY) throw missingInputError()
162
+ const terminal = createInterface({ input: stdin, output: stdout, terminal: true })
163
+ try {
164
+ let resolvedCourseUrl = courseUrl
165
+ let resolvedOutputFolder = outputFolder
166
+ let resolvedAccessToken = accessToken
167
+
168
+ if (!resolvedCourseUrl) resolvedCourseUrl = clean(await question(terminal, 'Canvas Modules URL\n> '))
169
+ if (!resolvedOutputFolder) resolvedOutputFolder = normalizedOutputFolder(await question(terminal, 'Destination folder (for example Downloads/IUI)\n> '))
170
+ if (!resolvedAccessToken && promptToken) {
171
+ await question(terminal, 'Copy the Canvas Personal Access Token, then press Return to save it in macOS Keychain\n> ')
172
+ resolvedAccessToken = await canvasTokenFromClipboard()
173
+ }
174
+ return { courseUrl: resolvedCourseUrl, outputFolder: resolvedOutputFolder, accessToken: resolvedAccessToken }
175
+ } catch (error) {
176
+ if (error instanceof LocalCanvasPromptError) throw error
177
+ throw new LocalCanvasPromptError('Canvas import was cancelled.')
178
+ } finally {
179
+ terminal.close()
180
+ }
181
+ }
182
+
183
+ export async function promptForLocalCanvasImport({ courseUrl, outputFolder, accessToken } = {}) {
184
+ let resolvedCourseUrl = clean(courseUrl)
185
+ let resolvedOutputFolder = normalizedOutputFolder(outputFolder)
186
+ let resolvedAccessToken = clean(accessToken)
187
+ if (!resolvedCourseUrl || !resolvedOutputFolder) {
188
+ const prompted = await promptInTerminal({
189
+ courseUrl: resolvedCourseUrl,
190
+ outputFolder: resolvedOutputFolder,
191
+ accessToken: resolvedAccessToken,
192
+ promptToken: false
193
+ })
194
+ resolvedCourseUrl = prompted.courseUrl
195
+ resolvedOutputFolder = normalizedOutputFolder(prompted.outputFolder)
196
+ }
197
+ if (!resolvedAccessToken) resolvedAccessToken = await savedCanvasAccessToken(resolvedCourseUrl)
198
+ if (!resolvedAccessToken) {
199
+ const prompted = await promptInTerminal({
200
+ courseUrl: resolvedCourseUrl,
201
+ outputFolder: resolvedOutputFolder,
202
+ accessToken: '',
203
+ promptToken: true
204
+ })
205
+ resolvedAccessToken = prompted.accessToken
206
+ if (resolvedAccessToken && await saveCanvasAccessToken(resolvedCourseUrl, resolvedAccessToken)) {
207
+ stdout.write('Saved this Canvas token in macOS Keychain for future local imports.\n')
208
+ }
209
+ }
210
+ if (!resolvedCourseUrl) throw new LocalCanvasPromptError('A Canvas course URL is required.')
211
+ if (!resolvedOutputFolder) throw new LocalCanvasPromptError('An output folder is required.')
212
+ if (!resolvedAccessToken) throw new LocalCanvasPromptError('A Canvas Personal Access Token is required.')
213
+ return { courseUrl: resolvedCourseUrl, outputFolder: resolvedOutputFolder, accessToken: resolvedAccessToken }
214
+ }