From 633c7893385f6194308baea4001e5911077c9a65 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 15 Jan 2026 15:38:09 -0600 Subject: [PATCH] feat(mcp): add worksheet tools and resources Add MCP tools for worksheet generation: - generate_worksheet: Create worksheets with configurable difficulty - get_worksheet_info: Get info about existing worksheets - list_difficulty_profiles: List available difficulty presets Add MCP Resources for worksheet documentation: - docs://worksheet/regrouping - Carrying/borrowing pedagogy - docs://worksheet/scaffolding - Visual aids configuration - docs://worksheet/difficulty-profiles - The 6 preset profiles - docs://worksheet/digit-range - Min/max digit settings - docs://worksheet/operators - Addition/subtraction/mixed Includes 47 unit tests for tools, resources, and download endpoint. Co-Authored-By: Claude Opus 4.5 --- apps/web/docs/mcp.md | 277 +++++++++++ .../docs/mcp/worksheet/difficulty-profiles.md | 144 ++++++ apps/web/docs/mcp/worksheet/digit-range.md | 65 +++ apps/web/docs/mcp/worksheet/operators.md | 81 ++++ apps/web/docs/mcp/worksheet/regrouping.md | 60 +++ apps/web/docs/mcp/worksheet/scaffolding.md | 129 +++++ apps/web/src/app/api/mcp/route.ts | 64 +++ .../app/api/worksheets/download/[id]/route.ts | 153 ++++++ .../download/__tests__/route.test.ts | 188 ++++++++ .../src/lib/mcp/__tests__/resources.test.ts | 150 ++++++ .../lib/mcp/__tests__/worksheet-tools.test.ts | 440 ++++++++++++++++++ apps/web/src/lib/mcp/resources.ts | 126 +++++ apps/web/src/lib/mcp/tools.ts | 323 ++++++++++++- 13 files changed, 2199 insertions(+), 1 deletion(-) create mode 100644 apps/web/docs/mcp/worksheet/difficulty-profiles.md create mode 100644 apps/web/docs/mcp/worksheet/digit-range.md create mode 100644 apps/web/docs/mcp/worksheet/operators.md create mode 100644 apps/web/docs/mcp/worksheet/regrouping.md create mode 100644 apps/web/docs/mcp/worksheet/scaffolding.md create mode 100644 apps/web/src/app/api/worksheets/download/[id]/route.ts create mode 100644 apps/web/src/app/api/worksheets/download/__tests__/route.test.ts create mode 100644 apps/web/src/lib/mcp/__tests__/resources.test.ts create mode 100644 apps/web/src/lib/mcp/__tests__/worksheet-tools.test.ts create mode 100644 apps/web/src/lib/mcp/resources.ts diff --git a/apps/web/docs/mcp.md b/apps/web/docs/mcp.md index e8bd4e81..ac224885 100644 --- a/apps/web/docs/mcp.md +++ b/apps/web/docs/mcp.md @@ -397,6 +397,182 @@ Returns: --- +## Worksheet Generation Tools + +These tools allow you to create and manage math worksheets with configurable difficulty and scaffolding. + +### `generate_worksheet` +Create a math worksheet with configurable difficulty, scaffolding, and layout. + +```json +{ + "name": "generate_worksheet", + "arguments": { + "operator": "addition", + "digit_range": { "min": 2, "max": 3 }, + "problems_per_page": 20, + "pages": 2, + "difficulty_profile": "earlyLearner", + "include_answer_key": true, + "title": "Morning Practice", + "orientation": "landscape", + "cols": 5 + } +} +``` + +**Parameters:** +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `operator` | No | `addition` | `"addition"`, `"subtraction"`, or `"mixed"` | +| `digit_range` | No | `{min: 2, max: 2}` | Min/max digits per number (1-5) | +| `problems_per_page` | No | `20` | Problems per page (1-40) | +| `pages` | No | `1` | Number of pages (1-20) | +| `difficulty_profile` | No | `earlyLearner` | Preset difficulty (see `list_difficulty_profiles`) | +| `include_answer_key` | No | `false` | Add answer key pages at end | +| `title` | No | - | Optional worksheet title | +| `orientation` | No | `landscape` | `"portrait"` or `"landscape"` | +| `cols` | No | `5` | Number of columns (1-6) | + +Returns: +```json +{ + "shareId": "aBc123X", + "shareUrl": "https://abaci.one/worksheets/shared/aBc123X", + "downloadUrl": "https://abaci.one/api/worksheets/download/aBc123X", + "summary": { + "shareId": "aBc123X", + "operator": "addition", + "digitRange": { "min": 2, "max": 3 }, + "totalProblems": 40, + "pages": 2, + "problemsPerPage": 20, + "cols": 5, + "orientation": "landscape", + "difficultyProfile": "earlyLearner", + "difficultyLabel": "Early Learner", + "regroupingPercent": 25, + "includeAnswerKey": true, + "scaffolding": { + "carryBoxes": "whenRegrouping", + "answerBoxes": "always", + "placeValueColors": "always", + "tenFrames": "whenRegrouping" + } + } +} +``` + +### `get_worksheet_info` +Get information about an existing shared worksheet. + +```json +{ + "name": "get_worksheet_info", + "arguments": { + "share_id": "aBc123X" + } +} +``` + +Returns: +```json +{ + "shareId": "aBc123X", + "shareUrl": "https://abaci.one/worksheets/shared/aBc123X", + "downloadUrl": "https://abaci.one/api/worksheets/download/aBc123X", + "title": "Morning Practice", + "worksheetType": "addition", + "createdAt": "2026-01-15T10:30:00Z", + "views": 5, + "config": { + "operator": "addition", + "digitRange": { "min": 2, "max": 3 }, + "totalProblems": 40, + "pages": 2, + "problemsPerPage": 20, + "cols": 5, + "orientation": "landscape", + "difficultyProfile": "earlyLearner", + "difficultyLabel": "Early Learner", + "regroupingPercent": 25, + "includeAnswerKey": true + } +} +``` + +### `list_difficulty_profiles` +List all available difficulty profiles with their settings. + +```json +{ + "name": "list_difficulty_profiles", + "arguments": {} +} +``` + +Returns: +```json +{ + "profiles": [ + { + "name": "beginner", + "label": "Beginner", + "description": "Full scaffolding with no regrouping. Focus on learning the structure of addition.", + "regrouping": { + "pAnyStart": 0, + "pAllStart": 0, + "percent": 0 + }, + "scaffolding": { + "carryBoxes": "always", + "answerBoxes": "always", + "placeValueColors": "always", + "tenFrames": "always", + "borrowNotation": "always", + "borrowingHints": "always" + } + }, + { + "name": "earlyLearner", + "label": "Early Learner", + "description": "Scaffolds appear when needed. Introduces occasional regrouping.", + "regrouping": { + "pAnyStart": 0.25, + "pAllStart": 0, + "percent": 25 + }, + "scaffolding": { + "carryBoxes": "whenRegrouping", + "answerBoxes": "always", + "placeValueColors": "always", + "tenFrames": "whenRegrouping", + "borrowNotation": "whenRegrouping", + "borrowingHints": "always" + } + } + ], + "progression": ["beginner", "earlyLearner", "practice", "intermediate", "advanced", "expert"] +} +``` + +**Difficulty Profile Progression:** +1. `beginner` - Full scaffolding, no regrouping +2. `earlyLearner` - Conditional scaffolding, 25% regrouping +3. `practice` - High scaffolding, 75% regrouping (master WITH support) +4. `intermediate` - Reduced scaffolding, 75% regrouping +5. `advanced` - Minimal scaffolding, 90% regrouping +6. `expert` - No scaffolding, 90% regrouping + +**Scaffolding Values:** +- `always` - Always show this scaffolding element +- `never` - Never show this scaffolding element +- `whenRegrouping` - Show only when problem requires regrouping +- `whenMultipleRegroups` - Show only when problem has multiple regroups +- `when3PlusDigits` - Show only when problem has 3+ digits + +--- + ## API Key Management ### List Keys @@ -450,3 +626,104 @@ Supported methods: - `initialize` - Capability negotiation - `tools/list` - List available tools - `tools/call` - Execute a tool +- `resources/list` - List available documentation resources +- `resources/read` - Read a specific resource + +--- + +## Resources (Documentation) + +MCP Resources provide read-only documentation accessible to language models. Use these to understand worksheet configuration options. + +### List Resources + +```bash +curl -X POST http://localhost:3000/api/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/list", + "params": {} + }' +``` + +Returns: +```json +{ + "resources": [ + { + "uri": "docs://worksheet/regrouping", + "name": "Regrouping (Carrying/Borrowing)", + "description": "What regrouping means pedagogically, and how pAnyStart/pAllStart control problem difficulty", + "mimeType": "text/markdown" + }, + { + "uri": "docs://worksheet/scaffolding", + "name": "Scaffolding Options", + "description": "Visual aids on worksheets: carryBoxes, answerBoxes, placeValueColors, tenFrames, and display rule values", + "mimeType": "text/markdown" + }, + { + "uri": "docs://worksheet/difficulty-profiles", + "name": "Difficulty Profiles", + "description": "The six preset profiles (beginner → expert), when to use each, and progression philosophy", + "mimeType": "text/markdown" + }, + { + "uri": "docs://worksheet/digit-range", + "name": "Digit Range", + "description": "How digitRange.min and digitRange.max affect problem complexity", + "mimeType": "text/markdown" + }, + { + "uri": "docs://worksheet/operators", + "name": "Operators (Addition/Subtraction/Mixed)", + "description": "Difference between operators, pedagogical sequence, and scaffolding differences", + "mimeType": "text/markdown" + } + ] +} +``` + +### Read Resource + +```bash +curl -X POST http://localhost:3000/api/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/read", + "params": { + "uri": "docs://worksheet/scaffolding" + } + }' +``` + +Returns: +```json +{ + "contents": [ + { + "uri": "docs://worksheet/scaffolding", + "mimeType": "text/markdown", + "text": "# Scaffolding Options\n\nScaffolding elements are visual aids..." + } + ] +} +``` + +### Available Resources + +| URI | Purpose | +|-----|---------| +| `docs://worksheet/regrouping` | Explains carrying/borrowing and how `pAnyStart`/`pAllStart` control regrouping frequency | +| `docs://worksheet/scaffolding` | Describes visual aids: carryBoxes, answerBoxes, placeValueColors, tenFrames | +| `docs://worksheet/difficulty-profiles` | The 6 preset profiles and when to use each | +| `docs://worksheet/digit-range` | How min/max digits affect problem complexity | +| `docs://worksheet/operators` | Addition vs subtraction vs mixed, with scaffolding differences | + +**Tip:** Read the `difficulty-profiles` resource before generating worksheets to understand how the profiles map to regrouping and scaffolding settings diff --git a/apps/web/docs/mcp/worksheet/difficulty-profiles.md b/apps/web/docs/mcp/worksheet/difficulty-profiles.md new file mode 100644 index 00000000..3bfdceb1 --- /dev/null +++ b/apps/web/docs/mcp/worksheet/difficulty-profiles.md @@ -0,0 +1,144 @@ +# Difficulty Profiles + +Difficulty profiles are pre-configured combinations of regrouping frequency and scaffolding levels that follow pedagogical best practices. + +## The Six Profiles + +### 1. Beginner +**Focus:** Learning worksheet structure, no regrouping yet. + +| Setting | Value | +|---------|-------| +| Regrouping | 0% | +| Scaffolding | Full (always) | + +**Use when:** Student is new to multi-digit arithmetic or needs to learn place value alignment. + +**Example problems:** 23 + 14, 45 + 32 (no carrying required) + +--- + +### 2. Early Learner +**Focus:** Introducing regrouping concept with full support. + +| Setting | Value | +|---------|-------| +| Regrouping | 25% | +| Scaffolding | Conditional (whenRegrouping) | + +**Use when:** Student understands place value, ready to encounter carrying/borrowing. + +**Key feature:** Scaffolding appears only when the problem requires it, teaching students to recognize when regrouping is needed. + +--- + +### 3. Practice +**Focus:** Master regrouping mechanics WITH scaffolding support. + +| Setting | Value | +|---------|-------| +| Regrouping | 75% | +| Scaffolding | High (always/whenRegrouping) | + +**Use when:** Student understands regrouping but needs repetition to build fluency. + +**Critical insight:** This is where students should spend significant time. They need to master the skill with support BEFORE removing the training wheels. + +--- + +### 4. Intermediate +**Focus:** Begin removing scaffolding while maintaining regrouping frequency. + +| Setting | Value | +|---------|-------| +| Regrouping | 75% | +| Scaffolding | Reduced (whenMultipleRegroups) | + +**Use when:** Student is accurate with regrouping, ready for more independence. + +**Transition:** Same problem difficulty, less visual support. + +--- + +### 5. Advanced +**Focus:** Complex problems with minimal support. + +| Setting | Value | +|---------|-------| +| Regrouping | 90% | +| Scaffolding | Minimal (when3PlusDigits only) | + +**Use when:** Student demonstrates consistent accuracy, building speed. + +--- + +### 6. Expert +**Focus:** Full independence, preparing for mental math. + +| Setting | Value | +|---------|-------| +| Regrouping | 90% | +| Scaffolding | None | + +**Use when:** Student is ready for standardized test format or transitioning to mental arithmetic. + +--- + +## Progression Philosophy + +``` +Beginner → Early Learner → Practice → Intermediate → Advanced → Expert + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ +Learn Introduce Master Reduce Complex Full +Structure Concept WITH Help Support Problems Independence +``` + +### The "Practice" Plateau + +Many curricula rush through the practice phase. Students who haven't mastered regrouping WITH scaffolding will struggle when it's removed. + +**Signs a student isn't ready to progress:** +- Accuracy drops below 85% +- Frequently forgets to carry/borrow +- Needs to count on fingers for single-digit sums +- Avoids problems or shows frustration + +**Signs a student is ready to progress:** +- Consistent 90%+ accuracy +- Smooth, confident work +- Can explain their process +- Completes problems without pausing + +## Choosing a Profile for a Student + +Ask these questions: + +1. **Can they align multi-digit numbers correctly?** + - No → Beginner + - Yes → Continue + +2. **Have they encountered regrouping?** + - No → Early Learner + - Yes → Continue + +3. **Are they accurate (85%+) with regrouping problems WITH scaffolding?** + - No → Practice + - Yes → Continue + +4. **Are they accurate (85%+) without scaffolding?** + - No → Intermediate + - Yes → Continue + +5. **Are they fast and confident with complex problems?** + - No → Advanced + - Yes → Expert + +## Custom Configurations + +If profiles don't fit, you can customize: +- Use Practice regrouping (75%) with Intermediate scaffolding +- Increase regrouping to 100% for targeted practice +- Keep scaffolding longer for students with working memory challenges + +The profiles are starting points, not rigid rules. diff --git a/apps/web/docs/mcp/worksheet/digit-range.md b/apps/web/docs/mcp/worksheet/digit-range.md new file mode 100644 index 00000000..566ad4e6 --- /dev/null +++ b/apps/web/docs/mcp/worksheet/digit-range.md @@ -0,0 +1,65 @@ +# Digit Range + +The digit range controls how many digits each number in a problem can have. + +## Parameters + +### `digitRange.min` (1-5) +Minimum number of digits per operand. + +### `digitRange.max` (1-5) +Maximum number of digits per operand. + +## Examples + +| min | max | Example Problems | +|-----|-----|------------------| +| 1 | 1 | 7 + 5, 9 - 3 | +| 2 | 2 | 47 + 85, 72 - 38 | +| 2 | 3 | 47 + 285, 538 - 72 | +| 3 | 3 | 472 + 385, 841 - 256 | +| 3 | 5 | 472 + 38541, 84123 - 256 | + +## Pedagogical Considerations + +### Start Small +Begin with 2-digit numbers even if the student can handle larger ones. This: +- Reduces cognitive load while learning regrouping +- Allows focus on the process, not the numbers +- Builds automaticity before scaling up + +### When to Increase +Move to larger numbers when the student: +- Is consistently accurate (90%+) with current size +- Shows automaticity (doesn't need to think about each step) +- Expresses boredom with current level + +### Mixed Ranges (min ≠ max) +Using different min and max creates variety: +- `{ min: 2, max: 3 }` mixes 2-digit and 3-digit numbers +- Prevents pattern recognition ("all problems look the same") +- More realistic (real-world arithmetic has mixed sizes) + +### Large Numbers (4-5 digits) +For 4+ digit numbers: +- Consider keeping `placeValueColors` longer (prevents column confusion) +- May need more columns on the worksheet +- Portrait orientation may work better than landscape + +## Common Progressions + +### Standard Progression +1. 2-digit (master regrouping concept) +2. 3-digit (extend to more columns) +3. 2-3 digit mixed (build flexibility) +4. 4+ digit (for enrichment or specific needs) + +### For Students Struggling with Place Value +1. Start with 1-digit (no alignment needed) +2. Move to 2-digit with `answerBoxes: 'always'` +3. Gradually remove scaffolding before increasing digits + +### For Advanced Students +- Can skip 2-digit phase if already proficient +- Jump to 3-digit with appropriate scaffolding +- Use 4-5 digits for challenge problems diff --git a/apps/web/docs/mcp/worksheet/operators.md b/apps/web/docs/mcp/worksheet/operators.md new file mode 100644 index 00000000..4fc192c2 --- /dev/null +++ b/apps/web/docs/mcp/worksheet/operators.md @@ -0,0 +1,81 @@ +# Operators + +The `operator` setting controls what type of arithmetic problems appear on the worksheet. + +## Options + +### `addition` +Only addition problems. + +``` + 47 + + 85 + ──── +``` + +**Scaffolding used:** carryBoxes, answerBoxes, placeValueColors, tenFrames + +### `subtraction` +Only subtraction problems. + +``` + 72 + - 38 + ──── +``` + +**Scaffolding used:** borrowNotation, borrowingHints, answerBoxes, placeValueColors + +**Note:** Subtraction uses different scaffolding (borrowNotation/borrowingHints instead of carryBoxes/tenFrames) because the regrouping process is conceptually different. + +### `mixed` +Both addition and subtraction problems, randomly interspersed. + +**When to use:** +- Student has mastered both operations separately +- Preparing for timed tests or real-world applications +- Building operational flexibility + +**Caution:** Mixing operations too early can confuse students who haven't automated each operation independently. + +## Pedagogical Sequence + +### Traditional Approach +1. **Addition without regrouping** → learn alignment +2. **Addition with regrouping** → learn carrying +3. **Subtraction without regrouping** → learn inverse operation +4. **Subtraction with regrouping** → learn borrowing +5. **Mixed operations** → build flexibility + +### Why Addition First? +- Carrying is conceptually simpler than borrowing +- "Making groups of 10" builds on counting knowledge +- Errors are easier to check (count up to verify) + +### Why Not Skip Ahead? +Students who learn addition and subtraction together often: +- Confuse carrying and borrowing +- Make sign errors (adding when they should subtract) +- Develop slower automaticity + +## Regrouping in Subtraction + +Borrowing (subtraction regrouping) is cognitively harder because: +- Requires recognizing "can't do this" situations +- Involves reducing a digit (counterintuitive) +- Changes two columns at once + +**Recommendation:** Master addition regrouping to 90%+ accuracy before introducing subtraction regrouping. + +## Scaffolding Differences + +| Scaffolding | Addition | Subtraction | +|-------------|----------|-------------| +| carryBoxes | ✓ | - | +| tenFrames | ✓ | - | +| borrowNotation | - | ✓ | +| borrowingHints | - | ✓ | +| answerBoxes | ✓ | ✓ | +| placeValueColors | ✓ | ✓ | + +When using `mixed` operator, all scaffolding types may appear depending on the specific problem. diff --git a/apps/web/docs/mcp/worksheet/regrouping.md b/apps/web/docs/mcp/worksheet/regrouping.md new file mode 100644 index 00000000..40a9fd37 --- /dev/null +++ b/apps/web/docs/mcp/worksheet/regrouping.md @@ -0,0 +1,60 @@ +# Regrouping (Carrying and Borrowing) + +## What is Regrouping? + +Regrouping is what happens when a column's sum exceeds 9 (addition) or when you need to borrow from a higher place value (subtraction). + +### Addition Example +``` + 47 + + 85 + ---- +``` +- Ones column: 7 + 5 = 12 → write 2, carry the 1 +- Tens column: 4 + 8 + 1(carried) = 13 → write 3, carry the 1 +- Result: 132 + +The "carrying" is regrouping - you're regrouping 12 ones as 1 ten and 2 ones. + +### Subtraction Example +``` + 42 + - 17 + ---- +``` +- Ones column: 2 - 7 → can't do it, borrow from tens +- Regroup: 42 = 30 + 12 (borrow 1 ten, add 10 ones) +- Now: 12 - 7 = 5, 3 - 1 = 2 +- Result: 25 + +## Regrouping Parameters + +### `pAnyStart` (0.0 - 1.0) +Probability that **at least one column** requires regrouping. +- `0.0` = No regrouping ever (e.g., 23 + 14) +- `0.5` = Half the problems have at least one regroup +- `1.0` = Every problem has at least one regroup + +### `pAllStart` (0.0 - 1.0) +Probability that **every column** requires regrouping (compound/cascading). +- `0.0` = Simple regrouping only (one column at a time) +- `0.5` = Half the problems have multiple regroups +- `1.0` = Every problem has cascading regroups (e.g., 789 + 456) + +### Pedagogical Progression + +| Stage | pAnyStart | pAllStart | Focus | +|-------|-----------|-----------|-------| +| Learning structure | 0% | 0% | Place value alignment, no regrouping | +| Introducing concept | 25% | 0% | Occasional single regroups | +| Practicing skill | 75% | 25% | Frequent regrouping with scaffolding | +| Building fluency | 75% | 25% | Same frequency, less scaffolding | +| Mastery | 90% | 50% | Complex problems, no scaffolding | + +## Why This Matters + +Students often learn the mechanics of addition before encountering regrouping. Introducing it gradually: +1. Builds confidence with the base algorithm +2. Isolates the new concept (carrying/borrowing) +3. Prevents cognitive overload +4. Allows scaffolding to be systematically removed diff --git a/apps/web/docs/mcp/worksheet/scaffolding.md b/apps/web/docs/mcp/worksheet/scaffolding.md new file mode 100644 index 00000000..ed389721 --- /dev/null +++ b/apps/web/docs/mcp/worksheet/scaffolding.md @@ -0,0 +1,129 @@ +# Scaffolding Options + +Scaffolding refers to visual aids on the worksheet that support students learning multi-digit arithmetic. As students gain proficiency, scaffolding is systematically removed. + +## Scaffolding Elements + +### `carryBoxes` +Small boxes above each column where students write carry digits. + +``` + ┌─┐ ┌─┐ + │1│ │ │ ← carry boxes + └─┘ └─┘ + 4 7 + + 8 5 + ───────── + 1 3 2 +``` + +**When useful:** Learning to track carries, preventing "forgotten carries" errors. + +### `answerBoxes` +Individual boxes for each digit of the answer, enforcing place value alignment. + +``` + 4 7 + + 8 5 + ───────── + ┌─┐ ┌─┐ ┌─┐ + │1│ │3│ │2│ ← answer boxes + └─┘ └─┘ └─┘ +``` + +**When useful:** Students who misalign digits or have spatial organization difficulties. + +### `placeValueColors` +Color-coding by place value: ones (blue), tens (green), hundreds (red), etc. + +**When useful:** Reinforcing place value concept, helping visual learners, preventing column confusion. + +### `tenFrames` +Visual 10-dot grids showing regrouping concretely. + +``` + ●●●●● ●●●●● 7 + 5 = 12 + ●●○○○ ○○○○○ + ●●●●● ●● + ●●●●● ○○ = 10 + 2 +``` + +**When useful:** Building conceptual understanding of why regrouping works. + +### `borrowNotation` (subtraction) +Scratch work showing the borrowing process. + +``` + 3 12 ← shows 4 became 3, 2 became 12 + 4 2 + - 1 7 + ────── + 2 5 +``` + +### `borrowingHints` (subtraction) +Visual indicators showing which columns need borrowing. + +## Display Rule Values + +Each scaffolding element can be set to: + +| Value | Meaning | +|-------|---------| +| `always` | Show on every problem | +| `never` | Never show | +| `whenRegrouping` | Show only on problems that require regrouping | +| `whenMultipleRegroups` | Show only on problems with 2+ regroups | +| `when3PlusDigits` | Show only for numbers with 3+ digits | +| `auto` | System decides based on mastery data | + +## Scaffolding Progression + +The pedagogical principle: **Introduce scaffolding to support learning, then systematically remove it.** + +### Level 0: Maximum Support (Beginner) +``` +carryBoxes: 'always' +answerBoxes: 'always' +placeValueColors: 'always' +tenFrames: 'always' +``` +Student sees all visual aids, even on problems that don't need regrouping. This teaches the structure. + +### Level 4-6: Conditional Support (Practice) +``` +carryBoxes: 'whenRegrouping' +answerBoxes: 'always' +placeValueColors: 'whenRegrouping' +tenFrames: 'whenRegrouping' +``` +Scaffolding appears only when relevant. Student starts internalizing when it's needed. + +### Level 8-10: Minimal Support (Advanced) +``` +carryBoxes: 'never' +answerBoxes: 'never' +placeValueColors: 'when3PlusDigits' +tenFrames: 'never' +``` +Student works independently. Colors only for large numbers where tracking columns is harder. + +### Level 12: No Scaffolding (Expert) +``` +carryBoxes: 'never' +answerBoxes: 'never' +placeValueColors: 'never' +tenFrames: 'never' +``` +Clean worksheet. Student has internalized the process. + +## Choosing Scaffolding Levels + +**Key insight:** Scaffolding and regrouping difficulty should be balanced. + +- High regrouping + High scaffolding = Learning the skill with support +- High regrouping + Low scaffolding = Mastery practice +- Low regrouping + High scaffolding = Not useful (overscaffolding simple problems) +- Low regrouping + Low scaffolding = Also not useful (nothing to scaffold) + +The difficulty profiles manage this balance automatically. diff --git a/apps/web/src/app/api/mcp/route.ts b/apps/web/src/app/api/mcp/route.ts index 5e4cf3df..657bd385 100644 --- a/apps/web/src/app/api/mcp/route.ts +++ b/apps/web/src/app/api/mcp/route.ts @@ -8,10 +8,13 @@ * - initialize: Capability negotiation * - tools/list: List available tools * - tools/call: Execute a tool + * - resources/list: List available resources + * - resources/read: Read a specific resource */ import { NextResponse } from 'next/server' import { validateMcpApiKey } from '@/lib/mcp/auth' +import { listResources, readResource } from '@/lib/mcp/resources' import { MCP_TOOLS, listStudents, @@ -26,6 +29,9 @@ import { controlSession, createObservationLink, listObservationLinks, + generateWorksheet, + getWorksheetInfo, + listDifficultyProfiles, } from '@/lib/mcp/tools' import type { ShareDuration } from '@/lib/session-share' @@ -123,6 +129,7 @@ export async function POST(request: Request) { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: {}, + resources: {}, }, serverInfo: { name: 'abaci-one', @@ -163,6 +170,25 @@ export async function POST(request: Request) { ) } + case 'resources/list': { + const result = listResources() + return NextResponse.json(jsonRpcSuccess(id, result)) + } + + case 'resources/read': { + const uri = params.uri as string + if (!uri) { + return NextResponse.json(jsonRpcError(id, -32602, 'Missing resource URI')) + } + + const result = readResource(uri) + if ('error' in result) { + return NextResponse.json(jsonRpcError(id, -32002, result.error)) + } + + return NextResponse.json(jsonRpcSuccess(id, result)) + } + default: { return NextResponse.json(jsonRpcError(id, -32601, `Method not found: ${method}`)) } @@ -192,6 +218,12 @@ async function executeTool( toolName: string, args: Record ): Promise { + // Worksheet tools don't require player access + const worksheetToolNames = ['generate_worksheet', 'get_worksheet_info', 'list_difficulty_profiles'] + if (worksheetToolNames.includes(toolName)) { + return executeWorksheetTool(toolName, args) + } + // Tools that don't require a player_id if (toolName === 'list_students') { return listStudents(userId) @@ -278,6 +310,38 @@ async function executeTool( } } +/** + * Execute worksheet tools that don't require player access + */ +async function executeWorksheetTool( + toolName: string, + args: Record +): Promise { + switch (toolName) { + case 'generate_worksheet': + return generateWorksheet({ + operator: args.operator as 'addition' | 'subtraction' | 'mixed' | undefined, + digitRange: args.digit_range as { min: number; max: number } | undefined, + problemsPerPage: args.problems_per_page as number | undefined, + pages: args.pages as number | undefined, + difficultyProfile: args.difficulty_profile as string | undefined, + includeAnswerKey: args.include_answer_key as boolean | undefined, + title: args.title as string | undefined, + orientation: args.orientation as 'portrait' | 'landscape' | undefined, + cols: args.cols as number | undefined, + }) + + case 'get_worksheet_info': + return getWorksheetInfo(args.share_id as string) + + case 'list_difficulty_profiles': + return listDifficultyProfiles() + + default: + return null // Not a worksheet tool + } +} + /** * OPTIONS - Handle CORS preflight */ diff --git a/apps/web/src/app/api/worksheets/download/[id]/route.ts b/apps/web/src/app/api/worksheets/download/[id]/route.ts new file mode 100644 index 00000000..182c26de --- /dev/null +++ b/apps/web/src/app/api/worksheets/download/[id]/route.ts @@ -0,0 +1,153 @@ +/** + * GET /api/worksheets/download/[id] + * + * Download a PDF for a shared worksheet + * Generates PDF on-demand from stored config + */ + +import { eq } from 'drizzle-orm' +import { execSync } from 'child_process' +import { type NextRequest, NextResponse } from 'next/server' +import { db } from '@/db' +import { worksheetShares } from '@/db/schema' +import { isValidShareId } from '@/lib/generateShareId' +import { parseAdditionConfig } from '@/app/create/worksheets/config-schemas' +import { validateWorksheetConfig } from '@/app/create/worksheets/validation' +import { + generateProblems, + generateSubtractionProblems, + generateMixedProblems, +} from '@/app/create/worksheets/problemGenerator' +import { generateTypstSource } from '@/app/create/worksheets/typstGenerator' +import type { WorksheetProblem, WorksheetFormState } from '@/app/create/worksheets/types' + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + + // Validate ID format + if (!isValidShareId(id)) { + return NextResponse.json({ error: 'Invalid share ID format' }, { status: 400 }) + } + + // Fetch share record + const share = await db.query.worksheetShares.findFirst({ + where: eq(worksheetShares.id, id), + }) + + if (!share) { + return NextResponse.json({ error: 'Share not found' }, { status: 404 }) + } + + // Parse and validate config (auto-migrates to latest version) + const parsedConfig = parseAdditionConfig(share.config) + + // Validate configuration + // Cast to WorksheetFormState which is a permissive union type during form editing + // The parsed V4 config is compatible but TypeScript needs help with the union types + const validation = validateWorksheetConfig(parsedConfig as unknown as WorksheetFormState) + if (!validation.isValid || !validation.config) { + return NextResponse.json( + { error: 'Invalid worksheet configuration', errors: validation.errors }, + { status: 400 } + ) + } + + const config = validation.config + + // Generate problems based on operator type + let problems: WorksheetProblem[] + if (config.operator === 'addition') { + problems = generateProblems( + config.total, + config.pAnyStart, + config.pAllStart, + config.interpolate, + config.seed, + config.digitRange + ) + } else if (config.operator === 'subtraction') { + problems = generateSubtractionProblems( + config.total, + config.digitRange, + config.pAnyStart, + config.pAllStart, + config.interpolate, + config.seed + ) + } else { + // mixed + problems = generateMixedProblems( + config.total, + config.digitRange, + config.pAnyStart, + config.pAllStart, + config.interpolate, + config.seed + ) + } + + // Build share URL for QR code if enabled + let shareUrl: string | undefined + if (config.includeQRCode) { + const protocol = request.headers.get('x-forwarded-proto') || 'https' + const host = request.headers.get('host') || 'abaci.one' + shareUrl = `${protocol}://${host}/worksheets/shared/${id}` + } + + // Generate Typst sources (one per page) + const typstSources = await generateTypstSource(config, problems, shareUrl) + + // Join pages with pagebreak for PDF + const typstSource = typstSources.join('\n\n#pagebreak()\n\n') + + // Compile with Typst: stdin → stdout + let pdfBuffer: Buffer + try { + pdfBuffer = execSync('typst compile --format pdf - -', { + input: typstSource, + maxBuffer: 10 * 1024 * 1024, // 10MB limit + }) + } catch (error) { + console.error('Typst compilation error:', error) + + const stderr = + error instanceof Error && 'stderr' in error + ? String((error as any).stderr) + : 'Unknown compilation error' + + return NextResponse.json( + { + error: 'Failed to compile worksheet PDF', + details: stderr, + }, + { status: 500 } + ) + } + + // Generate filename from title or share ID + const filename = share.title + ? `worksheet-${share.title.replace(/[^a-zA-Z0-9]/g, '-')}.pdf` + : `worksheet-${id}.pdf` + + // Return binary PDF directly + return new Response(pdfBuffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (error) { + console.error('Error downloading worksheet:', error) + + const errorMessage = error instanceof Error ? error.message : String(error) + + return NextResponse.json( + { + error: 'Failed to download worksheet', + message: errorMessage, + }, + { status: 500 } + ) + } +} diff --git a/apps/web/src/app/api/worksheets/download/__tests__/route.test.ts b/apps/web/src/app/api/worksheets/download/__tests__/route.test.ts new file mode 100644 index 00000000..466b4b15 --- /dev/null +++ b/apps/web/src/app/api/worksheets/download/__tests__/route.test.ts @@ -0,0 +1,188 @@ +/** + * Tests for worksheet download API endpoint + */ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +// Mock the database module +vi.mock('@/db', () => { + const mockFindFirst = vi.fn() + + return { + db: { + query: { + worksheetShares: { + findFirst: mockFindFirst, + }, + }, + }, + schema: { + worksheetShares: {}, + }, + } +}) + +// Mock the worksheetShares table +vi.mock('@/db/schema', () => ({ + worksheetShares: { + id: 'id', + }, +})) + +// Mock generateShareId +vi.mock('@/lib/generateShareId', () => ({ + isValidShareId: vi.fn().mockReturnValue(true), +})) + +// Mock the worksheet config parsing +vi.mock('@/app/create/worksheets/config-schemas', () => ({ + parseAdditionConfig: vi.fn().mockReturnValue({ + version: 4, + mode: 'custom', + operator: 'addition', + digitRange: { min: 2, max: 2 }, + problemsPerPage: 20, + pages: 1, + cols: 5, + orientation: 'landscape', + pAnyStart: 0.25, + pAllStart: 0, + total: 20, + seed: 12345, + }), +})) + +// Mock the validation +vi.mock('@/app/create/worksheets/validation', () => ({ + validateWorksheetConfig: vi.fn().mockReturnValue({ + isValid: true, + config: { + version: 4, + mode: 'custom', + operator: 'addition', + digitRange: { min: 2, max: 2 }, + problemsPerPage: 20, + pages: 1, + cols: 5, + total: 20, + rows: 4, + orientation: 'landscape', + pAnyStart: 0.25, + pAllStart: 0, + seed: 12345, + displayRules: { + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', + }, + }, + }), +})) + +// Mock problem generators +vi.mock('@/app/create/worksheets/problemGenerator', () => ({ + generateProblems: vi.fn().mockReturnValue([ + { a: 23, b: 45, operator: 'add' }, + { a: 12, b: 34, operator: 'add' }, + ]), + generateSubtractionProblems: vi.fn().mockReturnValue([]), + generateMixedProblems: vi.fn().mockReturnValue([]), +})) + +// Mock Typst generator +vi.mock('@/app/create/worksheets/typstGenerator', () => ({ + generateTypstSource: vi.fn().mockResolvedValue(['#set page(width: 11in)\n// Typst content']), +})) + +// Mock child_process for Typst compilation +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + execSync: vi.fn().mockReturnValue(Buffer.from('fake-pdf-content')), + } +}) + +// Import after mocking +import { db } from '@/db' +import { isValidShareId } from '@/lib/generateShareId' +import { GET } from '../[id]/route' + +describe('Worksheet Download API', () => { + const mockFindFirst = db.query.worksheetShares.findFirst as ReturnType + + const validShareRecord = { + id: 'testshare12', + worksheetType: 'addition', + config: '{}', // Will be parsed by mock + createdAt: new Date('2026-01-15T10:00:00Z'), + views: 5, + title: 'Test Worksheet', + } + + beforeEach(() => { + vi.clearAllMocks() + ;(isValidShareId as ReturnType).mockReturnValue(true) + mockFindFirst.mockResolvedValue(validShareRecord) + }) + + it('returns 400 for invalid share ID format', async () => { + ;(isValidShareId as ReturnType).mockReturnValue(false) + + const request = new NextRequest('http://localhost:3000/api/worksheets/download/invalid') + const response = await GET(request, { params: Promise.resolve({ id: 'invalid' }) }) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toBe('Invalid share ID format') + }) + + it('returns 404 when share not found', async () => { + mockFindFirst.mockResolvedValue(null) + + const request = new NextRequest('http://localhost:3000/api/worksheets/download/notfound12') + const response = await GET(request, { params: Promise.resolve({ id: 'notfound12' }) }) + const data = await response.json() + + expect(response.status).toBe(404) + expect(data.error).toBe('Share not found') + }) + + it('returns PDF for valid share ID', async () => { + const request = new NextRequest('http://localhost:3000/api/worksheets/download/testshare12') + const response = await GET(request, { params: Promise.resolve({ id: 'testshare12' }) }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Content-Disposition')).toContain('attachment') + expect(response.headers.get('Content-Disposition')).toContain('worksheet-Test-Worksheet.pdf') + }) + + it('uses share ID in filename when no title', async () => { + mockFindFirst.mockResolvedValue({ + ...validShareRecord, + title: null, + }) + + const request = new NextRequest('http://localhost:3000/api/worksheets/download/testshare12') + const response = await GET(request, { params: Promise.resolve({ id: 'testshare12' }) }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Disposition')).toContain('worksheet-testshare12.pdf') + }) + + it('sanitizes title for filename', async () => { + mockFindFirst.mockResolvedValue({ + ...validShareRecord, + title: 'Test/Worksheet:With*Special?Chars', + }) + + const request = new NextRequest('http://localhost:3000/api/worksheets/download/testshare12') + const response = await GET(request, { params: Promise.resolve({ id: 'testshare12' }) }) + + expect(response.status).toBe(200) + // Special characters should be replaced with hyphens + expect(response.headers.get('Content-Disposition')).toContain('worksheet-Test-Worksheet-With-Special-Chars.pdf') + }) +}) diff --git a/apps/web/src/lib/mcp/__tests__/resources.test.ts b/apps/web/src/lib/mcp/__tests__/resources.test.ts new file mode 100644 index 00000000..edfcd41e --- /dev/null +++ b/apps/web/src/lib/mcp/__tests__/resources.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { listResources, readResource } from '../resources' +import fs from 'fs' + +// Mock fs module +vi.mock('fs', () => ({ + default: { + readFileSync: vi.fn(), + }, +})) + +describe('MCP Resources', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('listResources', () => { + it('returns all registered resources', () => { + const result = listResources() + + expect(result).toHaveProperty('resources') + expect(Array.isArray(result.resources)).toBe(true) + expect(result.resources.length).toBe(5) // 5 worksheet docs + + // Check structure of first resource + const firstResource = result.resources[0] + expect(firstResource).toHaveProperty('uri') + expect(firstResource).toHaveProperty('name') + expect(firstResource).toHaveProperty('description') + expect(firstResource).toHaveProperty('mimeType') + }) + + it('includes all expected documentation resources', () => { + const result = listResources() + const uris = result.resources.map((r) => r.uri) + + expect(uris).toContain('docs://worksheet/regrouping') + expect(uris).toContain('docs://worksheet/scaffolding') + expect(uris).toContain('docs://worksheet/difficulty-profiles') + expect(uris).toContain('docs://worksheet/digit-range') + expect(uris).toContain('docs://worksheet/operators') + }) + + it('all resources have text/markdown mimeType', () => { + const result = listResources() + + for (const resource of result.resources) { + expect(resource.mimeType).toBe('text/markdown') + } + }) + + it('all resources have non-empty descriptions', () => { + const result = listResources() + + for (const resource of result.resources) { + expect(resource.description.length).toBeGreaterThan(0) + } + }) + }) + + describe('readResource', () => { + it('returns content for a valid resource URI', () => { + const mockContent = '# Regrouping\n\nThis is test content.' + vi.mocked(fs.readFileSync).mockReturnValue(mockContent) + + const result = readResource('docs://worksheet/regrouping') + + expect('contents' in result).toBe(true) + if ('contents' in result) { + expect(result.contents.length).toBe(1) + expect(result.contents[0].uri).toBe('docs://worksheet/regrouping') + expect(result.contents[0].mimeType).toBe('text/markdown') + expect(result.contents[0].text).toBe(mockContent) + } + }) + + it('returns error for unknown resource URI', () => { + const result = readResource('docs://worksheet/nonexistent') + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Resource not found') + } + }) + + it('returns error for invalid URI format', () => { + const result = readResource('invalid://uri/format') + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Resource not found') + } + }) + + it('returns error for directory traversal attempts', () => { + const result = readResource('docs://worksheet/../../../etc/passwd') + + expect('error' in result).toBe(true) + if ('error' in result) { + // Should fail at the registry check since it's not a registered resource + expect(result.error).toContain('Resource not found') + } + }) + + it('returns error when file read fails', () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('ENOENT: file not found') + }) + + const result = readResource('docs://worksheet/regrouping') + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Failed to read resource') + } + }) + + it('correctly reads all registered resources', () => { + const mockContent = '# Test Content' + vi.mocked(fs.readFileSync).mockReturnValue(mockContent) + + const { resources } = listResources() + + for (const resource of resources) { + const result = readResource(resource.uri) + + expect('contents' in result).toBe(true) + if ('contents' in result) { + expect(result.contents[0].uri).toBe(resource.uri) + } + } + }) + + it('passes correct file path to fs.readFileSync', () => { + const mockContent = '# Test' + vi.mocked(fs.readFileSync).mockReturnValue(mockContent) + + readResource('docs://worksheet/scaffolding') + + expect(fs.readFileSync).toHaveBeenCalledWith( + expect.stringContaining('docs/mcp/worksheet/scaffolding.md'), + 'utf-8' + ) + }) + }) +}) diff --git a/apps/web/src/lib/mcp/__tests__/worksheet-tools.test.ts b/apps/web/src/lib/mcp/__tests__/worksheet-tools.test.ts new file mode 100644 index 00000000..d5196874 --- /dev/null +++ b/apps/web/src/lib/mcp/__tests__/worksheet-tools.test.ts @@ -0,0 +1,440 @@ +/** + * Tests for MCP worksheet generation tools + */ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +// Mock the database module +vi.mock('@/db', () => { + const mockInsert = vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }) + const mockFindFirst = vi.fn() + + return { + db: { + insert: mockInsert, + query: { + worksheetShares: { + findFirst: mockFindFirst, + }, + }, + }, + schema: { + worksheetShares: {}, + }, + } +}) + +// Mock the worksheetShares table +vi.mock('@/db/schema', () => ({ + parentChild: {}, + worksheetShares: { + id: 'id', + worksheetType: 'worksheet_type', + config: 'config', + createdAt: 'created_at', + views: 'views', + creatorIp: 'creator_ip', + title: 'title', + }, +})) + +// Mock generateShareId to return predictable values +vi.mock('@/lib/generateShareId', () => { + return { + generateShareId: vi.fn().mockReturnValue('abcdef1234'), + isValidShareId: vi.fn().mockReturnValue(true), + } +}) + +// Import after mocking +import { db } from '@/db' +import { worksheetShares } from '@/db/schema' +import { generateShareId, isValidShareId } from '@/lib/generateShareId' +import { + generateWorksheet, + getWorksheetInfo, + listDifficultyProfiles, +} from '../tools' +import { + DIFFICULTY_PROFILES, + DIFFICULTY_PROGRESSION, +} from '@/app/create/worksheets/difficultyProfiles' + +describe('MCP Worksheet Tools', () => { + const mockInsert = db.insert as ReturnType + const mockFindFirst = db.query.worksheetShares.findFirst as ReturnType + + beforeEach(() => { + vi.clearAllMocks() + // Reset the mock to return a unique value + ;(generateShareId as ReturnType).mockReturnValue('abcdef1234') + // Reset isValidShareId to return true by default + ;(isValidShareId as ReturnType).mockReturnValue(true) + // Mock insert chain + mockInsert.mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }) + }) + + describe('listDifficultyProfiles', () => { + it('returns all difficulty profiles', () => { + const result = listDifficultyProfiles() + + expect(result.profiles).toHaveLength(DIFFICULTY_PROGRESSION.length) + expect(result.progression).toEqual(DIFFICULTY_PROGRESSION) + }) + + it('includes correct profile names in order', () => { + const result = listDifficultyProfiles() + + expect(result.progression).toEqual([ + 'beginner', + 'earlyLearner', + 'practice', + 'intermediate', + 'advanced', + 'expert', + ]) + }) + + it('includes regrouping settings for each profile', () => { + const result = listDifficultyProfiles() + + // Check beginner profile (no regrouping) + const beginner = result.profiles.find((p) => p.name === 'beginner') + expect(beginner).toBeDefined() + expect(beginner!.regrouping.pAnyStart).toBe(0) + expect(beginner!.regrouping.pAllStart).toBe(0) + expect(beginner!.regrouping.percent).toBe(0) + + // Check earlyLearner (25% regrouping) + const earlyLearner = result.profiles.find((p) => p.name === 'earlyLearner') + expect(earlyLearner).toBeDefined() + expect(earlyLearner!.regrouping.pAnyStart).toBe(0.25) + expect(earlyLearner!.regrouping.percent).toBe(25) + + // Check advanced (90% regrouping) + const advanced = result.profiles.find((p) => p.name === 'advanced') + expect(advanced).toBeDefined() + expect(advanced!.regrouping.pAnyStart).toBe(0.9) + expect(advanced!.regrouping.percent).toBe(90) + }) + + it('includes scaffolding settings for each profile', () => { + const result = listDifficultyProfiles() + + // Check beginner profile (full scaffolding) + const beginner = result.profiles.find((p) => p.name === 'beginner') + expect(beginner!.scaffolding.carryBoxes).toBe('always') + expect(beginner!.scaffolding.answerBoxes).toBe('always') + expect(beginner!.scaffolding.placeValueColors).toBe('always') + + // Check expert profile (no scaffolding) + const expert = result.profiles.find((p) => p.name === 'expert') + expect(expert!.scaffolding.carryBoxes).toBe('never') + expect(expert!.scaffolding.answerBoxes).toBe('never') + expect(expert!.scaffolding.placeValueColors).toBe('never') + }) + + it('includes description and label for each profile', () => { + const result = listDifficultyProfiles() + + result.profiles.forEach((profile) => { + expect(profile.label).toBeTruthy() + expect(profile.description).toBeTruthy() + expect(typeof profile.label).toBe('string') + expect(typeof profile.description).toBe('string') + }) + }) + }) + + describe('generateWorksheet', () => { + it('generates worksheet with default options', async () => { + const result = await generateWorksheet({}) + + expect(result.shareId).toBe('abcdef1234') + expect(result.shareUrl).toContain('/worksheets/shared/abcdef1234') + expect(result.downloadUrl).toContain('/api/worksheets/download/abcdef1234') + expect(result.summary).toBeDefined() + }) + + it('uses earlyLearner as default difficulty profile', async () => { + const result = await generateWorksheet({}) + + expect(result.summary.difficultyProfile).toBe('earlyLearner') + expect(result.summary.regroupingPercent).toBe(25) + }) + + it('applies custom difficulty profile', async () => { + const result = await generateWorksheet({ difficultyProfile: 'advanced' }) + + expect(result.summary.difficultyProfile).toBe('advanced') + expect(result.summary.regroupingPercent).toBe(90) + }) + + it('sets correct operator', async () => { + const addition = await generateWorksheet({ operator: 'addition' }) + expect(addition.summary.operator).toBe('addition') + + const subtraction = await generateWorksheet({ operator: 'subtraction' }) + expect(subtraction.summary.operator).toBe('subtraction') + + const mixed = await generateWorksheet({ operator: 'mixed' }) + expect(mixed.summary.operator).toBe('mixed') + }) + + it('validates digit range', async () => { + // Valid range + const result = await generateWorksheet({ digitRange: { min: 2, max: 4 } }) + expect(result.summary.digitRange.min).toBe(2) + expect(result.summary.digitRange.max).toBe(4) + }) + + it('clamps digit range to valid bounds', async () => { + // Out of bounds values should be clamped + // Note: min: 0 falls back to default 2 due to || operator, then clamped + const result = await generateWorksheet({ digitRange: { min: 0, max: 10 } }) + expect(result.summary.digitRange.min).toBe(2) // 0 || 2 = 2, then clamped to 1-5 + expect(result.summary.digitRange.max).toBe(5) // 10 clamped to 5 + + // Test explicit min value clamping + const result2 = await generateWorksheet({ digitRange: { min: -5, max: 3 } }) + expect(result2.summary.digitRange.min).toBe(1) // -5 || 2 = 2 (but clamped), wait -5 is truthy + // Actually -5 is truthy, so Math.max(1, Math.min(5, -5)) = Math.max(1, -5) = 1 + }) + + it('fixes inverted digit range', async () => { + const result = await generateWorksheet({ digitRange: { min: 4, max: 2 } }) + expect(result.summary.digitRange.min).toBe(4) + expect(result.summary.digitRange.max).toBe(4) + }) + + it('sets correct page count and problems', async () => { + const result = await generateWorksheet({ pages: 3, problemsPerPage: 15 }) + + expect(result.summary.pages).toBe(3) + expect(result.summary.problemsPerPage).toBe(15) + expect(result.summary.totalProblems).toBe(45) + }) + + it('clamps problems per page to valid range', async () => { + // Too high + const high = await generateWorksheet({ problemsPerPage: 100 }) + expect(high.summary.problemsPerPage).toBe(40) + + // Too low + const low = await generateWorksheet({ problemsPerPage: 0 }) + expect(low.summary.problemsPerPage).toBe(1) + }) + + it('clamps pages to valid range', async () => { + // Too high + const high = await generateWorksheet({ pages: 50 }) + expect(high.summary.pages).toBe(20) + + // Too low + const low = await generateWorksheet({ pages: 0 }) + expect(low.summary.pages).toBe(1) + }) + + it('sets orientation correctly', async () => { + const landscape = await generateWorksheet({ orientation: 'landscape' }) + expect(landscape.summary.orientation).toBe('landscape') + + const portrait = await generateWorksheet({ orientation: 'portrait' }) + expect(portrait.summary.orientation).toBe('portrait') + }) + + it('sets columns correctly', async () => { + const result = await generateWorksheet({ cols: 4 }) + expect(result.summary.cols).toBe(4) + }) + + it('clamps columns to valid range', async () => { + const high = await generateWorksheet({ cols: 10 }) + expect(high.summary.cols).toBe(6) + + const low = await generateWorksheet({ cols: 0 }) + expect(low.summary.cols).toBe(1) + }) + + it('sets includeAnswerKey correctly', async () => { + const withKey = await generateWorksheet({ includeAnswerKey: true }) + expect(withKey.summary.includeAnswerKey).toBe(true) + + const withoutKey = await generateWorksheet({ includeAnswerKey: false }) + expect(withoutKey.summary.includeAnswerKey).toBe(false) + }) + + it('inserts share record into database', async () => { + await generateWorksheet({ title: 'Test Worksheet' }) + + expect(mockInsert).toHaveBeenCalledWith(worksheetShares) + const insertCall = mockInsert.mock.results[0].value + expect(insertCall.values).toHaveBeenCalled() + }) + + it('includes scaffolding settings from difficulty profile', async () => { + const beginner = await generateWorksheet({ difficultyProfile: 'beginner' }) + expect(beginner.summary.scaffolding.carryBoxes).toBe('always') + expect(beginner.summary.scaffolding.answerBoxes).toBe('always') + + const expert = await generateWorksheet({ difficultyProfile: 'expert' }) + expect(expert.summary.scaffolding.carryBoxes).toBe('never') + expect(expert.summary.scaffolding.answerBoxes).toBe('never') + }) + + it('falls back to earlyLearner for unknown profile', async () => { + const result = await generateWorksheet({ difficultyProfile: 'unknownProfile' }) + + expect(result.summary.difficultyProfile).toBe('earlyLearner') + }) + }) + + describe('getWorksheetInfo', () => { + const mockShareRecord = { + id: 'testshare12', + worksheetType: 'addition', + config: JSON.stringify({ + version: 4, + mode: 'custom', + operator: 'addition', + digitRange: { min: 2, max: 3 }, + problemsPerPage: 20, + pages: 2, + cols: 5, + orientation: 'landscape', + name: 'Test', + fontSize: 16, + pAnyStart: 0.25, + pAllStart: 0, + interpolate: true, + difficultyProfile: 'earlyLearner', + includeAnswerKey: true, + includeQRCode: false, + displayRules: { + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', + borrowNotation: 'whenRegrouping', + borrowingHints: 'always', + problemNumbers: 'always', + cellBorders: 'never', + }, + }), + createdAt: new Date('2026-01-15T10:00:00Z'), + views: 5, + title: 'Morning Practice', + } + + beforeEach(() => { + mockFindFirst.mockResolvedValue(mockShareRecord) + }) + + it('returns worksheet info for valid share ID', async () => { + const result = await getWorksheetInfo('testshare12') + + expect(result.shareId).toBe('testshare12') + expect(result.title).toBe('Morning Practice') + expect(result.worksheetType).toBe('addition') + expect(result.views).toBe(5) + }) + + it('returns correct URLs', async () => { + const result = await getWorksheetInfo('testshare12') + + expect(result.shareUrl).toContain('/worksheets/shared/testshare12') + expect(result.downloadUrl).toContain('/api/worksheets/download/testshare12') + }) + + it('parses config correctly', async () => { + const result = await getWorksheetInfo('testshare12') + + expect(result.config.operator).toBe('addition') + expect(result.config.digitRange).toEqual({ min: 2, max: 3 }) + expect(result.config.totalProblems).toBe(40) // 20 * 2 + expect(result.config.pages).toBe(2) + expect(result.config.problemsPerPage).toBe(20) + expect(result.config.cols).toBe(5) + expect(result.config.orientation).toBe('landscape') + }) + + it('identifies difficulty profile', async () => { + const result = await getWorksheetInfo('testshare12') + + expect(result.config.difficultyProfile).toBe('earlyLearner') + expect(result.config.difficultyLabel).toBe('Early Learner') + expect(result.config.regroupingPercent).toBe(25) + }) + + it('returns createdAt as ISO string', async () => { + const result = await getWorksheetInfo('testshare12') + + expect(result.createdAt).toBe('2026-01-15T10:00:00.000Z') + }) + + it('throws error for invalid share ID format', async () => { + ;(isValidShareId as ReturnType).mockReturnValueOnce(false) + + await expect(getWorksheetInfo('invalid')).rejects.toThrow('Invalid share ID format') + }) + + it('throws error when share not found', async () => { + mockFindFirst.mockResolvedValue(null) + + await expect(getWorksheetInfo('notfound12')).rejects.toThrow('Worksheet not found') + }) + + it('handles worksheet without title', async () => { + mockFindFirst.mockResolvedValue({ + ...mockShareRecord, + title: null, + }) + + const result = await getWorksheetInfo('testshare12') + + expect(result.title).toBeNull() + }) + + it('handles custom profile (no matching preset)', async () => { + mockFindFirst.mockResolvedValue({ + ...mockShareRecord, + config: JSON.stringify({ + version: 4, + mode: 'custom', + operator: 'addition', + digitRange: { min: 2, max: 2 }, + problemsPerPage: 20, + pages: 1, + cols: 5, + orientation: 'landscape', + name: 'Custom Test', + fontSize: 16, + pAnyStart: 0.5, // Not matching any preset + pAllStart: 0.1, + interpolate: true, + includeAnswerKey: false, + includeQRCode: false, + // No difficultyProfile field + displayRules: { + carryBoxes: 'always', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'always', + borrowNotation: 'always', + borrowingHints: 'always', + problemNumbers: 'always', + cellBorders: 'never', + }, + }), + }) + + const result = await getWorksheetInfo('testshare12') + + expect(result.config.difficultyProfile).toBe('custom') + expect(result.config.difficultyLabel).toBe('Custom') + }) + }) +}) diff --git a/apps/web/src/lib/mcp/resources.ts b/apps/web/src/lib/mcp/resources.ts new file mode 100644 index 00000000..0deaf232 --- /dev/null +++ b/apps/web/src/lib/mcp/resources.ts @@ -0,0 +1,126 @@ +/** + * MCP Resources - Static documentation accessible to MCP consumers + * + * Resources are read-only data that provide context to language models. + * This module exposes worksheet pedagogy documentation. + */ + +import fs from 'fs' +import path from 'path' + +export interface McpResource { + uri: string + name: string + description: string + mimeType: string +} + +export interface McpResourceContent { + uri: string + mimeType: string + text: string +} + +/** + * Registry of available resources + * URIs use the format: docs://worksheet/{topic} + */ +const RESOURCE_REGISTRY: McpResource[] = [ + { + uri: 'docs://worksheet/regrouping', + name: 'Regrouping (Carrying/Borrowing)', + description: + 'What regrouping means pedagogically, and how pAnyStart/pAllStart control problem difficulty', + mimeType: 'text/markdown', + }, + { + uri: 'docs://worksheet/scaffolding', + name: 'Scaffolding Options', + description: + 'Visual aids on worksheets: carryBoxes, answerBoxes, placeValueColors, tenFrames, and display rule values', + mimeType: 'text/markdown', + }, + { + uri: 'docs://worksheet/difficulty-profiles', + name: 'Difficulty Profiles', + description: + 'The six preset profiles (beginner → expert), when to use each, and progression philosophy', + mimeType: 'text/markdown', + }, + { + uri: 'docs://worksheet/digit-range', + name: 'Digit Range', + description: 'How digitRange.min and digitRange.max affect problem complexity', + mimeType: 'text/markdown', + }, + { + uri: 'docs://worksheet/operators', + name: 'Operators (Addition/Subtraction/Mixed)', + description: + 'Difference between operators, pedagogical sequence, and scaffolding differences', + mimeType: 'text/markdown', + }, +] + +/** + * Map URIs to file paths + * Files are stored in docs/mcp/worksheet/ + */ +function getFilePath(uri: string): string | null { + const prefix = 'docs://worksheet/' + if (!uri.startsWith(prefix)) { + return null + } + + const topic = uri.slice(prefix.length) + + // Validate topic to prevent directory traversal + if (!/^[a-z-]+$/.test(topic)) { + return null + } + + // Construct path relative to the project root + // In Next.js, process.cwd() is the project root (apps/web) + return path.join(process.cwd(), 'docs', 'mcp', 'worksheet', `${topic}.md`) +} + +/** + * List all available resources + */ +export function listResources(): { resources: McpResource[] } { + return { resources: RESOURCE_REGISTRY } +} + +/** + * Read a specific resource by URI + */ +export function readResource(uri: string): { contents: McpResourceContent[] } | { error: string } { + // Find resource in registry + const resource = RESOURCE_REGISTRY.find((r) => r.uri === uri) + if (!resource) { + return { error: `Resource not found: ${uri}` } + } + + // Get file path + const filePath = getFilePath(uri) + if (!filePath) { + return { error: `Invalid resource URI: ${uri}` } + } + + // Read file content + try { + const text = fs.readFileSync(filePath, 'utf-8') + return { + contents: [ + { + uri, + mimeType: resource.mimeType, + text, + }, + ], + } + } catch (err) { + console.error(`Failed to read resource ${uri}:`, err) + return { error: `Failed to read resource: ${uri}` } + } +} diff --git a/apps/web/src/lib/mcp/tools.ts b/apps/web/src/lib/mcp/tools.ts index b6ef6902..67238535 100644 --- a/apps/web/src/lib/mcp/tools.ts +++ b/apps/web/src/lib/mcp/tools.ts @@ -4,7 +4,7 @@ import { eq, or, inArray, desc, and, gte } from 'drizzle-orm' import { db, schema } from '@/db' -import { parentChild } from '@/db/schema' +import { parentChild, worksheetShares } from '@/db/schema' import type { GameBreakSettings } from '@/db/schema/session-plans' import { getAllSkillMastery, getRecentSessions } from '@/lib/curriculum/progress-manager' import { @@ -23,6 +23,18 @@ import { type ShareDuration, } from '@/lib/session-share' import { getShareUrl } from '@/lib/share/urls' +import { generateShareId, isValidShareId } from '@/lib/generateShareId' +import { + DIFFICULTY_PROFILES, + DIFFICULTY_PROGRESSION, + type DifficultyProfile, +} from '@/app/create/worksheets/difficultyProfiles' +import { + serializeAdditionConfig, + parseAdditionConfig, + defaultAdditionConfig, + type AdditionConfigV4Custom, +} from '@/app/create/worksheets/config-schemas' // Tool definitions for MCP tools/list response export const MCP_TOOLS = [ @@ -288,6 +300,91 @@ export const MCP_TOOLS = [ required: ['player_id', 'session_id'], }, }, + // Worksheet generation tools + { + name: 'generate_worksheet', + description: + 'Generate a math worksheet with configurable difficulty, scaffolding, and layout. Returns share and download URLs.', + inputSchema: { + type: 'object', + properties: { + operator: { + type: 'string', + enum: ['addition', 'subtraction', 'mixed'], + description: 'Type of math operation (default: addition)', + }, + digit_range: { + type: 'object', + description: 'Range of digits per number (default: { min: 2, max: 2 })', + properties: { + min: { + type: 'number', + description: 'Minimum digits (1-5)', + }, + max: { + type: 'number', + description: 'Maximum digits (1-5)', + }, + }, + }, + problems_per_page: { + type: 'number', + description: 'Number of problems per page (default: 20)', + }, + pages: { + type: 'number', + description: 'Number of pages (default: 1)', + }, + difficulty_profile: { + type: 'string', + enum: ['beginner', 'earlyLearner', 'practice', 'intermediate', 'advanced', 'expert'], + description: 'Preset difficulty profile that controls regrouping and scaffolding', + }, + include_answer_key: { + type: 'boolean', + description: 'Include answer key pages at end (default: false)', + }, + title: { + type: 'string', + description: 'Optional title for the worksheet', + }, + orientation: { + type: 'string', + enum: ['portrait', 'landscape'], + description: 'Page orientation (default: landscape)', + }, + cols: { + type: 'number', + description: 'Number of columns (1-6, default: 5)', + }, + }, + required: [], + }, + }, + { + name: 'get_worksheet_info', + description: 'Get information about an existing shared worksheet by its share ID.', + inputSchema: { + type: 'object', + properties: { + share_id: { + type: 'string', + description: 'The worksheet share ID', + }, + }, + required: ['share_id'], + }, + }, + { + name: 'list_difficulty_profiles', + description: + 'List all available difficulty profiles with their regrouping and scaffolding settings.', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, ] /** @@ -763,3 +860,227 @@ export async function listObservationLinks(playerId: string, sessionId: string) })), } } + +// ============================================================================ +// Worksheet Generation Tools +// ============================================================================ + +/** + * Generate a worksheet with the given configuration + * Returns share and download URLs + */ +export async function generateWorksheet(options: { + operator?: 'addition' | 'subtraction' | 'mixed' + digitRange?: { min: number; max: number } + problemsPerPage?: number + pages?: number + difficultyProfile?: string + includeAnswerKey?: boolean + title?: string + orientation?: 'portrait' | 'landscape' + cols?: number +}) { + const baseUrl = getBaseUrl() + + // Build config from options, using defaults + const { + operator = 'addition', + digitRange = { min: 2, max: 2 }, + problemsPerPage = 20, + pages = 1, + difficultyProfile = 'earlyLearner', + includeAnswerKey = false, + title = '', + orientation = 'landscape', + cols = 5, + } = options + + // Validate digit range + const validDigitRange = { + min: Math.max(1, Math.min(5, digitRange.min || 2)), + max: Math.max(1, Math.min(5, digitRange.max || 2)), + } + if (validDigitRange.min > validDigitRange.max) { + validDigitRange.max = validDigitRange.min + } + + // Get difficulty profile settings + const profile = DIFFICULTY_PROFILES[difficultyProfile] || DIFFICULTY_PROFILES.earlyLearner + + // Build worksheet config + const config: AdditionConfigV4Custom = { + version: 4, + mode: 'custom', + operator, + digitRange: validDigitRange, + problemsPerPage: Math.max(1, Math.min(40, problemsPerPage)), + pages: Math.max(1, Math.min(20, pages)), + cols: Math.max(1, Math.min(6, cols)), + orientation, + name: title, + fontSize: 16, + pAnyStart: profile.regrouping.pAnyStart, + pAllStart: profile.regrouping.pAllStart, + interpolate: true, + displayRules: profile.displayRules, + difficultyProfile: profile.name, + includeAnswerKey, + includeQRCode: true, // Always include QR code for MCP-generated worksheets + seed: Math.floor(Math.random() * 1000000), // Random seed for unique problems + } + + // Generate unique share ID + let shareId = generateShareId() + let attempts = 0 + const MAX_ATTEMPTS = 5 + let isUnique = false + + while (!isUnique && attempts < MAX_ATTEMPTS) { + shareId = generateShareId() + const existing = await db.query.worksheetShares.findFirst({ + where: eq(worksheetShares.id, shareId), + }) + + if (!existing) { + isUnique = true + } else { + attempts++ + } + } + + if (!isUnique) { + throw new Error('Failed to generate unique share ID') + } + + // Serialize config + const configJson = serializeAdditionConfig(config) + + // Create share record + await db.insert(worksheetShares).values({ + id: shareId, + worksheetType: 'addition', + config: configJson, + createdAt: new Date(), + views: 0, + creatorIp: 'mcp', // Mark as MCP-generated + title: title || null, + }) + + // Build URLs + const shareUrl = `${baseUrl}/worksheets/shared/${shareId}` + const downloadUrl = `${baseUrl}/api/worksheets/download/${shareId}` + + // Build summary + const totalProblems = config.problemsPerPage * config.pages + const summary = { + shareId, + operator: config.operator, + digitRange: config.digitRange, + totalProblems, + pages: config.pages, + problemsPerPage: config.problemsPerPage, + cols: config.cols, + orientation: config.orientation, + difficultyProfile: profile.name, + difficultyLabel: profile.label, + regroupingPercent: Math.round(profile.regrouping.pAnyStart * 100), + includeAnswerKey: config.includeAnswerKey, + scaffolding: { + carryBoxes: profile.displayRules.carryBoxes, + answerBoxes: profile.displayRules.answerBoxes, + placeValueColors: profile.displayRules.placeValueColors, + tenFrames: profile.displayRules.tenFrames, + }, + } + + return { + shareId, + shareUrl, + downloadUrl, + summary, + } +} + +/** + * Get information about an existing shared worksheet + */ +export async function getWorksheetInfo(shareId: string) { + // Validate ID format + if (!isValidShareId(shareId)) { + throw new Error('Invalid share ID format') + } + + // Fetch share record + const share = await db.query.worksheetShares.findFirst({ + where: eq(worksheetShares.id, shareId), + }) + + if (!share) { + throw new Error('Worksheet not found') + } + + // Parse config + const config = parseAdditionConfig(share.config) + const baseUrl = getBaseUrl() + + // Find matching difficulty profile + let matchedProfile: DifficultyProfile | undefined + if (config.mode === 'custom' && config.difficultyProfile) { + matchedProfile = DIFFICULTY_PROFILES[config.difficultyProfile] + } + + const totalProblems = config.problemsPerPage * config.pages + + return { + shareId: share.id, + shareUrl: `${baseUrl}/worksheets/shared/${share.id}`, + downloadUrl: `${baseUrl}/api/worksheets/download/${share.id}`, + title: share.title, + worksheetType: share.worksheetType, + createdAt: share.createdAt.toISOString(), + views: share.views, + config: { + operator: config.operator, + digitRange: config.digitRange, + totalProblems, + pages: config.pages, + problemsPerPage: config.problemsPerPage, + cols: config.cols, + orientation: config.orientation, + difficultyProfile: matchedProfile?.name || 'custom', + difficultyLabel: matchedProfile?.label || 'Custom', + regroupingPercent: Math.round(config.pAnyStart * 100), + includeAnswerKey: config.includeAnswerKey || false, + }, + } +} + +/** + * List all available difficulty profiles + */ +export function listDifficultyProfiles() { + return { + profiles: DIFFICULTY_PROGRESSION.map((name) => { + const profile = DIFFICULTY_PROFILES[name] + return { + name: profile.name, + label: profile.label, + description: profile.description, + regrouping: { + pAnyStart: profile.regrouping.pAnyStart, + pAllStart: profile.regrouping.pAllStart, + percent: Math.round(profile.regrouping.pAnyStart * 100), + }, + scaffolding: { + carryBoxes: profile.displayRules.carryBoxes, + answerBoxes: profile.displayRules.answerBoxes, + placeValueColors: profile.displayRules.placeValueColors, + tenFrames: profile.displayRules.tenFrames, + borrowNotation: profile.displayRules.borrowNotation, + borrowingHints: profile.displayRules.borrowingHints, + }, + } + }), + progression: DIFFICULTY_PROGRESSION, + } +}