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/README.md +78 -0
- package/authorize.mjs +132 -0
- package/config.mjs +109 -0
- package/package.json +35 -0
- package/scripts/macos-keychain.swift +79 -0
- package/server.mjs +621 -0
- package/vendor/canvas-course-export.mjs +60 -0
- package/vendor/canvas-course-import.mjs +685 -0
- package/vendor/local-canvas-prompts.mjs +214 -0
|
@@ -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
|
+
}
|