@gonzih/exam-prep-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db.ts ADDED
@@ -0,0 +1,257 @@
1
+ import Database from 'better-sqlite3';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { mkdirSync } from 'fs';
5
+
6
+ export interface Card {
7
+ id: string;
8
+ profile_id: string;
9
+ subject: string;
10
+ topic: string;
11
+ question: string;
12
+ answer: string;
13
+ difficulty: number;
14
+ stability: number;
15
+ retrievability: number;
16
+ next_review: string | null;
17
+ review_count: number;
18
+ last_rating: number | null;
19
+ created_at: string;
20
+ }
21
+
22
+ export interface Session {
23
+ id: string;
24
+ profile_id: string;
25
+ exam_type: string;
26
+ started_at: string;
27
+ ended_at: string | null;
28
+ questions_answered: number;
29
+ correct: number;
30
+ readiness_score: number | null;
31
+ }
32
+
33
+ export interface ExamConfig {
34
+ profile_id: string;
35
+ exam_type: string;
36
+ exam_date: string;
37
+ current_level: string;
38
+ created_at: string;
39
+ }
40
+
41
+ export interface TopicStats {
42
+ profile_id: string;
43
+ topic: string;
44
+ subject: string;
45
+ total_attempts: number;
46
+ correct_attempts: number;
47
+ last_attempt: string | null;
48
+ }
49
+
50
+ let db: Database.Database | null = null;
51
+
52
+ export function getDb(): Database.Database {
53
+ if (!db) {
54
+ db = initDb();
55
+ }
56
+ return db;
57
+ }
58
+
59
+ export function initDb(): Database.Database {
60
+ const dbPath = process.env.EXAM_PREP_DB || join(homedir(), '.exam-prep', 'study.sqlite');
61
+ const dir = dbPath.substring(0, dbPath.lastIndexOf('/'));
62
+ mkdirSync(dir, { recursive: true });
63
+
64
+ const database = new Database(dbPath);
65
+ database.pragma('journal_mode = WAL');
66
+
67
+ database.exec(`
68
+ CREATE TABLE IF NOT EXISTS cards (
69
+ id TEXT PRIMARY KEY,
70
+ profile_id TEXT NOT NULL,
71
+ subject TEXT NOT NULL,
72
+ topic TEXT NOT NULL,
73
+ question TEXT NOT NULL,
74
+ answer TEXT NOT NULL,
75
+ difficulty REAL DEFAULT 0.3,
76
+ stability REAL DEFAULT 1.0,
77
+ retrievability REAL DEFAULT 1.0,
78
+ next_review TEXT,
79
+ review_count INTEGER DEFAULT 0,
80
+ last_rating INTEGER,
81
+ created_at TEXT NOT NULL
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS sessions (
85
+ id TEXT PRIMARY KEY,
86
+ profile_id TEXT NOT NULL,
87
+ exam_type TEXT NOT NULL,
88
+ started_at TEXT NOT NULL,
89
+ ended_at TEXT,
90
+ questions_answered INTEGER DEFAULT 0,
91
+ correct INTEGER DEFAULT 0,
92
+ readiness_score REAL
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS exam_config (
96
+ profile_id TEXT PRIMARY KEY,
97
+ exam_type TEXT NOT NULL,
98
+ exam_date TEXT NOT NULL,
99
+ current_level TEXT NOT NULL,
100
+ created_at TEXT NOT NULL
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS topic_stats (
104
+ profile_id TEXT NOT NULL,
105
+ topic TEXT NOT NULL,
106
+ subject TEXT NOT NULL,
107
+ total_attempts INTEGER DEFAULT 0,
108
+ correct_attempts INTEGER DEFAULT 0,
109
+ last_attempt TEXT,
110
+ PRIMARY KEY (profile_id, topic)
111
+ );
112
+ `);
113
+
114
+ db = database;
115
+ return database;
116
+ }
117
+
118
+ // Card CRUD
119
+ export function insertCard(card: Card): void {
120
+ const database = getDb();
121
+ database.prepare(`
122
+ INSERT OR IGNORE INTO cards
123
+ (id, profile_id, subject, topic, question, answer, difficulty, stability, retrievability, next_review, review_count, last_rating, created_at)
124
+ VALUES
125
+ (@id, @profile_id, @subject, @topic, @question, @answer, @difficulty, @stability, @retrievability, @next_review, @review_count, @last_rating, @created_at)
126
+ `).run(card);
127
+ }
128
+
129
+ export function getCard(id: string): Card | undefined {
130
+ return getDb().prepare('SELECT * FROM cards WHERE id = ?').get(id) as Card | undefined;
131
+ }
132
+
133
+ export function updateCard(card: Partial<Card> & { id: string }): void {
134
+ const database = getDb();
135
+ database.prepare(`
136
+ UPDATE cards SET
137
+ difficulty = @difficulty,
138
+ stability = @stability,
139
+ retrievability = @retrievability,
140
+ next_review = @next_review,
141
+ review_count = @review_count,
142
+ last_rating = @last_rating
143
+ WHERE id = @id
144
+ `).run(card);
145
+ }
146
+
147
+ export function getDueCards(profileId: string, now: string, limit: number): Card[] {
148
+ return getDb().prepare(`
149
+ SELECT * FROM cards
150
+ WHERE profile_id = ? AND (next_review IS NULL OR next_review <= ?)
151
+ ORDER BY next_review ASC NULLS FIRST
152
+ LIMIT ?
153
+ `).all(profileId, now, limit) as Card[];
154
+ }
155
+
156
+ export function getNewCards(profileId: string, limit: number): Card[] {
157
+ return getDb().prepare(`
158
+ SELECT * FROM cards
159
+ WHERE profile_id = ? AND review_count = 0
160
+ ORDER BY created_at ASC
161
+ LIMIT ?
162
+ `).all(profileId, limit) as Card[];
163
+ }
164
+
165
+ export function getCardsByProfile(profileId: string): Card[] {
166
+ return getDb().prepare('SELECT * FROM cards WHERE profile_id = ?').all(profileId) as Card[];
167
+ }
168
+
169
+ export function countCardsByQuestion(profileId: string, question: string): number {
170
+ const row = getDb().prepare('SELECT COUNT(*) as cnt FROM cards WHERE profile_id = ? AND question = ?').get(profileId, question) as { cnt: number };
171
+ return row.cnt;
172
+ }
173
+
174
+ export function getCardsByTopic(profileId: string, topic: string, limit: number): Card[] {
175
+ return getDb().prepare(`
176
+ SELECT * FROM cards WHERE profile_id = ? AND topic = ?
177
+ ORDER BY RANDOM() LIMIT ?
178
+ `).all(profileId, topic, limit) as Card[];
179
+ }
180
+
181
+ export function getAllCardsForExam(profileId: string, subject: string, limit: number): Card[] {
182
+ return getDb().prepare(`
183
+ SELECT * FROM cards WHERE profile_id = ? AND subject = ?
184
+ ORDER BY RANDOM() LIMIT ?
185
+ `).all(profileId, subject, limit) as Card[];
186
+ }
187
+
188
+ // Session CRUD
189
+ export function insertSession(session: Session): void {
190
+ getDb().prepare(`
191
+ INSERT INTO sessions (id, profile_id, exam_type, started_at, ended_at, questions_answered, correct, readiness_score)
192
+ VALUES (@id, @profile_id, @exam_type, @started_at, @ended_at, @questions_answered, @correct, @readiness_score)
193
+ `).run(session);
194
+ }
195
+
196
+ export function updateSession(session: Partial<Session> & { id: string }): void {
197
+ getDb().prepare(`
198
+ UPDATE sessions SET
199
+ ended_at = @ended_at,
200
+ questions_answered = @questions_answered,
201
+ correct = @correct,
202
+ readiness_score = @readiness_score
203
+ WHERE id = @id
204
+ `).run(session);
205
+ }
206
+
207
+ export function getSession(id: string): Session | undefined {
208
+ return getDb().prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
209
+ }
210
+
211
+ export function getRecentSessions(profileId: string, limit: number): Session[] {
212
+ return getDb().prepare(`
213
+ SELECT * FROM sessions WHERE profile_id = ? ORDER BY started_at DESC LIMIT ?
214
+ `).all(profileId, limit) as Session[];
215
+ }
216
+
217
+ // Exam Config CRUD
218
+ export function upsertExamConfig(config: ExamConfig): void {
219
+ getDb().prepare(`
220
+ INSERT OR REPLACE INTO exam_config (profile_id, exam_type, exam_date, current_level, created_at)
221
+ VALUES (@profile_id, @exam_type, @exam_date, @current_level, @created_at)
222
+ `).run(config);
223
+ }
224
+
225
+ export function getExamConfig(profileId: string): ExamConfig | undefined {
226
+ return getDb().prepare('SELECT * FROM exam_config WHERE profile_id = ?').get(profileId) as ExamConfig | undefined;
227
+ }
228
+
229
+ // Topic Stats CRUD
230
+ export function upsertTopicStats(stats: TopicStats): void {
231
+ getDb().prepare(`
232
+ INSERT INTO topic_stats (profile_id, topic, subject, total_attempts, correct_attempts, last_attempt)
233
+ VALUES (@profile_id, @topic, @subject, @total_attempts, @correct_attempts, @last_attempt)
234
+ ON CONFLICT(profile_id, topic) DO UPDATE SET
235
+ total_attempts = topic_stats.total_attempts + @total_attempts,
236
+ correct_attempts = topic_stats.correct_attempts + @correct_attempts,
237
+ last_attempt = @last_attempt,
238
+ subject = @subject
239
+ `).run(stats);
240
+ }
241
+
242
+ export function getTopicStats(profileId: string): TopicStats[] {
243
+ return getDb().prepare('SELECT * FROM topic_stats WHERE profile_id = ?').all(profileId) as TopicStats[];
244
+ }
245
+
246
+ export function getTopicStat(profileId: string, topic: string): TopicStats | undefined {
247
+ return getDb().prepare('SELECT * FROM topic_stats WHERE profile_id = ? AND topic = ?').get(profileId, topic) as TopicStats | undefined;
248
+ }
249
+
250
+ export function getWeakTopics(profileId: string, threshold: number): TopicStats[] {
251
+ return getDb().prepare(`
252
+ SELECT * FROM topic_stats
253
+ WHERE profile_id = ? AND total_attempts >= 3
254
+ AND CAST(correct_attempts AS REAL) / total_attempts < ?
255
+ ORDER BY CAST(correct_attempts AS REAL) / total_attempts ASC
256
+ `).all(profileId, threshold) as TopicStats[];
257
+ }
package/src/fsrs.ts ADDED
@@ -0,0 +1,118 @@
1
+ // FSRS-5 Spaced Repetition Algorithm Implementation
2
+
3
+ const FSRS_PARAMS = {
4
+ w: [
5
+ 0.4072, 1.1829, 3.1262, 15.4722, 7.2102, 0.5316, 1.0651, 0.0589,
6
+ 1.5330, 0.1544, 1.0071, 1.9299, 0.1100, 0.2900, 2.2700, 0.2500, 2.9898
7
+ ]
8
+ };
9
+
10
+ // Rating: 1=Again, 2=Hard, 3=Good, 4=Easy
11
+ export interface FSRSResult {
12
+ stability: number;
13
+ difficulty: number;
14
+ retrievability: number;
15
+ nextReview: Date;
16
+ intervalDays: number;
17
+ }
18
+
19
+ export function scheduleReview(
20
+ rating: 1 | 2 | 3 | 4,
21
+ currentStability: number,
22
+ currentDifficulty: number,
23
+ reviewCount: number,
24
+ lastReview?: Date
25
+ ): FSRSResult {
26
+ const w = FSRS_PARAMS.w;
27
+ let stability: number;
28
+ let difficulty: number;
29
+ let retrievability: number;
30
+
31
+ if (reviewCount === 0) {
32
+ // Initial scheduling for new cards
33
+ switch (rating) {
34
+ case 1: stability = w[0]; break; // Again
35
+ case 2: stability = w[1]; break; // Hard
36
+ case 3: stability = w[2]; break; // Good
37
+ case 4: stability = w[3]; break; // Easy
38
+ }
39
+
40
+ // Initial difficulty
41
+ difficulty = w[4] - Math.exp(w[5] * (rating - 1)) + 1;
42
+ difficulty = Math.max(1, Math.min(10, difficulty));
43
+ retrievability = 1.0;
44
+ } else {
45
+ // Review scheduling
46
+ const now = new Date();
47
+ const elapsed = lastReview
48
+ ? (now.getTime() - lastReview.getTime()) / (1000 * 60 * 60 * 24)
49
+ : 1;
50
+
51
+ // R(t) = e^(-0.9 * t / S)
52
+ retrievability = calculateRetrievability(currentStability, elapsed);
53
+
54
+ // Update difficulty using mean reversion
55
+ const deltaDifficulty = -w[6] * (rating - 3);
56
+ difficulty = currentDifficulty + deltaDifficulty * (10 - currentDifficulty) / 9;
57
+ difficulty = Math.max(1, Math.min(10, difficulty));
58
+
59
+ // Calculate new stability based on rating
60
+ let stabilityMultiplier: number;
61
+ if (rating === 1) {
62
+ // Forgetting — reset stability
63
+ stability = w[11] * Math.pow(difficulty, -w[12]) *
64
+ (Math.pow(currentStability + 1, w[13]) - 1) *
65
+ Math.exp(w[14] * (1 - retrievability));
66
+ } else {
67
+ // Recall — increase stability
68
+ const ratingFactor = rating === 2 ? w[15] : (rating === 4 ? w[16] : 1.0);
69
+ stabilityMultiplier = Math.exp(w[8]) *
70
+ (11 - difficulty) *
71
+ Math.pow(currentStability, -w[9]) *
72
+ (Math.exp(w[10] * (1 - retrievability)) - 1) *
73
+ ratingFactor;
74
+ stability = currentStability * (1 + stabilityMultiplier);
75
+ }
76
+
77
+ stability = Math.max(0.1, stability);
78
+ }
79
+
80
+ // Calculate next review interval
81
+ // For target retrievability of 0.9: interval = S * ln(0.9) / ln(R_target)
82
+ // Simplified: interval ≈ stability (days to reach 90% retrievability)
83
+ const targetRetrievability = 0.9;
84
+ let intervalDays = Math.round(stability * Math.log(targetRetrievability) / Math.log(0.9));
85
+
86
+ // Clamp interval
87
+ intervalDays = Math.max(1, Math.min(36500, intervalDays));
88
+
89
+ // For "Again" ratings, short review
90
+ if (rating === 1) {
91
+ intervalDays = 1;
92
+ }
93
+
94
+ const nextReview = new Date();
95
+ nextReview.setDate(nextReview.getDate() + intervalDays);
96
+
97
+ return {
98
+ stability,
99
+ difficulty,
100
+ retrievability,
101
+ nextReview,
102
+ intervalDays
103
+ };
104
+ }
105
+
106
+ // R(t) = e^(-t/S) where t = days since last review, S = stability
107
+ export function calculateRetrievability(stability: number, daysSinceReview: number): number {
108
+ if (daysSinceReview <= 0) return 1.0;
109
+ const r = Math.exp(-0.9 * daysSinceReview / stability);
110
+ return Math.max(0, Math.min(1, r));
111
+ }
112
+
113
+ // Map a 0-1 accuracy to a rating
114
+ export function accuracyToRating(correct: boolean, responseTime?: number): 1 | 2 | 3 | 4 {
115
+ if (!correct) return 1; // Again
116
+ if (responseTime && responseTime > 60) return 2; // Hard (took long)
117
+ return 3; // Good (default)
118
+ }
package/src/index.ts ADDED
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import {
5
+ ListToolsRequestSchema,
6
+ CallToolRequestSchema,
7
+ ErrorCode,
8
+ McpError,
9
+ } from '@modelcontextprotocol/sdk/types.js';
10
+ import { initDb } from './db.js';
11
+ import {
12
+ setExam,
13
+ getDailySession,
14
+ answerQuestion,
15
+ startMockExam,
16
+ submitMockExam,
17
+ getReadiness,
18
+ getWeakAreas,
19
+ } from './tools.js';
20
+
21
+ const server = new Server(
22
+ {
23
+ name: 'exam-prep-mcp',
24
+ version: '0.1.0',
25
+ },
26
+ {
27
+ capabilities: {
28
+ tools: {},
29
+ },
30
+ }
31
+ );
32
+
33
+ // ─── List Tools ───────────────────────────────────────────────────────────────
34
+
35
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
36
+ return {
37
+ tools: [
38
+ {
39
+ name: 'set_exam',
40
+ description: 'Configure an exam for a student profile. Seeds question cards and generates a study plan.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ profileId: {
45
+ type: 'string',
46
+ description: 'Unique identifier for the student (e.g., "alice", "student-001")',
47
+ },
48
+ examType: {
49
+ type: 'string',
50
+ enum: [
51
+ 'AP_Biology', 'AP_Chemistry', 'AP_US_History', 'AP_World_History',
52
+ 'AP_Physics', 'AP_Calculus', 'AP_Statistics', 'AP_English',
53
+ 'AP_Economics', 'SAT_Math', 'SAT_Reading', 'SAT_Writing',
54
+ 'MCAT', 'LSAT', 'GRE',
55
+ ],
56
+ description: 'Type of exam to prepare for',
57
+ },
58
+ examDate: {
59
+ type: 'string',
60
+ description: 'Exam date in YYYY-MM-DD format (e.g., "2025-05-15")',
61
+ },
62
+ currentLevel: {
63
+ type: 'string',
64
+ enum: ['beginner', 'intermediate', 'advanced'],
65
+ description: 'Current knowledge level of the student',
66
+ },
67
+ },
68
+ required: ['profileId', 'examType', 'examDate', 'currentLevel'],
69
+ },
70
+ },
71
+ {
72
+ name: 'get_daily_session',
73
+ description: 'Get today\'s personalized practice questions based on spaced repetition scheduling.',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: {
77
+ profileId: {
78
+ type: 'string',
79
+ description: 'Student profile ID',
80
+ },
81
+ },
82
+ required: ['profileId'],
83
+ },
84
+ },
85
+ {
86
+ name: 'answer_question',
87
+ description: 'Submit an answer to a practice question. Updates FSRS scheduling and topic stats.',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: {
91
+ profileId: {
92
+ type: 'string',
93
+ description: 'Student profile ID',
94
+ },
95
+ cardId: {
96
+ type: 'string',
97
+ description: 'The card ID from get_daily_session or start_mock_exam',
98
+ },
99
+ answer: {
100
+ type: 'string',
101
+ description: 'The student\'s answer (for multiple choice: "A", "B", "C", or "D")',
102
+ },
103
+ rating: {
104
+ type: 'number',
105
+ enum: [1, 2, 3, 4],
106
+ description: 'Optional manual difficulty rating: 1=Again, 2=Hard, 3=Good, 4=Easy',
107
+ },
108
+ },
109
+ required: ['profileId', 'cardId', 'answer'],
110
+ },
111
+ },
112
+ {
113
+ name: 'start_mock_exam',
114
+ description: 'Begin a timed full-length practice exam simulating real exam conditions.',
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: {
118
+ profileId: {
119
+ type: 'string',
120
+ description: 'Student profile ID',
121
+ },
122
+ },
123
+ required: ['profileId'],
124
+ },
125
+ },
126
+ {
127
+ name: 'submit_mock_exam',
128
+ description: 'Submit all answers for a mock exam and receive scoring and analysis.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ sessionId: {
133
+ type: 'string',
134
+ description: 'Session ID from start_mock_exam',
135
+ },
136
+ answers: {
137
+ type: 'array',
138
+ description: 'Array of answers for each question',
139
+ items: {
140
+ type: 'object',
141
+ properties: {
142
+ cardId: { type: 'string', description: 'Card ID' },
143
+ answer: { type: 'string', description: 'Student\'s answer' },
144
+ },
145
+ required: ['cardId', 'answer'],
146
+ },
147
+ },
148
+ },
149
+ required: ['sessionId', 'answers'],
150
+ },
151
+ },
152
+ {
153
+ name: 'get_readiness',
154
+ description: 'Get a comprehensive readiness report with score, grade estimate, trends, and recommendations.',
155
+ inputSchema: {
156
+ type: 'object',
157
+ properties: {
158
+ profileId: {
159
+ type: 'string',
160
+ description: 'Student profile ID',
161
+ },
162
+ },
163
+ required: ['profileId'],
164
+ },
165
+ },
166
+ {
167
+ name: 'get_weak_areas',
168
+ description: 'Identify topics with low accuracy that need focused study.',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ profileId: {
173
+ type: 'string',
174
+ description: 'Student profile ID',
175
+ },
176
+ },
177
+ required: ['profileId'],
178
+ },
179
+ },
180
+ ],
181
+ };
182
+ });
183
+
184
+ // ─── Call Tool ────────────────────────────────────────────────────────────────
185
+
186
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
187
+ const { name, arguments: args } = request.params;
188
+
189
+ if (!args) {
190
+ throw new McpError(ErrorCode.InvalidParams, 'No arguments provided');
191
+ }
192
+
193
+ try {
194
+ let result: unknown;
195
+
196
+ switch (name) {
197
+ case 'set_exam':
198
+ result = await setExam(args as unknown as Parameters<typeof setExam>[0]);
199
+ break;
200
+
201
+ case 'get_daily_session':
202
+ result = await getDailySession(args as unknown as Parameters<typeof getDailySession>[0]);
203
+ break;
204
+
205
+ case 'answer_question':
206
+ result = await answerQuestion(args as unknown as Parameters<typeof answerQuestion>[0]);
207
+ break;
208
+
209
+ case 'start_mock_exam':
210
+ result = await startMockExam(args as unknown as Parameters<typeof startMockExam>[0]);
211
+ break;
212
+
213
+ case 'submit_mock_exam':
214
+ result = await submitMockExam(args as unknown as Parameters<typeof submitMockExam>[0]);
215
+ break;
216
+
217
+ case 'get_readiness':
218
+ result = await getReadiness(args as unknown as Parameters<typeof getReadiness>[0]);
219
+ break;
220
+
221
+ case 'get_weak_areas':
222
+ result = await getWeakAreas(args as unknown as Parameters<typeof getWeakAreas>[0]);
223
+ break;
224
+
225
+ default:
226
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
227
+ }
228
+
229
+ return {
230
+ content: [
231
+ {
232
+ type: 'text',
233
+ text: JSON.stringify(result, null, 2),
234
+ },
235
+ ],
236
+ };
237
+ } catch (error) {
238
+ if (error instanceof McpError) {
239
+ throw error;
240
+ }
241
+ const message = error instanceof Error ? error.message : String(error);
242
+ throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${message}`);
243
+ }
244
+ });
245
+
246
+ // ─── Start Server ─────────────────────────────────────────────────────────────
247
+
248
+ async function main() {
249
+ // Initialize database
250
+ initDb();
251
+
252
+ const transport = new StdioServerTransport();
253
+ await server.connect(transport);
254
+
255
+ // Log to stderr so stdout remains clean for MCP protocol
256
+ process.stderr.write('exam-prep-mcp server started\n');
257
+ }
258
+
259
+ main().catch((error) => {
260
+ process.stderr.write(`Fatal error: ${error}\n`);
261
+ process.exit(1);
262
+ });