@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/LICENSE +21 -0
- package/README.md +287 -0
- package/SKILL.md +79 -0
- package/dist/db.d.ts +66 -0
- package/dist/db.js +188 -0
- package/dist/fsrs.d.ts +10 -0
- package/dist/fsrs.js +101 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +226 -0
- package/dist/questions.d.ts +17 -0
- package/dist/questions.js +906 -0
- package/dist/readiness.d.ts +15 -0
- package/dist/readiness.js +272 -0
- package/dist/study-plan.d.ts +25 -0
- package/dist/study-plan.js +313 -0
- package/dist/tools.d.ts +132 -0
- package/dist/tools.js +424 -0
- package/llms.txt +83 -0
- package/package.json +30 -0
- package/src/db.ts +257 -0
- package/src/fsrs.ts +118 -0
- package/src/index.ts +262 -0
- package/src/questions.ts +962 -0
- package/src/readiness.ts +268 -0
- package/src/study-plan.ts +364 -0
- package/src/tools.ts +557 -0
- package/tsconfig.json +16 -0
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
|
+
});
|