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 <noreply@anthropic.com>
This commit is contained in:
parent
9f483b142e
commit
633c789338
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<string, unknown>
|
||||
): Promise<unknown> {
|
||||
// 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<string, unknown>
|
||||
): Promise<unknown> {
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof import('child_process')>()
|
||||
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<typeof vi.fn>
|
||||
|
||||
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<typeof vi.fn>).mockReturnValue(true)
|
||||
mockFindFirst.mockResolvedValue(validShareRecord)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid share ID format', async () => {
|
||||
;(isValidShareId as ReturnType<typeof vi.fn>).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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof vi.fn>
|
||||
const mockFindFirst = db.query.worksheetShares.findFirst as ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset the mock to return a unique value
|
||||
;(generateShareId as ReturnType<typeof vi.fn>).mockReturnValue('abcdef1234')
|
||||
// Reset isValidShareId to return true by default
|
||||
;(isValidShareId as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}` }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue