canvas-lms-mcp 2.8.2 → 2.8.3
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/dist/canvas/index.cjs +3 -3
- package/dist/canvas/index.js +2 -2
- package/dist/{chunk-ZT2BWA2J.cjs → chunk-2RKEK44E.cjs} +2 -2
- package/dist/{chunk-ZT2BWA2J.cjs.map → chunk-2RKEK44E.cjs.map} +1 -1
- package/dist/{chunk-HJWLGW54.cjs → chunk-2UETRKXN.cjs} +352 -352
- package/dist/chunk-2UETRKXN.cjs.map +1 -0
- package/dist/{chunk-DEFB5PL2.cjs → chunk-3X2VLRJJ.cjs} +5 -5
- package/dist/{chunk-DEFB5PL2.cjs.map → chunk-3X2VLRJJ.cjs.map} +1 -1
- package/dist/{chunk-F34MMZIS.js → chunk-RNQKGCCJ.js} +2 -2
- package/dist/{chunk-F34MMZIS.js.map → chunk-RNQKGCCJ.js.map} +1 -1
- package/dist/{chunk-YNNREXP7.js → chunk-V52N4FYY.js} +10 -10
- package/dist/chunk-V52N4FYY.js.map +1 -0
- package/dist/{chunk-AFN5BB4O.js → chunk-VVFE5VOC.js} +2 -2
- package/dist/http.cjs +6 -6
- package/dist/http.js +3 -3
- package/dist/init.cjs +3 -3
- package/dist/init.js +1 -1
- package/dist/server.cjs +4 -4
- package/dist/server.js +3 -3
- package/dist/stdio.cjs +4 -4
- package/dist/stdio.js +3 -3
- package/dist/tools/discussions.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-HJWLGW54.cjs.map +0 -1
- package/dist/chunk-YNNREXP7.js.map +0 -1
- /package/dist/{chunk-AFN5BB4O.js.map → chunk-VVFE5VOC.js.map} +0 -0
package/dist/canvas/index.cjs
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
var
|
|
9
|
+
var _chunk3X2VLRJJcjs = require('../chunk-3X2VLRJJ.cjs');
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
var
|
|
15
|
+
var _chunk2RKEK44Ecjs = require('../chunk-2RKEK44E.cjs');
|
|
16
16
|
require('../chunk-AMZAZNZZ.cjs');
|
|
17
17
|
|
|
18
18
|
|
|
@@ -26,5 +26,5 @@ require('../chunk-AMZAZNZZ.cjs');
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
exports.CANVAS_MAX_ID =
|
|
29
|
+
exports.CANVAS_MAX_ID = _chunk3X2VLRJJcjs.CANVAS_MAX_ID; exports.CanvasApiError = _chunk2RKEK44Ecjs.CanvasApiError; exports.CanvasClient = _chunk3X2VLRJJcjs.CanvasClient; exports.CanvasHttpClient = _chunk2RKEK44Ecjs.CanvasHttpClient; exports.MAX_SAFE_CANVAS_NUMBER_ID = _chunk3X2VLRJJcjs.MAX_SAFE_CANVAS_NUMBER_ID; exports.appendCanvasQuery = _chunk2RKEK44Ecjs.appendCanvasQuery; exports.canvasIdInput = _chunk3X2VLRJJcjs.canvasIdInput; exports.canvasIdList = _chunk3X2VLRJJcjs.canvasIdList; exports.compareCanvasIds = _chunk3X2VLRJJcjs.compareCanvasIds; exports.normalizeCanvasIdInput = _chunk3X2VLRJJcjs.normalizeCanvasIdInput; exports.toCanvasQuery = _chunk2RKEK44Ecjs.toCanvasQuery;
|
|
30
30
|
//# sourceMappingURL=index.cjs.map
|
package/dist/canvas/index.js
CHANGED
|
@@ -6,13 +6,13 @@ import {
|
|
|
6
6
|
canvasIdList,
|
|
7
7
|
compareCanvasIds,
|
|
8
8
|
normalizeCanvasIdInput
|
|
9
|
-
} from "../chunk-
|
|
9
|
+
} from "../chunk-VVFE5VOC.js";
|
|
10
10
|
import {
|
|
11
11
|
CanvasApiError,
|
|
12
12
|
CanvasHttpClient,
|
|
13
13
|
appendCanvasQuery,
|
|
14
14
|
toCanvasQuery
|
|
15
|
-
} from "../chunk-
|
|
15
|
+
} from "../chunk-RNQKGCCJ.js";
|
|
16
16
|
import "../chunk-XLORAICV.js";
|
|
17
17
|
export {
|
|
18
18
|
CANVAS_MAX_ID,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
var _chunkAMZAZNZZcjs = require('./chunk-AMZAZNZZ.cjs');
|
|
4
4
|
|
|
5
5
|
// package.json
|
|
6
|
-
var version = "2.8.
|
|
6
|
+
var version = "2.8.3";
|
|
7
7
|
|
|
8
8
|
// src/canvas/query.ts
|
|
9
9
|
function appendCanvasQuery(target, params) {
|
|
@@ -343,4 +343,4 @@ var CanvasHttpClient = class {
|
|
|
343
343
|
|
|
344
344
|
|
|
345
345
|
exports.version = version; exports.appendCanvasQuery = appendCanvasQuery; exports.toCanvasQuery = toCanvasQuery; exports.CanvasApiError = CanvasApiError; exports.CanvasHttpClient = CanvasHttpClient;
|
|
346
|
-
//# sourceMappingURL=chunk-
|
|
346
|
+
//# sourceMappingURL=chunk-2RKEK44E.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/canvas-lms-mcp/canvas-lms-mcp/dist/chunk-ZT2BWA2J.cjs","../package.json","../src/canvas/query.ts","../src/canvas/normalize-ids.ts","../src/canvas/string-ids.ts","../src/canvas/client.ts"],"names":[],"mappings":"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","file":"/home/runner/work/canvas-lms-mcp/canvas-lms-mcp/dist/chunk-ZT2BWA2J.cjs","sourcesContent":[null,"{\n \"name\": \"canvas-lms-mcp\",\n \"version\": \"2.8.2\",\n \"mcpName\": \"io.github.bruchris/canvas-lms-mcp\",\n \"description\": \"TypeScript MCP 1.x server for Canvas LMS — 183 tools across Canvas courses, assignments, grades, gradebook history, quizzes, New Quizzes (LTI), outcomes, discussions, admin workflows, and more.\",\n \"type\": \"module\",\n \"types\": \"./dist/server.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/server.d.ts\",\n \"import\": \"./dist/server.js\",\n \"require\": \"./dist/server.cjs\"\n },\n \"./stdio\": {\n \"types\": \"./dist/stdio.d.ts\",\n \"import\": \"./dist/stdio.js\",\n \"require\": \"./dist/stdio.cjs\"\n },\n \"./http\": {\n \"types\": \"./dist/http.d.ts\",\n \"import\": \"./dist/http.js\",\n \"require\": \"./dist/http.cjs\"\n },\n \"./canvas\": {\n \"types\": \"./dist/canvas/index.d.ts\",\n \"import\": \"./dist/canvas/index.js\",\n \"require\": \"./dist/canvas/index.cjs\"\n }\n },\n \"bin\": {\n \"canvas-lms-mcp\": \"bin/canvas-lms-mcp.js\"\n },\n \"files\": [\n \"bin/\",\n \"dist/\",\n \"skills/\"\n ],\n \"scripts\": {\n \"dev\": \"tsup --watch\",\n \"build\": \"tsup && tsc -p tsconfig.build.json\",\n \"generate:manifests\": \"tsx scripts/generate-manifests.ts\",\n \"generate:prompts\": \"tsx scripts/generate-prompts.ts\",\n \"mcpb:pack\": \"node scripts/pack-mcpb.mjs\",\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"lint\": \"eslint src/ tests/ && prettier --check src/ tests/\",\n \"lint:fix\": \"eslint src/ tests/ --fix && prettier --write src/ tests/\",\n \"typecheck\": \"tsc --noEmit\"\n },\n \"keywords\": [\n \"canvas\",\n \"canvas-lms\",\n \"lms\",\n \"mcp\",\n \"model-context-protocol\",\n \"education\",\n \"grading\",\n \"ai-agent\",\n \"ai-tools\",\n \"claude-desktop\",\n \"cursor\",\n \"vscode\",\n \"instructure\",\n \"education-technology\",\n \"edtech\",\n \"teacher\",\n \"student\",\n \"grading-automation\",\n \"typescript\"\n ],\n \"author\": \"Christian Bru\",\n \"license\": \"MIT\",\n \"publishConfig\": {\n \"access\": \"public\"\n },\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/bruchris/canvas-lms-mcp.git\"\n },\n \"engines\": {\n \"node\": \">=22\"\n },\n \"packageManager\": \"pnpm@10.11.0\",\n \"dependencies\": {\n \"@iarna/toml\": \"^2.2.5\",\n \"@modelcontextprotocol/sdk\": \"^1.32.1\",\n \"prompts\": \"^2.4.2\",\n \"zod\": \"^4.4.3\"\n },\n \"devDependencies\": {\n \"@eslint/js\": \"^10.0.1\",\n \"@types/jsdom\": \"^30.0.0\",\n \"@types/node\": \"^26.4.0\",\n \"@types/prompts\": \"^2.4.9\",\n \"@typescript-eslint/eslint-plugin\": \"^8.65.0\",\n \"@typescript-eslint/parser\": \"^8.65.0\",\n \"@vitest/coverage-v8\": \"^4.1.10\",\n \"ajv\": \"^8.20.0\",\n \"eslint\": \"^10.7.0\",\n \"eslint-config-prettier\": \"^10.1.8\",\n \"eslint-plugin-prettier\": \"^5.5.6\",\n \"jsdom\": \"^30.0.1\",\n \"prettier\": \"^3.9.6\",\n \"tsup\": \"^8.5.1\",\n \"tsx\": \"^4.23.1\",\n \"typescript\": \"^6.0.3\",\n \"typescript-eslint\": \"^8.65.0\",\n \"vite\": \"^8.1.5\",\n \"vitest\": \"^4.1.10\",\n \"yaml\": \"^2.8.3\"\n }\n}\n","export type CanvasQueryPrimitive = string | number | boolean\n\nexport type CanvasQueryValue = CanvasQueryPrimitive | ReadonlyArray<CanvasQueryPrimitive>\n\nexport type CanvasQueryParams = Record<string, CanvasQueryValue | undefined | null>\n\nexport function appendCanvasQuery(target: URLSearchParams, params?: CanvasQueryParams): void {\n if (!params) return\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null) continue\n if (Array.isArray(value)) {\n if (value.length === 0) continue\n const arrayKey = key.endsWith('[]') ? key : `${key}[]`\n for (const item of value) {\n if (item === undefined || item === null) continue\n target.append(arrayKey, String(item))\n }\n } else {\n target.set(key, String(value))\n }\n }\n}\n\nexport function toCanvasQuery(params?: CanvasQueryParams): URLSearchParams {\n const search = new URLSearchParams()\n appendCanvasQuery(search, params)\n return search\n}\n","/**\n * Response-side Canvas identifier normalization.\n *\n * Phase 2, PR 2a of the 64-bit identifier design\n * (`docs/superpowers/specs/2026-10-05-bru-2730-canvas-64bit-identifiers.md`\n * §4.3, BRU-2730 / BRU-2828). Every parsed Canvas response body passes through\n * `normalizeCanvasIds()` on all three `CanvasHttpClient` paths, so that after\n * the HTTP boundary **every identifier in the process is a `CanvasId` string**\n * — the same representation `canvasIdInput()` produces on the way in.\n *\n * ## Why this runs unconditionally, and lands before the Accept header\n *\n * Canvas offers `Accept: application/json+canvas-string-ids`, which stringifies\n * response IDs. On its own it is **not** a drop-in, and the measurement that\n * settles it is a grade:\n *\n * `StringifyIds.recursively_stringify_ids` converts a value only when the key\n * matches `/(^|_)id$/i` (scalar) or `/(^|_)ids$/i` (array). Canvas's\n * `assignment_group.rules.never_drop` holds a list of assignment IDs and\n * matches neither, so the header leaves it numeric while stringifying the\n * sibling `assignment.id` it is compared against. `src/tools/grade-engine.ts`\n * builds `new Set(rules.never_drop)` and tests `has(assignment.id)`, so every\n * pinned assignment silently becomes droppable: on the §2.3.1 fixture the\n * reported course grade moves from **47.5 to 92.5**, with no error and no log\n * line. `explain_grade` and `project_grade` share that engine.\n *\n * So the normalization must be uniform, and it must hold whether or not the\n * header was sent. Then PR 2b's header becomes a pure precision improvement\n * rather than a semantic change, and it can be turned on and off without\n * changing any join. The order is not negotiable: normalization first.\n *\n * ## Why the key policy is Canvas's regexes *plus* an explicit exception list\n *\n * The two regexes are the right base — on today's `types.ts` no field whose\n * name matches `(^|_)ids?$` is a quantity, so the over-match risk on them is\n * zero (§3.1). But they miss 12 identifier fields (§3.2), and those are exactly\n * the ones that fail a join silently. The over-match risk on the *exceptions*\n * is **not** zero, which is why they carry a parent scope (§4.3).\n *\n * `tests/canvas/normalize-ids.test.ts` asserts the inverse of this list\n * directly against `src/canvas/types.ts`: no numeric field may remain that this\n * module would convert. That is the soundness gate — a field this module\n * stringifies while `types.ts` still declares it `number` is a declared type\n * that lies.\n */\n\n/** Canvas's scalar key rule, from `gems/stringify_ids`. */\nconst SCALAR_ID_KEY = /(^|_)id$/i\n\n/** Canvas's array key rule, from `gems/stringify_ids`. */\nconst ARRAY_ID_KEY = /(^|_)ids$/i\n\n/**\n * The parent of a property at the top of a response body, where there is no\n * enclosing key. A distinct value rather than `null` so that an exception\n * declaring it reads as a deliberate choice about a wire shape.\n */\nexport const RESPONSE_ROOT = Symbol('canvas response root')\nexport type ResponseParent = string | typeof RESPONSE_ROOT\n\nexport interface IdFieldException {\n /** The property name, exactly as Canvas spells it on the wire. */\n readonly name: string\n /** `scalar` for a single identifier, `array` for a list of them. */\n readonly kind: 'scalar' | 'array'\n /**\n * The nearest enclosing property names this exception applies under, or\n * `'any'` where the name is unique enough across Canvas's payloads that a\n * scope would add no safety. Array indices are transparent: the parent of a\n * property inside `graders: [{…}]` is `graders`.\n */\n readonly parents: 'any' | readonly ResponseParent[]\n /** Why Canvas's own regexes miss it, and what breaks if it is left numeric. */\n readonly why: string\n}\n\n/**\n * The 12 identifier fields of §3.2, as 11 entries — `links.user` covers both\n * `CanvasOutcomeResult.links.user` and `CanvasOutcomeRollup.links.user`.\n *\n * Exported so the tests can assert a count floor on it: §8 requires that an\n * entry cannot be silently dropped, because dropping one reintroduces a silent\n * join failure rather than a visible error.\n */\nexport const ID_FIELD_EXCEPTIONS: readonly IdFieldException[] = [\n {\n name: 'never_drop',\n kind: 'array',\n parents: 'any',\n why: 'Assignment IDs pinned against `drop_lowest`. The §2.3 grade divergence: left numeric, every pinned assignment silently becomes droppable.',\n },\n {\n name: 'assignment_visibility',\n kind: 'array',\n parents: 'any',\n why: 'User IDs a differentiated assignment is visible to. Left numeric, visibility checks silently come back empty.',\n },\n {\n name: 'assignments',\n // `GradebookHistoryModule.listDays()` returns `[{date, graders: [...]}]`,\n // but `getDay()` returns the grader array *at the response root* with no\n // `graders` key at all. Scoping this to `graders` alone would silently miss\n // `get_gradebook_history_day` — the §4.3 instruction to path-scope this\n // field is right, but one parent is not enough to cover its two call sites.\n kind: 'array',\n parents: [RESPONSE_ROOT, 'graders'],\n why: 'Assignment IDs a grader touched, on `CanvasGradebookHistoryGrader` only. Left numeric, gradebook-history joins silently miss.',\n },\n {\n name: 'courseId',\n kind: 'scalar',\n parents: 'any',\n why: 'Dashboard cards are the one camelCase payload Canvas returns; `(^|_)id$` needs `^id` or `_id`, and `courseId` matches neither.',\n },\n {\n name: 'answer',\n // Scoped to the envelope key `getSubmissionAnswers()` reads, because a bare\n // name match would reach free-text answers in unrelated payloads (§4.3).\n kind: 'scalar',\n parents: ['quiz_submission_questions'],\n why: 'An answer ID for choice question types. See the polymorphism note below — this field is not an identifier for every question type.',\n },\n {\n name: 'course',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome rollup's course ID. The three `links` objects in types.ts are all outcome-related; these member names are far too generic to match unscoped.\",\n },\n {\n name: 'user',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome rollup's and an outcome result's user ID — two field sites, one entry.\",\n },\n {\n name: 'section',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome rollup's course-section ID.\",\n },\n {\n name: 'learning_outcome',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome result's outcome ID. Already `string | number` on the wire, which is itself evidence that Canvas's outcomes endpoints return these as strings on some paths.\",\n },\n {\n name: 'alignment',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome result's alignment ID. Already `string | number`.\",\n },\n {\n name: 'outcome',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome rollup score's outcome ID. Its own siblings `score` and `count` are quantities in the same object — the clearest illustration of why this has to be field-level, not type-level.\",\n },\n]\n\n/**\n * `answer` is the one exception whose field is **polymorphic by question type**,\n * and §3.2's description of it (\"an answer ID for choice questions\") is not the\n * whole story: a `numerical_question` answer is a quantity, and\n * `CanvasQuizSubmissionQuestion` carries no `question_type` to discriminate on.\n *\n * `Number.isInteger` is therefore the only guard available, which bounds the\n * blast radius to *integral* numerical answers: `42.5` survives as a number,\n * `42` becomes `\"42\"`. The field's declared type already admits `string`, its\n * sole consumer (`src/tools/quiz-question-responses.ts`) passes it straight\n * into a text payload without joining on it, and the alternative — dropping it\n * from the list — leaves choice-question answer IDs unjoinable, which is the\n * failure §4.3 exists to remove. Recorded here rather than left implicit.\n */\n\ntype ExceptionIndex = ReadonlyMap<string, 'any' | ReadonlySet<ResponseParent>>\n\nfunction indexExceptions(kind: IdFieldException['kind']): ExceptionIndex {\n const index = new Map<string, 'any' | ReadonlySet<ResponseParent>>()\n for (const exception of ID_FIELD_EXCEPTIONS) {\n if (exception.kind !== kind) continue\n index.set(exception.name, exception.parents === 'any' ? 'any' : new Set(exception.parents))\n }\n return index\n}\n\nconst SCALAR_EXCEPTIONS = indexExceptions('scalar')\nconst ARRAY_EXCEPTIONS = indexExceptions('array')\n\nfunction isExcepted(index: ExceptionIndex, key: string, parent: ResponseParent): boolean {\n const parents = index.get(key)\n if (parents === undefined) return false\n return parents === 'any' || parents.has(parent)\n}\n\n/**\n * Canvas's own conversion guard: only an `Integer` becomes a string. A float\n * under an identifier key is not an identifier, and stringifying it would be a\n * quantity change rather than a representation change.\n */\nfunction normalizeScalar(value: unknown): unknown {\n return typeof value === 'number' && Number.isInteger(value) ? String(value) : value\n}\n\nfunction visit(node: unknown, parent: ResponseParent): void {\n if (Array.isArray(node)) {\n // Array indices are transparent: the members of an object inside\n // `graders: [{…}]` have `graders` as their parent, not an index.\n for (const item of node) {\n if (item !== null && typeof item === 'object') visit(item, parent)\n }\n return\n }\n if (node === null || typeof node !== 'object') return\n\n const record = node as Record<string, unknown>\n for (const key of Object.keys(record)) {\n const value = record[key]\n\n if (typeof value === 'number') {\n if (SCALAR_ID_KEY.test(key) || isExcepted(SCALAR_EXCEPTIONS, key, parent)) {\n record[key] = normalizeScalar(value)\n }\n continue\n }\n\n if (Array.isArray(value)) {\n if (ARRAY_ID_KEY.test(key) || isExcepted(ARRAY_EXCEPTIONS, key, parent)) {\n for (let index = 0; index < value.length; index += 1) {\n value[index] = normalizeScalar(value[index])\n }\n }\n visit(value, key)\n continue\n }\n\n if (value !== null && typeof value === 'object') visit(value, key)\n }\n}\n\n/**\n * Rewrites every Canvas identifier in a parsed response body to its canonical\n * decimal string, **in place**, and returns the same reference.\n *\n * In place because this runs on every response on every path: a structural copy\n * would double the allocation of the largest thing the client handles, and\n * nothing holds a reference to the pre-normalization body (it is the direct\n * result of `response.json()`).\n */\nexport function normalizeCanvasIds<T>(body: T): T {\n visit(body, RESPONSE_ROOT)\n return body\n}\n","/**\n * Opt-in Canvas string-ID response negotiation.\n *\n * Phase 2, PR 2b of the 64-bit identifier design\n * (`docs/superpowers/specs/2026-10-05-bru-2730-canvas-64bit-identifiers.md`\n * §5 / §8, BRU-2730 / BRU-2851), landing strictly after PR 2a's unconditional\n * `normalizeCanvasIds()`.\n *\n * ## What the header buys, and what it cannot\n *\n * `response.json()` is `JSON.parse`, so a Canvas ID above `Number.MAX_SAFE_INTEGER`\n * is already rounded before any code of ours runs: the shard-901 id\n * `9010000000000001` arrives as `9010000000000000`. No amount of normalization\n * recovers a digit that never arrived. Asking Canvas to render identifiers as\n * JSON **strings** is the only fix, and this header is how you ask.\n *\n * It is best-effort on purpose. `ApplicationController#render` applies\n * `json_cast` only `unless json.is_a?(String)`, so an endpoint that renders\n * pre-serialized JSON answers with numbers even when the header was sent.\n * That is exactly why §4.3's normalization runs unconditionally and this is a\n * pure precision improvement layered on top: every join in the process keeps\n * working in both modes, and the flag can be flipped without changing\n * semantics.\n *\n * ## Why the flag comparison is byte-exact\n *\n * This flag changes the wire type of every identifier in every response. Every\n * trim, lowercase or truthiness step widens the set of strings that\n * *accidentally* enable it — `TRUE` out of a YAML file, `1` out of a template,\n * a trailing space out of a paste — and each of those is a plausible accident.\n * So the comparison is `=== 'true'` and nothing else. This deliberately does\n * **not** use `isEnvTruthy()` from `src/env.ts`, which accepts\n * `true`/`1`/`yes`/`on` case-insensitively after trimming.\n */\n\n/** The only environment variable that enables negotiation. */\nexport const CANVAS_STRING_IDS_ENV_VAR = 'CANVAS_STRING_IDS'\n\n/**\n * Canvas gates on `request.headers[\"Accept\"]&.include?(\"application/json+canvas-string-ids\")`\n * — a substring test (`application_controller.rb:2978`, `stringify_json_ids?`).\n * The plain `application/json` fallback therefore costs nothing and keeps the\n * request acceptable to a proxy or endpoint that does not know the vendor type.\n */\nexport const CANVAS_STRING_IDS_ACCEPT = 'application/json+canvas-string-ids, application/json'\n\n/**\n * New Quizzes. `json_cast` is an `ApplicationController` concern and\n * `/api/quiz/v1` is served by the separate New Quizzes service, which does not\n * implement the vendor media type — a code search for `quiz/v1` under Canvas's\n * `config/` returns no routes. Our own types already record that it answers\n * differently: `CanvasNewQuizItem.id` is a `string` while `CanvasNewQuiz.id` is\n * a `number`. Sending the header there negotiates nothing, so excluding it\n * keeps those 11 call sites byte-identical to their pre-flag requests instead\n * of betting on an unverified service (§5, open question 2).\n */\nconst NEW_QUIZZES_PATH_PREFIX = '/api/quiz/v1'\n\n/** Byte-exact, deliberately un-normalized. See the module comment. */\nfunction negotiationEnabled(): boolean {\n return process.env[CANVAS_STRING_IDS_ENV_VAR] === 'true'\n}\n\n/**\n * The `Accept` header to merge into a Canvas request, or `undefined` when this\n * request must not negotiate. Spread it into a header record: `{...undefined}`\n * is `{}`, so a disabled flag adds no key at all and the request stays\n * byte-identical to the pre-flag one.\n *\n * Decided per URL rather than per client so that a `Link` rel=\"next\" URL is\n * judged on its own pathname, and read from the environment per call so the\n * flag has no construction-order dependency.\n *\n * Fails **closed** on a URL that does not parse: no header means numbers,\n * which is the pre-2b status quo, and §4.3 normalization still applies.\n */\nexport function stringIdsAcceptHeader(url: string): { Accept: string } | undefined {\n if (!negotiationEnabled()) return undefined\n\n let pathname: string\n try {\n pathname = new URL(url).pathname\n } catch {\n return undefined\n }\n if (pathname.startsWith(NEW_QUIZZES_PATH_PREFIX)) return undefined\n\n return { Accept: CANVAS_STRING_IDS_ACCEPT }\n}\n","import { version } from '../../package.json'\nimport { MISSING_CREDENTIALS_TOOL_CALL_ERROR } from '../auth/credential-gate'\nimport { normalizeCanvasIds } from './normalize-ids'\nimport { appendCanvasQuery, type CanvasQueryParams } from './query'\nimport { stringIdsAcceptHeader } from './string-ids'\nimport type { CanvasClientConfig, CanvasErrorResponse } from './types'\n\nexport class CanvasApiError extends Error {\n status: number\n endpoint: string\n\n constructor(message: string, status: number, endpoint: string) {\n super(message)\n this.name = 'CanvasApiError'\n this.status = status\n this.endpoint = endpoint\n }\n}\n\nconst DEFAULT_MAX_PAGINATION_PAGES = 1000\nconst USER_AGENT = `canvas-lms-mcp/${version}`\n\nexport interface CanvasRequestOptions extends RequestInit {\n /**\n * Query params to append to the endpoint URL. Arrays produce repeated\n * `key[]=v1&key[]=v2` entries (the convention Canvas uses for `include[]`).\n * Preserves any query string already present on the endpoint.\n */\n query?: CanvasQueryParams\n}\n\nexport class CanvasHttpClient {\n private token: string\n private _baseUrl: string\n private maxPaginationPages: number\n\n constructor(config: CanvasClientConfig) {\n this.token = config.token\n this._baseUrl = config.baseUrl.replace(/\\/+$/, '')\n this.maxPaginationPages = config.maxPaginationPages ?? DEFAULT_MAX_PAGINATION_PAGES\n }\n\n get baseUrl(): string {\n return this._baseUrl\n }\n\n /**\n * Makes a single authenticated request to the Canvas API.\n * Returns `undefined` (typed as `T`) when Canvas responds with 204 No Content,\n * which is the expected response for DELETE operations.\n */\n async request<T>(endpoint: string, options: CanvasRequestOptions = {}): Promise<T> {\n const { query, ...init } = options\n let url = endpoint.startsWith('http') ? endpoint : `${this._baseUrl}${endpoint}`\n if (query) {\n const parsed = new URL(url)\n appendCanvasQuery(parsed.searchParams, query)\n url = parsed.toString()\n }\n\n const method = (init.method ?? 'GET').toUpperCase()\n if (init.body != null && (method === 'GET' || method === 'HEAD')) {\n throw new Error(\n `GET requests must not include a body (Canvas CloudFront CDN rejects them with 403): ${endpoint}`,\n )\n }\n\n const headers: Record<string, string> = this.canvasHeaders(url)\n if (init.body) {\n headers['Content-Type'] = 'application/json'\n }\n\n const response = await fetch(url, {\n ...init,\n headers: {\n ...headers,\n ...init.headers,\n },\n })\n\n if (!response.ok) {\n const body = (await response.json().catch(() => ({}))) as CanvasErrorResponse\n const message =\n body.errors?.[0]?.message ?? body.message ?? `Canvas API error: ${response.status}`\n throw new CanvasApiError(message, response.status, endpoint)\n }\n\n if (response.status === 204) {\n return undefined as T\n }\n\n return normalizeCanvasIds((await response.json()) as T)\n }\n\n async paginate<T>(\n endpoint: string,\n params?: CanvasQueryParams,\n options?: { maxItems?: number },\n ): Promise<T[]> {\n const maxItems = options?.maxItems\n const url = new URL(`${this._baseUrl}${endpoint}`)\n if (!url.searchParams.has('per_page')) {\n url.searchParams.set('per_page', '100')\n }\n appendCanvasQuery(url.searchParams, params)\n\n const results: T[] = []\n let nextUrl: string | null = url.toString()\n let pages = 0\n let stoppedAtMaxItems = false\n\n while (nextUrl && pages < this.maxPaginationPages) {\n // Inside the loop, keyed on `nextUrl`: a followed `Link` rel=\"next\" URL\n // must get the same header block, and is judged on its own pathname.\n const response = await fetch(nextUrl, { headers: this.canvasHeaders(nextUrl) })\n\n if (!response.ok) {\n const body = (await response.json().catch(() => ({}))) as CanvasErrorResponse\n const message =\n body.errors?.[0]?.message ?? body.message ?? `Canvas API error: ${response.status}`\n throw new CanvasApiError(message, response.status, endpoint)\n }\n\n const data = normalizeCanvasIds((await response.json()) as T[])\n results.push(...data)\n pages++\n\n if (maxItems !== undefined && results.length >= maxItems) {\n stoppedAtMaxItems = true\n break\n }\n\n nextUrl = this.parseNextLink(response.headers.get('Link'))\n }\n\n // A deliberate maxItems stop leaves `nextUrl` non-null on purpose — that\n // is not the page-cap truncation assertNotTruncated exists to catch.\n if (!stoppedAtMaxItems) {\n this.assertNotTruncated(nextUrl, endpoint)\n }\n return stoppedAtMaxItems ? results.slice(0, maxItems) : results\n }\n\n async paginateEnvelope<T>(\n endpoint: string,\n envelopeKey: string,\n params?: CanvasQueryParams,\n ): Promise<T[]> {\n const url = new URL(`${this._baseUrl}${endpoint}`)\n if (!url.searchParams.has('per_page')) {\n url.searchParams.set('per_page', '100')\n }\n appendCanvasQuery(url.searchParams, params)\n\n const results: T[] = []\n let nextUrl: string | null = url.toString()\n let pages = 0\n\n while (nextUrl && pages < this.maxPaginationPages) {\n // Inside the loop, keyed on `nextUrl`: a followed `Link` rel=\"next\" URL\n // must get the same header block, and is judged on its own pathname.\n const response = await fetch(nextUrl, { headers: this.canvasHeaders(nextUrl) })\n\n if (!response.ok) {\n const body = (await response.json().catch(() => ({}))) as CanvasErrorResponse\n const message =\n body.errors?.[0]?.message ?? body.message ?? `Canvas API error: ${response.status}`\n throw new CanvasApiError(message, response.status, endpoint)\n }\n\n // Normalized before the envelope is opened, not after: the `answer`\n // exception in `normalize-ids.ts` is scoped to the envelope key it\n // arrives under, so the walk has to see the whole body (§4.3).\n const body = normalizeCanvasIds((await response.json()) as Record<string, T[]>)\n const data = body[envelopeKey] ?? []\n results.push(...data)\n pages++\n\n nextUrl = this.parseNextLink(response.headers.get('Link'))\n }\n\n this.assertNotTruncated(nextUrl, endpoint)\n return results\n }\n\n /**\n * The header block every Canvas request shares. Defining it in one place is\n * what makes \"the string-ID Accept header is on all three response paths\"\n * (BRU-2730 §5) structural rather than three coincidences.\n *\n * Takes the fully-resolved URL because the negotiation decision is per URL:\n * `/api/quiz/v1` is excluded by pathname prefix.\n */\n private canvasHeaders(url: string): Record<string, string> {\n // Credentialless servers never reach the network (GH #442, BRU-3043).\n // The tool layer gates earlier and more legibly; this is the property\n // underneath that convention. Every authenticated Canvas request builds\n // its header block here, and all three call sites evaluate this call\n // *before* invoking `fetch`, so there is no way for a caller the tool\n // wrapper does not cover — a resource read, or an embedder using the\n // standalone client as a library — to issue a tokenless request.\n if (this.token === '') {\n throw new Error(MISSING_CREDENTIALS_TOOL_CALL_ERROR)\n }\n return {\n Authorization: `Bearer ${this.token}`,\n 'User-Agent': USER_AGENT,\n ...stringIdsAcceptHeader(url),\n }\n }\n\n /**\n * The pagination loops exit with a non-null `nextUrl` only when the page cap\n * was reached while Canvas still advertised another page. Surface that as an\n * error rather than returning a partial list that looks complete.\n */\n private assertNotTruncated(nextUrl: string | null, endpoint: string): void {\n if (!nextUrl) return\n throw new Error(\n `Results are incomplete: ${endpoint} has more pages beyond the configured limit of ${this.maxPaginationPages} pages, so the list was truncated. Narrow the query with filters or raise maxPaginationPages.`,\n )\n }\n\n private parseNextLink(linkHeader: string | null): string | null {\n if (!linkHeader) return null\n const match = linkHeader.match(/<([^>]+)>;\\s*rel=\"next\"/)\n return match?.[1] ?? null\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/canvas-lms-mcp/canvas-lms-mcp/dist/chunk-2RKEK44E.cjs","../package.json","../src/canvas/query.ts","../src/canvas/normalize-ids.ts","../src/canvas/string-ids.ts","../src/canvas/client.ts"],"names":[],"mappings":"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","file":"/home/runner/work/canvas-lms-mcp/canvas-lms-mcp/dist/chunk-2RKEK44E.cjs","sourcesContent":[null,"{\n \"name\": \"canvas-lms-mcp\",\n \"version\": \"2.8.3\",\n \"mcpName\": \"io.github.bruchris/canvas-lms-mcp\",\n \"description\": \"TypeScript MCP 1.x server for Canvas LMS — 183 tools across Canvas courses, assignments, grades, gradebook history, quizzes, New Quizzes (LTI), outcomes, discussions, admin workflows, and more.\",\n \"type\": \"module\",\n \"types\": \"./dist/server.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/server.d.ts\",\n \"import\": \"./dist/server.js\",\n \"require\": \"./dist/server.cjs\"\n },\n \"./stdio\": {\n \"types\": \"./dist/stdio.d.ts\",\n \"import\": \"./dist/stdio.js\",\n \"require\": \"./dist/stdio.cjs\"\n },\n \"./http\": {\n \"types\": \"./dist/http.d.ts\",\n \"import\": \"./dist/http.js\",\n \"require\": \"./dist/http.cjs\"\n },\n \"./canvas\": {\n \"types\": \"./dist/canvas/index.d.ts\",\n \"import\": \"./dist/canvas/index.js\",\n \"require\": \"./dist/canvas/index.cjs\"\n }\n },\n \"bin\": {\n \"canvas-lms-mcp\": \"bin/canvas-lms-mcp.js\"\n },\n \"files\": [\n \"bin/\",\n \"dist/\",\n \"skills/\"\n ],\n \"scripts\": {\n \"dev\": \"tsup --watch\",\n \"build\": \"tsup && tsc -p tsconfig.build.json\",\n \"generate:manifests\": \"tsx scripts/generate-manifests.ts\",\n \"generate:prompts\": \"tsx scripts/generate-prompts.ts\",\n \"mcpb:pack\": \"node scripts/pack-mcpb.mjs\",\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"lint\": \"eslint src/ tests/ && prettier --check src/ tests/\",\n \"lint:fix\": \"eslint src/ tests/ --fix && prettier --write src/ tests/\",\n \"typecheck\": \"tsc --noEmit\"\n },\n \"keywords\": [\n \"canvas\",\n \"canvas-lms\",\n \"lms\",\n \"mcp\",\n \"model-context-protocol\",\n \"education\",\n \"grading\",\n \"ai-agent\",\n \"ai-tools\",\n \"claude-desktop\",\n \"cursor\",\n \"vscode\",\n \"instructure\",\n \"education-technology\",\n \"edtech\",\n \"teacher\",\n \"student\",\n \"grading-automation\",\n \"typescript\"\n ],\n \"author\": \"Christian Bru\",\n \"license\": \"MIT\",\n \"publishConfig\": {\n \"access\": \"public\"\n },\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/bruchris/canvas-lms-mcp.git\"\n },\n \"engines\": {\n \"node\": \">=22\"\n },\n \"packageManager\": \"pnpm@10.11.0\",\n \"dependencies\": {\n \"@iarna/toml\": \"^2.2.5\",\n \"@modelcontextprotocol/sdk\": \"^1.32.1\",\n \"prompts\": \"^2.4.2\",\n \"zod\": \"^4.4.3\"\n },\n \"devDependencies\": {\n \"@eslint/js\": \"^10.0.1\",\n \"@types/jsdom\": \"^30.0.0\",\n \"@types/node\": \"^26.4.0\",\n \"@types/prompts\": \"^2.4.9\",\n \"@typescript-eslint/eslint-plugin\": \"^8.65.0\",\n \"@typescript-eslint/parser\": \"^8.65.0\",\n \"@vitest/coverage-v8\": \"^4.1.10\",\n \"ajv\": \"^8.20.0\",\n \"eslint\": \"^10.7.0\",\n \"eslint-config-prettier\": \"^10.1.8\",\n \"eslint-plugin-prettier\": \"^5.5.6\",\n \"jsdom\": \"^30.0.1\",\n \"prettier\": \"^3.9.6\",\n \"tsup\": \"^8.5.1\",\n \"tsx\": \"^4.23.1\",\n \"typescript\": \"^6.0.3\",\n \"typescript-eslint\": \"^8.65.0\",\n \"vite\": \"^8.1.5\",\n \"vitest\": \"^4.1.10\",\n \"yaml\": \"^2.8.3\"\n }\n}\n","export type CanvasQueryPrimitive = string | number | boolean\n\nexport type CanvasQueryValue = CanvasQueryPrimitive | ReadonlyArray<CanvasQueryPrimitive>\n\nexport type CanvasQueryParams = Record<string, CanvasQueryValue | undefined | null>\n\nexport function appendCanvasQuery(target: URLSearchParams, params?: CanvasQueryParams): void {\n if (!params) return\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null) continue\n if (Array.isArray(value)) {\n if (value.length === 0) continue\n const arrayKey = key.endsWith('[]') ? key : `${key}[]`\n for (const item of value) {\n if (item === undefined || item === null) continue\n target.append(arrayKey, String(item))\n }\n } else {\n target.set(key, String(value))\n }\n }\n}\n\nexport function toCanvasQuery(params?: CanvasQueryParams): URLSearchParams {\n const search = new URLSearchParams()\n appendCanvasQuery(search, params)\n return search\n}\n","/**\n * Response-side Canvas identifier normalization.\n *\n * Phase 2, PR 2a of the 64-bit identifier design\n * (`docs/superpowers/specs/2026-10-05-bru-2730-canvas-64bit-identifiers.md`\n * §4.3, BRU-2730 / BRU-2828). Every parsed Canvas response body passes through\n * `normalizeCanvasIds()` on all three `CanvasHttpClient` paths, so that after\n * the HTTP boundary **every identifier in the process is a `CanvasId` string**\n * — the same representation `canvasIdInput()` produces on the way in.\n *\n * ## Why this runs unconditionally, and lands before the Accept header\n *\n * Canvas offers `Accept: application/json+canvas-string-ids`, which stringifies\n * response IDs. On its own it is **not** a drop-in, and the measurement that\n * settles it is a grade:\n *\n * `StringifyIds.recursively_stringify_ids` converts a value only when the key\n * matches `/(^|_)id$/i` (scalar) or `/(^|_)ids$/i` (array). Canvas's\n * `assignment_group.rules.never_drop` holds a list of assignment IDs and\n * matches neither, so the header leaves it numeric while stringifying the\n * sibling `assignment.id` it is compared against. `src/tools/grade-engine.ts`\n * builds `new Set(rules.never_drop)` and tests `has(assignment.id)`, so every\n * pinned assignment silently becomes droppable: on the §2.3.1 fixture the\n * reported course grade moves from **47.5 to 92.5**, with no error and no log\n * line. `explain_grade` and `project_grade` share that engine.\n *\n * So the normalization must be uniform, and it must hold whether or not the\n * header was sent. Then PR 2b's header becomes a pure precision improvement\n * rather than a semantic change, and it can be turned on and off without\n * changing any join. The order is not negotiable: normalization first.\n *\n * ## Why the key policy is Canvas's regexes *plus* an explicit exception list\n *\n * The two regexes are the right base — on today's `types.ts` no field whose\n * name matches `(^|_)ids?$` is a quantity, so the over-match risk on them is\n * zero (§3.1). But they miss 12 identifier fields (§3.2), and those are exactly\n * the ones that fail a join silently. The over-match risk on the *exceptions*\n * is **not** zero, which is why they carry a parent scope (§4.3).\n *\n * `tests/canvas/normalize-ids.test.ts` asserts the inverse of this list\n * directly against `src/canvas/types.ts`: no numeric field may remain that this\n * module would convert. That is the soundness gate — a field this module\n * stringifies while `types.ts` still declares it `number` is a declared type\n * that lies.\n */\n\n/** Canvas's scalar key rule, from `gems/stringify_ids`. */\nconst SCALAR_ID_KEY = /(^|_)id$/i\n\n/** Canvas's array key rule, from `gems/stringify_ids`. */\nconst ARRAY_ID_KEY = /(^|_)ids$/i\n\n/**\n * The parent of a property at the top of a response body, where there is no\n * enclosing key. A distinct value rather than `null` so that an exception\n * declaring it reads as a deliberate choice about a wire shape.\n */\nexport const RESPONSE_ROOT = Symbol('canvas response root')\nexport type ResponseParent = string | typeof RESPONSE_ROOT\n\nexport interface IdFieldException {\n /** The property name, exactly as Canvas spells it on the wire. */\n readonly name: string\n /** `scalar` for a single identifier, `array` for a list of them. */\n readonly kind: 'scalar' | 'array'\n /**\n * The nearest enclosing property names this exception applies under, or\n * `'any'` where the name is unique enough across Canvas's payloads that a\n * scope would add no safety. Array indices are transparent: the parent of a\n * property inside `graders: [{…}]` is `graders`.\n */\n readonly parents: 'any' | readonly ResponseParent[]\n /** Why Canvas's own regexes miss it, and what breaks if it is left numeric. */\n readonly why: string\n}\n\n/**\n * The 12 identifier fields of §3.2, as 11 entries — `links.user` covers both\n * `CanvasOutcomeResult.links.user` and `CanvasOutcomeRollup.links.user`.\n *\n * Exported so the tests can assert a count floor on it: §8 requires that an\n * entry cannot be silently dropped, because dropping one reintroduces a silent\n * join failure rather than a visible error.\n */\nexport const ID_FIELD_EXCEPTIONS: readonly IdFieldException[] = [\n {\n name: 'never_drop',\n kind: 'array',\n parents: 'any',\n why: 'Assignment IDs pinned against `drop_lowest`. The §2.3 grade divergence: left numeric, every pinned assignment silently becomes droppable.',\n },\n {\n name: 'assignment_visibility',\n kind: 'array',\n parents: 'any',\n why: 'User IDs a differentiated assignment is visible to. Left numeric, visibility checks silently come back empty.',\n },\n {\n name: 'assignments',\n // `GradebookHistoryModule.listDays()` returns `[{date, graders: [...]}]`,\n // but `getDay()` returns the grader array *at the response root* with no\n // `graders` key at all. Scoping this to `graders` alone would silently miss\n // `get_gradebook_history_day` — the §4.3 instruction to path-scope this\n // field is right, but one parent is not enough to cover its two call sites.\n kind: 'array',\n parents: [RESPONSE_ROOT, 'graders'],\n why: 'Assignment IDs a grader touched, on `CanvasGradebookHistoryGrader` only. Left numeric, gradebook-history joins silently miss.',\n },\n {\n name: 'courseId',\n kind: 'scalar',\n parents: 'any',\n why: 'Dashboard cards are the one camelCase payload Canvas returns; `(^|_)id$` needs `^id` or `_id`, and `courseId` matches neither.',\n },\n {\n name: 'answer',\n // Scoped to the envelope key `getSubmissionAnswers()` reads, because a bare\n // name match would reach free-text answers in unrelated payloads (§4.3).\n kind: 'scalar',\n parents: ['quiz_submission_questions'],\n why: 'An answer ID for choice question types. See the polymorphism note below — this field is not an identifier for every question type.',\n },\n {\n name: 'course',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome rollup's course ID. The three `links` objects in types.ts are all outcome-related; these member names are far too generic to match unscoped.\",\n },\n {\n name: 'user',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome rollup's and an outcome result's user ID — two field sites, one entry.\",\n },\n {\n name: 'section',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome rollup's course-section ID.\",\n },\n {\n name: 'learning_outcome',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome result's outcome ID. Already `string | number` on the wire, which is itself evidence that Canvas's outcomes endpoints return these as strings on some paths.\",\n },\n {\n name: 'alignment',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome result's alignment ID. Already `string | number`.\",\n },\n {\n name: 'outcome',\n kind: 'scalar',\n parents: ['links'],\n why: \"An outcome rollup score's outcome ID. Its own siblings `score` and `count` are quantities in the same object — the clearest illustration of why this has to be field-level, not type-level.\",\n },\n]\n\n/**\n * `answer` is the one exception whose field is **polymorphic by question type**,\n * and §3.2's description of it (\"an answer ID for choice questions\") is not the\n * whole story: a `numerical_question` answer is a quantity, and\n * `CanvasQuizSubmissionQuestion` carries no `question_type` to discriminate on.\n *\n * `Number.isInteger` is therefore the only guard available, which bounds the\n * blast radius to *integral* numerical answers: `42.5` survives as a number,\n * `42` becomes `\"42\"`. The field's declared type already admits `string`, its\n * sole consumer (`src/tools/quiz-question-responses.ts`) passes it straight\n * into a text payload without joining on it, and the alternative — dropping it\n * from the list — leaves choice-question answer IDs unjoinable, which is the\n * failure §4.3 exists to remove. Recorded here rather than left implicit.\n */\n\ntype ExceptionIndex = ReadonlyMap<string, 'any' | ReadonlySet<ResponseParent>>\n\nfunction indexExceptions(kind: IdFieldException['kind']): ExceptionIndex {\n const index = new Map<string, 'any' | ReadonlySet<ResponseParent>>()\n for (const exception of ID_FIELD_EXCEPTIONS) {\n if (exception.kind !== kind) continue\n index.set(exception.name, exception.parents === 'any' ? 'any' : new Set(exception.parents))\n }\n return index\n}\n\nconst SCALAR_EXCEPTIONS = indexExceptions('scalar')\nconst ARRAY_EXCEPTIONS = indexExceptions('array')\n\nfunction isExcepted(index: ExceptionIndex, key: string, parent: ResponseParent): boolean {\n const parents = index.get(key)\n if (parents === undefined) return false\n return parents === 'any' || parents.has(parent)\n}\n\n/**\n * Canvas's own conversion guard: only an `Integer` becomes a string. A float\n * under an identifier key is not an identifier, and stringifying it would be a\n * quantity change rather than a representation change.\n */\nfunction normalizeScalar(value: unknown): unknown {\n return typeof value === 'number' && Number.isInteger(value) ? String(value) : value\n}\n\nfunction visit(node: unknown, parent: ResponseParent): void {\n if (Array.isArray(node)) {\n // Array indices are transparent: the members of an object inside\n // `graders: [{…}]` have `graders` as their parent, not an index.\n for (const item of node) {\n if (item !== null && typeof item === 'object') visit(item, parent)\n }\n return\n }\n if (node === null || typeof node !== 'object') return\n\n const record = node as Record<string, unknown>\n for (const key of Object.keys(record)) {\n const value = record[key]\n\n if (typeof value === 'number') {\n if (SCALAR_ID_KEY.test(key) || isExcepted(SCALAR_EXCEPTIONS, key, parent)) {\n record[key] = normalizeScalar(value)\n }\n continue\n }\n\n if (Array.isArray(value)) {\n if (ARRAY_ID_KEY.test(key) || isExcepted(ARRAY_EXCEPTIONS, key, parent)) {\n for (let index = 0; index < value.length; index += 1) {\n value[index] = normalizeScalar(value[index])\n }\n }\n visit(value, key)\n continue\n }\n\n if (value !== null && typeof value === 'object') visit(value, key)\n }\n}\n\n/**\n * Rewrites every Canvas identifier in a parsed response body to its canonical\n * decimal string, **in place**, and returns the same reference.\n *\n * In place because this runs on every response on every path: a structural copy\n * would double the allocation of the largest thing the client handles, and\n * nothing holds a reference to the pre-normalization body (it is the direct\n * result of `response.json()`).\n */\nexport function normalizeCanvasIds<T>(body: T): T {\n visit(body, RESPONSE_ROOT)\n return body\n}\n","/**\n * Opt-in Canvas string-ID response negotiation.\n *\n * Phase 2, PR 2b of the 64-bit identifier design\n * (`docs/superpowers/specs/2026-10-05-bru-2730-canvas-64bit-identifiers.md`\n * §5 / §8, BRU-2730 / BRU-2851), landing strictly after PR 2a's unconditional\n * `normalizeCanvasIds()`.\n *\n * ## What the header buys, and what it cannot\n *\n * `response.json()` is `JSON.parse`, so a Canvas ID above `Number.MAX_SAFE_INTEGER`\n * is already rounded before any code of ours runs: the shard-901 id\n * `9010000000000001` arrives as `9010000000000000`. No amount of normalization\n * recovers a digit that never arrived. Asking Canvas to render identifiers as\n * JSON **strings** is the only fix, and this header is how you ask.\n *\n * It is best-effort on purpose. `ApplicationController#render` applies\n * `json_cast` only `unless json.is_a?(String)`, so an endpoint that renders\n * pre-serialized JSON answers with numbers even when the header was sent.\n * That is exactly why §4.3's normalization runs unconditionally and this is a\n * pure precision improvement layered on top: every join in the process keeps\n * working in both modes, and the flag can be flipped without changing\n * semantics.\n *\n * ## Why the flag comparison is byte-exact\n *\n * This flag changes the wire type of every identifier in every response. Every\n * trim, lowercase or truthiness step widens the set of strings that\n * *accidentally* enable it — `TRUE` out of a YAML file, `1` out of a template,\n * a trailing space out of a paste — and each of those is a plausible accident.\n * So the comparison is `=== 'true'` and nothing else. This deliberately does\n * **not** use `isEnvTruthy()` from `src/env.ts`, which accepts\n * `true`/`1`/`yes`/`on` case-insensitively after trimming.\n */\n\n/** The only environment variable that enables negotiation. */\nexport const CANVAS_STRING_IDS_ENV_VAR = 'CANVAS_STRING_IDS'\n\n/**\n * Canvas gates on `request.headers[\"Accept\"]&.include?(\"application/json+canvas-string-ids\")`\n * — a substring test (`application_controller.rb:2978`, `stringify_json_ids?`).\n * The plain `application/json` fallback therefore costs nothing and keeps the\n * request acceptable to a proxy or endpoint that does not know the vendor type.\n */\nexport const CANVAS_STRING_IDS_ACCEPT = 'application/json+canvas-string-ids, application/json'\n\n/**\n * New Quizzes. `json_cast` is an `ApplicationController` concern and\n * `/api/quiz/v1` is served by the separate New Quizzes service, which does not\n * implement the vendor media type — a code search for `quiz/v1` under Canvas's\n * `config/` returns no routes. Our own types already record that it answers\n * differently: `CanvasNewQuizItem.id` is a `string` while `CanvasNewQuiz.id` is\n * a `number`. Sending the header there negotiates nothing, so excluding it\n * keeps those 11 call sites byte-identical to their pre-flag requests instead\n * of betting on an unverified service (§5, open question 2).\n */\nconst NEW_QUIZZES_PATH_PREFIX = '/api/quiz/v1'\n\n/** Byte-exact, deliberately un-normalized. See the module comment. */\nfunction negotiationEnabled(): boolean {\n return process.env[CANVAS_STRING_IDS_ENV_VAR] === 'true'\n}\n\n/**\n * The `Accept` header to merge into a Canvas request, or `undefined` when this\n * request must not negotiate. Spread it into a header record: `{...undefined}`\n * is `{}`, so a disabled flag adds no key at all and the request stays\n * byte-identical to the pre-flag one.\n *\n * Decided per URL rather than per client so that a `Link` rel=\"next\" URL is\n * judged on its own pathname, and read from the environment per call so the\n * flag has no construction-order dependency.\n *\n * Fails **closed** on a URL that does not parse: no header means numbers,\n * which is the pre-2b status quo, and §4.3 normalization still applies.\n */\nexport function stringIdsAcceptHeader(url: string): { Accept: string } | undefined {\n if (!negotiationEnabled()) return undefined\n\n let pathname: string\n try {\n pathname = new URL(url).pathname\n } catch {\n return undefined\n }\n if (pathname.startsWith(NEW_QUIZZES_PATH_PREFIX)) return undefined\n\n return { Accept: CANVAS_STRING_IDS_ACCEPT }\n}\n","import { version } from '../../package.json'\nimport { MISSING_CREDENTIALS_TOOL_CALL_ERROR } from '../auth/credential-gate'\nimport { normalizeCanvasIds } from './normalize-ids'\nimport { appendCanvasQuery, type CanvasQueryParams } from './query'\nimport { stringIdsAcceptHeader } from './string-ids'\nimport type { CanvasClientConfig, CanvasErrorResponse } from './types'\n\nexport class CanvasApiError extends Error {\n status: number\n endpoint: string\n\n constructor(message: string, status: number, endpoint: string) {\n super(message)\n this.name = 'CanvasApiError'\n this.status = status\n this.endpoint = endpoint\n }\n}\n\nconst DEFAULT_MAX_PAGINATION_PAGES = 1000\nconst USER_AGENT = `canvas-lms-mcp/${version}`\n\nexport interface CanvasRequestOptions extends RequestInit {\n /**\n * Query params to append to the endpoint URL. Arrays produce repeated\n * `key[]=v1&key[]=v2` entries (the convention Canvas uses for `include[]`).\n * Preserves any query string already present on the endpoint.\n */\n query?: CanvasQueryParams\n}\n\nexport class CanvasHttpClient {\n private token: string\n private _baseUrl: string\n private maxPaginationPages: number\n\n constructor(config: CanvasClientConfig) {\n this.token = config.token\n this._baseUrl = config.baseUrl.replace(/\\/+$/, '')\n this.maxPaginationPages = config.maxPaginationPages ?? DEFAULT_MAX_PAGINATION_PAGES\n }\n\n get baseUrl(): string {\n return this._baseUrl\n }\n\n /**\n * Makes a single authenticated request to the Canvas API.\n * Returns `undefined` (typed as `T`) when Canvas responds with 204 No Content,\n * which is the expected response for DELETE operations.\n */\n async request<T>(endpoint: string, options: CanvasRequestOptions = {}): Promise<T> {\n const { query, ...init } = options\n let url = endpoint.startsWith('http') ? endpoint : `${this._baseUrl}${endpoint}`\n if (query) {\n const parsed = new URL(url)\n appendCanvasQuery(parsed.searchParams, query)\n url = parsed.toString()\n }\n\n const method = (init.method ?? 'GET').toUpperCase()\n if (init.body != null && (method === 'GET' || method === 'HEAD')) {\n throw new Error(\n `GET requests must not include a body (Canvas CloudFront CDN rejects them with 403): ${endpoint}`,\n )\n }\n\n const headers: Record<string, string> = this.canvasHeaders(url)\n if (init.body) {\n headers['Content-Type'] = 'application/json'\n }\n\n const response = await fetch(url, {\n ...init,\n headers: {\n ...headers,\n ...init.headers,\n },\n })\n\n if (!response.ok) {\n const body = (await response.json().catch(() => ({}))) as CanvasErrorResponse\n const message =\n body.errors?.[0]?.message ?? body.message ?? `Canvas API error: ${response.status}`\n throw new CanvasApiError(message, response.status, endpoint)\n }\n\n if (response.status === 204) {\n return undefined as T\n }\n\n return normalizeCanvasIds((await response.json()) as T)\n }\n\n async paginate<T>(\n endpoint: string,\n params?: CanvasQueryParams,\n options?: { maxItems?: number },\n ): Promise<T[]> {\n const maxItems = options?.maxItems\n const url = new URL(`${this._baseUrl}${endpoint}`)\n if (!url.searchParams.has('per_page')) {\n url.searchParams.set('per_page', '100')\n }\n appendCanvasQuery(url.searchParams, params)\n\n const results: T[] = []\n let nextUrl: string | null = url.toString()\n let pages = 0\n let stoppedAtMaxItems = false\n\n while (nextUrl && pages < this.maxPaginationPages) {\n // Inside the loop, keyed on `nextUrl`: a followed `Link` rel=\"next\" URL\n // must get the same header block, and is judged on its own pathname.\n const response = await fetch(nextUrl, { headers: this.canvasHeaders(nextUrl) })\n\n if (!response.ok) {\n const body = (await response.json().catch(() => ({}))) as CanvasErrorResponse\n const message =\n body.errors?.[0]?.message ?? body.message ?? `Canvas API error: ${response.status}`\n throw new CanvasApiError(message, response.status, endpoint)\n }\n\n const data = normalizeCanvasIds((await response.json()) as T[])\n results.push(...data)\n pages++\n\n if (maxItems !== undefined && results.length >= maxItems) {\n stoppedAtMaxItems = true\n break\n }\n\n nextUrl = this.parseNextLink(response.headers.get('Link'))\n }\n\n // A deliberate maxItems stop leaves `nextUrl` non-null on purpose — that\n // is not the page-cap truncation assertNotTruncated exists to catch.\n if (!stoppedAtMaxItems) {\n this.assertNotTruncated(nextUrl, endpoint)\n }\n return stoppedAtMaxItems ? results.slice(0, maxItems) : results\n }\n\n async paginateEnvelope<T>(\n endpoint: string,\n envelopeKey: string,\n params?: CanvasQueryParams,\n ): Promise<T[]> {\n const url = new URL(`${this._baseUrl}${endpoint}`)\n if (!url.searchParams.has('per_page')) {\n url.searchParams.set('per_page', '100')\n }\n appendCanvasQuery(url.searchParams, params)\n\n const results: T[] = []\n let nextUrl: string | null = url.toString()\n let pages = 0\n\n while (nextUrl && pages < this.maxPaginationPages) {\n // Inside the loop, keyed on `nextUrl`: a followed `Link` rel=\"next\" URL\n // must get the same header block, and is judged on its own pathname.\n const response = await fetch(nextUrl, { headers: this.canvasHeaders(nextUrl) })\n\n if (!response.ok) {\n const body = (await response.json().catch(() => ({}))) as CanvasErrorResponse\n const message =\n body.errors?.[0]?.message ?? body.message ?? `Canvas API error: ${response.status}`\n throw new CanvasApiError(message, response.status, endpoint)\n }\n\n // Normalized before the envelope is opened, not after: the `answer`\n // exception in `normalize-ids.ts` is scoped to the envelope key it\n // arrives under, so the walk has to see the whole body (§4.3).\n const body = normalizeCanvasIds((await response.json()) as Record<string, T[]>)\n const data = body[envelopeKey] ?? []\n results.push(...data)\n pages++\n\n nextUrl = this.parseNextLink(response.headers.get('Link'))\n }\n\n this.assertNotTruncated(nextUrl, endpoint)\n return results\n }\n\n /**\n * The header block every Canvas request shares. Defining it in one place is\n * what makes \"the string-ID Accept header is on all three response paths\"\n * (BRU-2730 §5) structural rather than three coincidences.\n *\n * Takes the fully-resolved URL because the negotiation decision is per URL:\n * `/api/quiz/v1` is excluded by pathname prefix.\n */\n private canvasHeaders(url: string): Record<string, string> {\n // Credentialless servers never reach the network (GH #442, BRU-3043).\n // The tool layer gates earlier and more legibly; this is the property\n // underneath that convention. Every authenticated Canvas request builds\n // its header block here, and all three call sites evaluate this call\n // *before* invoking `fetch`, so there is no way for a caller the tool\n // wrapper does not cover — a resource read, or an embedder using the\n // standalone client as a library — to issue a tokenless request.\n if (this.token === '') {\n throw new Error(MISSING_CREDENTIALS_TOOL_CALL_ERROR)\n }\n return {\n Authorization: `Bearer ${this.token}`,\n 'User-Agent': USER_AGENT,\n ...stringIdsAcceptHeader(url),\n }\n }\n\n /**\n * The pagination loops exit with a non-null `nextUrl` only when the page cap\n * was reached while Canvas still advertised another page. Surface that as an\n * error rather than returning a partial list that looks complete.\n */\n private assertNotTruncated(nextUrl: string | null, endpoint: string): void {\n if (!nextUrl) return\n throw new Error(\n `Results are incomplete: ${endpoint} has more pages beyond the configured limit of ${this.maxPaginationPages} pages, so the list was truncated. Narrow the query with filters or raise maxPaginationPages.`,\n )\n }\n\n private parseNextLink(linkHeader: string | null): string | null {\n if (!linkHeader) return null\n const match = linkHeader.match(/<([^>]+)>;\\s*rel=\"next\"/)\n return match?.[1] ?? null\n }\n}\n"]}
|