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 ADDED
@@ -0,0 +1,78 @@
1
+ # wicker-study-mcp
2
+
3
+ MCP server for [Wicker Study](https://study.wicker.life) — a private, source-grounded academic
4
+ workspace. It gives an agent read access to course material and a student's academic record, the
5
+ ability to study on their behalf, a Canvas course importer that never sees the Canvas token, and —
6
+ with an admin key — the whole editorial content workflow.
7
+
8
+ Runs from anywhere. Nothing here needs a checkout of the application.
9
+
10
+ ## Use it
11
+
12
+ ```jsonc
13
+ // Claude Desktop / Claude Code / Cursor MCP config
14
+ {
15
+ "mcpServers": {
16
+ "wicker-study": {
17
+ "command": "npx",
18
+ "args": ["-y", "wicker-study-mcp"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Then ask the agent to connect. It calls `wicker_status`, finds no key, calls `wicker_authorize`,
25
+ and gives you a URL to approve in your browser. The key comes straight back to your machine over
26
+ loopback and is saved for every future session.
27
+
28
+ No environment variable is required. Two are honoured when set:
29
+
30
+ | Variable | Meaning |
31
+ | --- | --- |
32
+ | `WICKER_STUDY_URL` | Which server to use. Default `https://study.wicker.life`. Use `http://localhost:4177` for a local development server; plain http is refused for anything else. |
33
+ | `WICKER_STUDY_API_KEY` | Use this key instead of the saved one. Always wins, so a one-off run and CI never pick up a developer's saved key. |
34
+
35
+ ## Where the key lives
36
+
37
+ `~/.config/wicker-study/config.json`, mode `0600` in a `0700` directory, keyed by server URL so a
38
+ local and a production key never overwrite each other. `wicker_sign_out` removes it. Revoke the key
39
+ itself under **Account → API access** in the web app.
40
+
41
+ The key is never printed, never a tool argument, and never sent to a host other than the one it was
42
+ minted for. Wicker Study will only deliver it to a loopback address, so a link that asks for
43
+ anything else is refused by the approval page.
44
+
45
+ ## Canvas
46
+
47
+ Canvas material is reached through the account's own encrypted connection, not through the agent.
48
+ Call `canvas_connect` first: it reports whether the account has a Canvas connection and, if not,
49
+ returns the page where the student pastes their Canvas Personal Access Token — in their browser.
50
+
51
+ **Never ask a user for a Canvas token, password, MFA code, cookie, or session export in chat.** The
52
+ agent receives proxied course data and nothing else.
53
+
54
+ ## Tools
55
+
56
+ `wicker_status`, `wicker_authorize`, `wicker_sign_out`, and `canvas_connect` work without a key —
57
+ they are how a key is obtained. Everything else needs one.
58
+
59
+ - **Reading** — `list_courses`, `get_course`, `get_chapter`, `get_course_outline`, `search_course`,
60
+ `list_materials`, `list_questions`, `get_practice_queue`, `get_progress`, `list_flashcards`,
61
+ `list_due_cards`, `list_mistakes`, `list_mock_sessions`, `get_mock_session`, `get_academic_plan`,
62
+ `list_known_programmes`, `get_calendar`, `get_activity`, `get_account_summary`, `whoami`
63
+ - **Studying** — `submit_answer`, `set_mastery`, `review_card`, `add_to_deck`, `create_flashcard`,
64
+ `review_flashcard`, `resolve_mistake`, `record_chapter_read`, `save_academic_plan`,
65
+ `set_course_visibility`, `join_programme`
66
+ - **Documents and calendars** — `analyze_documents`, `apply_changes`, `preview_calendar`,
67
+ `save_calendar_link`, `sync_calendar_link`, `remove_calendar_link`
68
+ - **Canvas** — `canvas_connect`, `canvas_list_remote_courses`, `canvas_list_remote_course_modules`,
69
+ `canvas_import_remote_course`, `canvas_import_remote_course_set`
70
+ - **Editorial (admin key)** — the `admin_*` family: course editions, source folders, rights review,
71
+ extraction, mapping, generation, artifact review, and publication.
72
+
73
+ `GET /api/agent/manifest` (also exposed as the `wicker-study://manifest` resource) is the
74
+ authoritative list of endpoints and scopes.
75
+
76
+ ## Licence
77
+
78
+ MIT.
package/authorize.mjs ADDED
@@ -0,0 +1,132 @@
1
+ // Getting a key without putting one in a chat transcript.
2
+ //
3
+ // The agent opens a listener bound to loopback, sends the user to /connect with
4
+ // a verifier challenge and that listener's address, and waits. The browser
5
+ // approves; Wicker Study redirects back to loopback with a single-use code; the
6
+ // agent exchanges code + verifier for a key and stores it globally.
7
+ //
8
+ // The key therefore travels browser → loopback → disk. It is never printed,
9
+ // never passed as a tool argument, and never leaves the machine that asked for
10
+ // it — Wicker Study refuses any callback that is not loopback.
11
+
12
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
13
+ import { createServer } from 'node:http'
14
+ import { saveApiKey } from './config.mjs'
15
+
16
+ export const AUTHORIZE_TIMEOUT_MS = 5 * 60_000
17
+
18
+ function base64url(bytes) { return bytes.toString('base64url') }
19
+
20
+ function sameSecret(left, right) {
21
+ const a = Buffer.from(String(left || ''))
22
+ const b = Buffer.from(String(right || ''))
23
+ return a.length === b.length && a.length > 0 && timingSafeEqual(a, b)
24
+ }
25
+
26
+ function page(title, body) {
27
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title><style>
28
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f7f7f4;color:#20263a;
29
+ font:400 15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
30
+ main{max-width:420px;padding:32px;background:#fff;border:1px solid #dfe2ea;border-radius:12px;text-align:center}
31
+ h1{margin:0 0 8px;font-size:19px;letter-spacing:-.02em}p{margin:0;color:#59627b;font-size:13.5px}
32
+ </style></head><body><main><h1>${title}</h1><p>${body}</p></main></body></html>`
33
+ }
34
+
35
+ // Bound to 127.0.0.1 explicitly so nothing on the network can reach it. The
36
+ // caller needs the address straight away and the code much later, so the two
37
+ // are handed back as separate promises.
38
+ export function startCallbackListener({ timeoutMs = AUTHORIZE_TIMEOUT_MS } = {}) {
39
+ const state = base64url(randomBytes(24))
40
+ let resolveCode
41
+ let rejectCode
42
+ const code = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject })
43
+ let settled = false
44
+
45
+ const server = createServer((req, res) => {
46
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
47
+ if (url.pathname !== '/callback') { res.writeHead(404).end(); return }
48
+ if (!sameSecret(url.searchParams.get('state') || '', state)) {
49
+ res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
50
+ res.end(page('That did not match', 'This response did not come from the authorization this agent started. Nothing has been saved.'))
51
+ return
52
+ }
53
+ const failure = url.searchParams.get('error')
54
+ const value = url.searchParams.get('code')
55
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
56
+ res.end(failure || !value
57
+ ? page('Authorization cancelled', 'Nothing was granted. You can close this tab and try again from your agent.')
58
+ : page('Wicker Study is connected', 'You can close this tab and go back to your agent.'))
59
+ settle(failure ? new Error('The authorization was cancelled in the browser.') : null, value)
60
+ })
61
+
62
+ function settle(error, value) {
63
+ if (settled) return
64
+ settled = true
65
+ clearTimeout(timer)
66
+ setTimeout(() => server.close(), 250).unref?.()
67
+ if (error) rejectCode(error); else resolveCode(value)
68
+ }
69
+
70
+ const timer = setTimeout(() => settle(new Error(`No response within ${Math.round(timeoutMs / 60_000)} minutes. Start the authorization again.`)), timeoutMs)
71
+ timer.unref?.()
72
+ server.once('error', (error) => settle(error))
73
+
74
+ const address = new Promise((resolve, reject) => {
75
+ server.once('error', reject)
76
+ server.listen(0, '127.0.0.1', () => {
77
+ const { port } = server.address()
78
+ resolve({ port, redirectUri: `http://127.0.0.1:${port}/callback`, state })
79
+ })
80
+ })
81
+
82
+ return { address, code, state, cancel: () => settle(new Error('The authorization was cancelled.')) }
83
+ }
84
+
85
+ export function authorizationUrl(serverUrl, { name, scopes, challenge, state, redirectUri }) {
86
+ const url = new URL('/connect', serverUrl)
87
+ url.searchParams.set('name', name)
88
+ url.searchParams.set('scopes', scopes.join(','))
89
+ url.searchParams.set('challenge', challenge)
90
+ url.searchParams.set('state', state)
91
+ url.searchParams.set('redirect_uri', redirectUri)
92
+ return url.toString()
93
+ }
94
+
95
+ export function makeVerifier() {
96
+ const verifier = base64url(randomBytes(32))
97
+ return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }
98
+ }
99
+
100
+ export async function exchange(serverUrl, { code, verifier }) {
101
+ const response = await fetch(new URL('/api/agent/authorize/exchange', serverUrl), {
102
+ method: 'POST',
103
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
104
+ body: JSON.stringify({ code, verifier })
105
+ })
106
+ const body = await response.json().catch(() => null)
107
+ if (!response.ok) throw new Error(body?.error || `The authorization could not be completed (HTTP ${response.status}).`)
108
+ if (!body?.apiKey) throw new Error('Wicker Study returned no API key.')
109
+ return body
110
+ }
111
+
112
+ // The whole flow. Returns the loopback URL to show the user immediately, and a
113
+ // promise that settles when they have finished in the browser.
114
+ export function beginAuthorization(serverUrl, { name = 'Agent (MCP)', scopes = ['read', 'write'], timeoutMs = AUTHORIZE_TIMEOUT_MS } = {}) {
115
+ const { verifier, challenge } = makeVerifier()
116
+ const listener = startCallbackListener({ timeoutMs })
117
+ const ready = listener.address.then((address) => ({
118
+ url: authorizationUrl(serverUrl, { name, scopes, challenge, state: address.state, redirectUri: address.redirectUri }),
119
+ redirectUri: address.redirectUri
120
+ }))
121
+ const completed = (async () => {
122
+ await ready
123
+ const granted = await exchange(serverUrl, { code: await listener.code, verifier })
124
+ const saved = await saveApiKey(serverUrl, granted.apiKey, granted)
125
+ return { ...saved, name: granted.name, scopes: granted.scopes, expiresAt: granted.expiresAt }
126
+ })()
127
+ // The caller may only await this much later, or never (the user walked away).
128
+ // Keep an unobserved rejection from taking the process down; the rejection is
129
+ // still delivered to whoever does await it.
130
+ completed.catch(() => {})
131
+ return { ready, completed, cancel: listener.cancel }
132
+ }
package/config.mjs ADDED
@@ -0,0 +1,109 @@
1
+ // Where an authorised key lives once the agent has one.
2
+ //
3
+ // Keys are stored per server URL, so a developer pointing at localhost and the
4
+ // same person pointing at study.wicker.life do not overwrite each other, and so
5
+ // a key is never sent to a host it was not minted for. The file is written
6
+ // 0600 and its directory 0700; the key never appears in a shell history, a
7
+ // project directory, or a chat transcript.
8
+
9
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
10
+ import { homedir } from 'node:os'
11
+ import { dirname, join } from 'node:path'
12
+
13
+ export function configDirectory(env = process.env) {
14
+ const explicit = String(env.WICKER_STUDY_CONFIG_DIR || '').trim()
15
+ if (explicit) return explicit
16
+ const xdg = String(env.XDG_CONFIG_HOME || '').trim()
17
+ return join(xdg || join(homedir(), '.config'), 'wicker-study')
18
+ }
19
+
20
+ export function configPath(env = process.env) {
21
+ return join(configDirectory(env), 'config.json')
22
+ }
23
+
24
+ export function normaliseServerUrl(value) {
25
+ const raw = String(value || '').trim()
26
+ if (!raw) throw new Error('A Wicker Study URL is required.')
27
+ let url
28
+ try { url = new URL(raw) } catch { throw new Error(`"${raw}" is not a valid Wicker Study URL.`) }
29
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('A Wicker Study URL must be http or https.')
30
+ // http is only sensible against a local development server; anything else
31
+ // would put a bearer key on the wire in clear text.
32
+ if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
33
+ throw new Error(`Refusing to use http for ${url.hostname}. Use https, or point at a local development server.`)
34
+ }
35
+ return url.origin
36
+ }
37
+
38
+ async function readConfig(env = process.env) {
39
+ try {
40
+ const parsed = JSON.parse(await readFile(configPath(env), 'utf8'))
41
+ return parsed && typeof parsed === 'object' && parsed.servers && typeof parsed.servers === 'object' ? parsed : { servers: {} }
42
+ } catch {
43
+ return { servers: {} }
44
+ }
45
+ }
46
+
47
+ async function writeConfig(config, env = process.env) {
48
+ const path = configPath(env)
49
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
50
+ await chmod(dirname(path), 0o700).catch(() => {})
51
+ // Write-then-rename so an interrupted save cannot leave a truncated file, and
52
+ // set the mode before the content is in place at its final name.
53
+ const temporary = `${path}.${process.pid}.tmp`
54
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
55
+ await chmod(temporary, 0o600).catch(() => {})
56
+ await rename(temporary, path)
57
+ }
58
+
59
+ export async function savedApiKey(serverUrl, env = process.env) {
60
+ const config = await readConfig(env)
61
+ const entry = config.servers[normaliseServerUrl(serverUrl)]
62
+ return entry && typeof entry.apiKey === 'string' && entry.apiKey ? entry.apiKey : null
63
+ }
64
+
65
+ export async function saveApiKey(serverUrl, apiKey, details = {}, env = process.env) {
66
+ const origin = normaliseServerUrl(serverUrl)
67
+ const key = String(apiKey || '').trim()
68
+ if (!key.startsWith('wsk_')) throw new Error('That does not look like a Wicker Study API key (they start with wsk_).')
69
+ const config = await readConfig(env)
70
+ config.servers[origin] = {
71
+ apiKey: key,
72
+ name: details.name || null,
73
+ scopes: Array.isArray(details.scopes) ? details.scopes : null,
74
+ expiresAt: details.expiresAt || null,
75
+ savedAt: new Date().toISOString()
76
+ }
77
+ await writeConfig(config, env)
78
+ return { server: origin, path: configPath(env) }
79
+ }
80
+
81
+ export async function forgetApiKey(serverUrl, env = process.env) {
82
+ const origin = normaliseServerUrl(serverUrl)
83
+ const config = await readConfig(env)
84
+ if (!config.servers[origin]) return false
85
+ delete config.servers[origin]
86
+ await writeConfig(config, env)
87
+ return true
88
+ }
89
+
90
+ export async function listSavedServers(env = process.env) {
91
+ const config = await readConfig(env)
92
+ // Never return the key itself — only that one exists and what it can do.
93
+ return Object.entries(config.servers).map(([server, entry]) => ({
94
+ server,
95
+ name: entry?.name || null,
96
+ scopes: entry?.scopes || null,
97
+ savedAt: entry?.savedAt || null,
98
+ expiresAt: entry?.expiresAt || null
99
+ }))
100
+ }
101
+
102
+ // The environment always wins, so a one-off `WICKER_STUDY_API_KEY=… npx …`
103
+ // still works and CI never picks up a developer's saved key by accident.
104
+ export async function resolveApiKey(serverUrl, env = process.env) {
105
+ const fromEnv = String(env.WICKER_STUDY_API_KEY || '').trim()
106
+ if (fromEnv) return { apiKey: fromEnv, source: 'environment' }
107
+ const stored = await savedApiKey(serverUrl, env)
108
+ return stored ? { apiKey: stored, source: configPath(env) } : { apiKey: null, source: null }
109
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "wicker-study-mcp",
3
+ "version": "2.0.0",
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 — with an admin key — run the editorial workflow.",
5
+ "keywords": ["mcp", "model-context-protocol", "wicker-study", "canvas-lms", "study", "education"],
6
+ "license": "MIT",
7
+ "author": "David Wicker",
8
+ "homepage": "https://study.wicker.life",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/davidwickerhf/exam-study-platform.git",
12
+ "directory": "mcp"
13
+ },
14
+ "bugs": "https://github.com/davidwickerhf/exam-study-platform/issues",
15
+ "type": "module",
16
+ "bin": {
17
+ "wicker-study-mcp": "./server.mjs"
18
+ },
19
+ "main": "./server.mjs",
20
+ "engines": {
21
+ "node": ">=20.11"
22
+ },
23
+ "files": [
24
+ "server.mjs",
25
+ "config.mjs",
26
+ "authorize.mjs",
27
+ "vendor/",
28
+ "scripts/",
29
+ "README.md"
30
+ ],
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.30.0",
33
+ "zod": "^3.25.76"
34
+ }
35
+ }
@@ -0,0 +1,79 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ struct KeychainRequest: Decodable {
5
+ let operation: String
6
+ let service: String
7
+ let account: String
8
+ let value: String?
9
+ }
10
+
11
+ struct KeychainResponse: Encodable {
12
+ let found: Bool
13
+ let value: String?
14
+ }
15
+
16
+ func write(_ response: KeychainResponse) {
17
+ guard let data = try? JSONEncoder().encode(response) else { exit(1) }
18
+ FileHandle.standardOutput.write(data)
19
+ }
20
+
21
+ func fail(_ status: OSStatus) -> Never {
22
+ let text = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)"
23
+ FileHandle.standardError.write(Data(text.utf8))
24
+ exit(1)
25
+ }
26
+
27
+ let input = FileHandle.standardInput.readDataToEndOfFile()
28
+ guard let request = try? JSONDecoder().decode(KeychainRequest.self, from: input),
29
+ !request.service.isEmpty,
30
+ !request.account.isEmpty else {
31
+ FileHandle.standardError.write(Data("Invalid Keychain request".utf8))
32
+ exit(1)
33
+ }
34
+
35
+ func baseQuery() -> [String: Any] {
36
+ [
37
+ kSecClass as String: kSecClassGenericPassword,
38
+ kSecAttrService as String: request.service,
39
+ kSecAttrAccount as String: request.account
40
+ ]
41
+ }
42
+
43
+ switch request.operation {
44
+ case "get":
45
+ var query = baseQuery()
46
+ query[kSecReturnData as String] = true
47
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
48
+ var result: CFTypeRef?
49
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
50
+ if status == errSecItemNotFound {
51
+ write(KeychainResponse(found: false, value: nil))
52
+ } else if status == errSecSuccess, let data = result as? Data {
53
+ write(KeychainResponse(found: true, value: String(data: data, encoding: .utf8)))
54
+ } else {
55
+ fail(status)
56
+ }
57
+ case "set":
58
+ guard let value = request.value, !value.isEmpty else {
59
+ FileHandle.standardError.write(Data("A Keychain value is required".utf8))
60
+ exit(1)
61
+ }
62
+ let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
63
+ if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound { fail(deleteStatus) }
64
+ var query = baseQuery()
65
+ query[kSecValueData as String] = Data(value.utf8)
66
+ query[kSecAttrLabel as String] = "Wicker Study Canvas token (\(request.account))"
67
+ query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
68
+ let status = SecItemAdd(query as CFDictionary, nil)
69
+ if status == errSecSuccess { write(KeychainResponse(found: true, value: nil)) }
70
+ else { fail(status) }
71
+ case "delete":
72
+ let status = SecItemDelete(baseQuery() as CFDictionary)
73
+ if status == errSecSuccess { write(KeychainResponse(found: true, value: nil)) }
74
+ else if status == errSecItemNotFound { write(KeychainResponse(found: false, value: nil)) }
75
+ else { fail(status) }
76
+ default:
77
+ FileHandle.standardError.write(Data("Unknown Keychain operation".utf8))
78
+ exit(1)
79
+ }