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:
Thomas Hallock 2026-01-15 15:38:09 -06:00
parent 9f483b142e
commit 633c789338
13 changed files with 2199 additions and 1 deletions

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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
*/

View File

@ -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 }
)
}
}

View File

@ -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')
})
})

View File

@ -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'
)
})
})
})

View File

@ -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')
})
})
})

View File

@ -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}` }
}
}

View File

@ -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,
}
}