@graspful/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/dist/index.js ADDED
@@ -0,0 +1,1046 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
38
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
39
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
40
+ const yaml = __importStar(require("js-yaml"));
41
+ const crypto = __importStar(require("crypto"));
42
+ const schemas_1 = require("./schemas");
43
+ // ─── API Client (mirrors packages/cli/src/lib/api-client.ts) ──────────────
44
+ function getApiCredentials() {
45
+ const baseUrl = (process.env.GRASPFUL_API_URL || 'https://api.graspful.com').replace(/\/$/, '');
46
+ const apiKey = process.env.GRASPFUL_API_KEY;
47
+ if (apiKey) {
48
+ return { baseUrl, authHeader: `Bearer ${apiKey}` };
49
+ }
50
+ return { baseUrl };
51
+ }
52
+ async function apiPost(path, body) {
53
+ const { baseUrl, authHeader } = getApiCredentials();
54
+ const headers = { 'Content-Type': 'application/json' };
55
+ if (authHeader)
56
+ headers['Authorization'] = authHeader;
57
+ const res = await fetch(`${baseUrl}${path}`, {
58
+ method: 'POST',
59
+ headers,
60
+ body: JSON.stringify(body),
61
+ });
62
+ if (!res.ok) {
63
+ const text = await res.text();
64
+ throw new Error(`API error ${res.status}: ${text}`);
65
+ }
66
+ return res.json();
67
+ }
68
+ async function apiGet(path) {
69
+ const { baseUrl, authHeader } = getApiCredentials();
70
+ const headers = { 'Content-Type': 'application/json' };
71
+ if (authHeader)
72
+ headers['Authorization'] = authHeader;
73
+ const res = await fetch(`${baseUrl}${path}`, {
74
+ method: 'GET',
75
+ headers,
76
+ });
77
+ if (!res.ok) {
78
+ const text = await res.text();
79
+ throw new Error(`API error ${res.status}: ${text}`);
80
+ }
81
+ return res.json();
82
+ }
83
+ // ─── Scaffold helpers (mirrors packages/cli/src/commands/create-course.ts) ──
84
+ function scaffoldCourse(topic, options) {
85
+ const slug = topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
86
+ return yaml.dump({
87
+ course: {
88
+ id: slug,
89
+ name: topic,
90
+ description: `Adaptive course on ${topic}`,
91
+ estimatedHours: options.hours || 10,
92
+ version: '2026.1',
93
+ sourceDocument: options.source || 'TODO: Add source document',
94
+ },
95
+ sections: [
96
+ { id: 'foundations', name: 'Foundations', description: 'Core concepts' },
97
+ { id: 'application', name: 'Application', description: 'Applied concepts' },
98
+ ],
99
+ concepts: [
100
+ {
101
+ id: `${slug}-intro`,
102
+ name: `Introduction to ${topic}`,
103
+ section: 'foundations',
104
+ difficulty: 2,
105
+ estimatedMinutes: 15,
106
+ tags: ['foundational'],
107
+ prerequisites: [],
108
+ knowledgePoints: [],
109
+ },
110
+ ],
111
+ }, { lineWidth: 120, noRefs: true });
112
+ }
113
+ function scaffoldBrand(niche, options) {
114
+ const NICHE_PRESETS = {
115
+ education: { preset: 'blue', tagline: 'Learn smarter, not harder', headline: 'Master any subject with adaptive learning' },
116
+ healthcare: { preset: 'emerald', tagline: 'Training that saves lives', headline: 'Adaptive healthcare education for professionals' },
117
+ finance: { preset: 'slate', tagline: 'Build financial expertise', headline: 'Master finance with adaptive learning' },
118
+ tech: { preset: 'indigo', tagline: 'Level up your skills', headline: 'Adaptive tech training that meets you where you are' },
119
+ legal: { preset: 'amber', tagline: 'Know the law, pass the exam', headline: 'Adaptive legal education for exam success' },
120
+ default: { preset: 'blue', tagline: 'Learn adaptively', headline: 'Personalized learning that works' },
121
+ };
122
+ const config = NICHE_PRESETS[niche] || NICHE_PRESETS['default'];
123
+ const slug = (options.name || niche).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
124
+ const name = options.name || `${niche.charAt(0).toUpperCase() + niche.slice(1)} Academy`;
125
+ const domain = options.domain || `${slug}.graspful.com`;
126
+ return yaml.dump({
127
+ brand: {
128
+ id: slug,
129
+ name,
130
+ domain,
131
+ tagline: config.tagline,
132
+ orgSlug: options.orgSlug || 'TODO: your-org-slug',
133
+ },
134
+ theme: {
135
+ preset: config.preset,
136
+ radius: '0.5rem',
137
+ },
138
+ landing: {
139
+ hero: {
140
+ headline: config.headline,
141
+ subheadline: `${name} uses adaptive learning to help you master concepts faster.`,
142
+ ctaText: 'Start Learning',
143
+ },
144
+ features: {
145
+ heading: 'Why choose us?',
146
+ items: [
147
+ { title: 'Adaptive Learning', description: 'Content adapts to your knowledge level', icon: 'brain' },
148
+ { title: 'Spaced Repetition', description: 'Review at optimal intervals for lasting memory', icon: 'clock' },
149
+ { title: 'Progress Tracking', description: 'See exactly where you stand', icon: 'chart' },
150
+ ],
151
+ },
152
+ howItWorks: {
153
+ heading: 'How it works',
154
+ items: [
155
+ { title: 'Take a diagnostic', description: 'We assess what you already know' },
156
+ { title: 'Learn adaptively', description: 'Focus on gaps, skip what you know' },
157
+ { title: 'Master the material', description: 'Prove mastery through progressive challenges' },
158
+ ],
159
+ },
160
+ faq: [],
161
+ },
162
+ seo: {
163
+ title: `${name} — Adaptive Learning`,
164
+ description: config.tagline,
165
+ keywords: [niche, 'learning', 'adaptive', 'education'],
166
+ },
167
+ }, { lineWidth: 120, noRefs: true });
168
+ }
169
+ function detectFileType(data) {
170
+ if (typeof data !== 'object' || data === null)
171
+ return null;
172
+ const obj = data;
173
+ if ('course' in obj)
174
+ return 'course';
175
+ if ('brand' in obj)
176
+ return 'brand';
177
+ if ('academy' in obj)
178
+ return 'academy';
179
+ return null;
180
+ }
181
+ function detectCycles(concepts) {
182
+ const graph = new Map();
183
+ for (const c of concepts) {
184
+ graph.set(c.id, c.prerequisites);
185
+ }
186
+ const visited = new Set();
187
+ const inStack = new Set();
188
+ const cycles = [];
189
+ function dfs(node, path) {
190
+ if (inStack.has(node)) {
191
+ const cycleStart = path.indexOf(node);
192
+ const cycle = path.slice(cycleStart).concat(node);
193
+ cycles.push(`Cycle: ${cycle.join(' -> ')}`);
194
+ return true;
195
+ }
196
+ if (visited.has(node))
197
+ return false;
198
+ visited.add(node);
199
+ inStack.add(node);
200
+ path.push(node);
201
+ for (const dep of graph.get(node) ?? []) {
202
+ dfs(dep, path);
203
+ }
204
+ path.pop();
205
+ inStack.delete(node);
206
+ return false;
207
+ }
208
+ for (const id of graph.keys()) {
209
+ if (!visited.has(id)) {
210
+ dfs(id, []);
211
+ }
212
+ }
213
+ return cycles;
214
+ }
215
+ function validateYaml(yamlStr) {
216
+ let raw;
217
+ try {
218
+ raw = yaml.load(yamlStr);
219
+ }
220
+ catch (e) {
221
+ const msg = e instanceof Error ? e.message : String(e);
222
+ return { valid: false, errors: [`YAML parse error: ${msg}`], stats: {} };
223
+ }
224
+ const fileType = detectFileType(raw);
225
+ if (!fileType) {
226
+ return { valid: false, errors: ['Could not detect file type. Expected top-level key: course, brand, or academy'], stats: {} };
227
+ }
228
+ const schemaMap = {
229
+ course: schemas_1.CourseYamlSchema,
230
+ brand: schemas_1.BrandYamlSchema,
231
+ academy: schemas_1.AcademyManifestSchema,
232
+ };
233
+ const result = schemaMap[fileType].safeParse(raw);
234
+ if (!result.success) {
235
+ const errors = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
236
+ return { valid: false, fileType, errors, stats: {} };
237
+ }
238
+ // For courses, also check DAG
239
+ const dagErrors = [];
240
+ let stats = { fileType };
241
+ if (fileType === 'course') {
242
+ const data = result.data;
243
+ const conceptIds = new Set(data.concepts.map((c) => c.id));
244
+ for (const concept of data.concepts) {
245
+ for (const prereq of concept.prerequisites) {
246
+ if (!conceptIds.has(prereq)) {
247
+ dagErrors.push(`Concept "${concept.id}" has unknown prerequisite "${prereq}"`);
248
+ }
249
+ }
250
+ }
251
+ const cycles = detectCycles(data.concepts.map((c) => ({ id: c.id, prerequisites: c.prerequisites })));
252
+ dagErrors.push(...cycles);
253
+ const kpCount = data.concepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
254
+ const problemCount = data.concepts.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
255
+ stats = { fileType, concepts: data.concepts.length, knowledgePoints: kpCount, problems: problemCount };
256
+ }
257
+ if (dagErrors.length > 0) {
258
+ return { valid: false, fileType, errors: dagErrors, stats };
259
+ }
260
+ return { valid: true, fileType, errors: [], stats };
261
+ }
262
+ // ─── Review helpers (mirrors packages/cli/src/commands/review.ts) ───────────
263
+ function checkYamlParses(raw) {
264
+ const result = schemas_1.CourseYamlSchema.safeParse(raw);
265
+ if (result.success) {
266
+ return { check: 'yaml_parses', passed: true };
267
+ }
268
+ const errors = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
269
+ return {
270
+ check: 'yaml_parses',
271
+ passed: false,
272
+ details: `${errors.length} schema error(s): ${errors.slice(0, 5).join('; ')}${errors.length > 5 ? ` (+${errors.length - 5} more)` : ''}`,
273
+ };
274
+ }
275
+ function checkUniqueProblemIds(data) {
276
+ const duplicates = [];
277
+ const seen = new Set();
278
+ for (const concept of data.concepts) {
279
+ for (const kp of concept.knowledgePoints) {
280
+ for (const problem of kp.problems) {
281
+ if (seen.has(problem.id)) {
282
+ duplicates.push(problem.id);
283
+ }
284
+ seen.add(problem.id);
285
+ }
286
+ }
287
+ }
288
+ if (duplicates.length === 0) {
289
+ return { check: 'unique_problem_ids', passed: true };
290
+ }
291
+ return {
292
+ check: 'unique_problem_ids',
293
+ passed: false,
294
+ details: `Duplicate problem IDs: ${duplicates.join(', ')}`,
295
+ };
296
+ }
297
+ function checkPrerequisitesValid(data) {
298
+ const conceptIds = new Set(data.concepts.map((c) => c.id));
299
+ const invalid = [];
300
+ for (const concept of data.concepts) {
301
+ for (const prereq of concept.prerequisites) {
302
+ if (!conceptIds.has(prereq)) {
303
+ invalid.push(`${concept.id} -> ${prereq}`);
304
+ }
305
+ }
306
+ }
307
+ if (invalid.length === 0) {
308
+ return { check: 'prerequisites_valid', passed: true };
309
+ }
310
+ return {
311
+ check: 'prerequisites_valid',
312
+ passed: false,
313
+ details: `Unknown prerequisites: ${invalid.join(', ')}`,
314
+ };
315
+ }
316
+ function normalizeText(text) {
317
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
318
+ }
319
+ function checkQuestionDeduplication(data) {
320
+ const seen = new Map();
321
+ const collisions = [];
322
+ for (const concept of data.concepts) {
323
+ for (const kp of concept.knowledgePoints) {
324
+ for (const problem of kp.problems) {
325
+ const normalized = normalizeText(problem.question);
326
+ const hash = crypto.createHash('md5').update(normalized).digest('hex').substring(0, 12);
327
+ const key = `${hash}-d${problem.difficulty ?? 'none'}`;
328
+ const existing = seen.get(key);
329
+ if (existing) {
330
+ collisions.push(`"${problem.id}" collides with "${existing.problemId}" (same question text at same difficulty)`);
331
+ }
332
+ else {
333
+ seen.set(key, { problemId: problem.id });
334
+ }
335
+ }
336
+ }
337
+ }
338
+ if (collisions.length === 0) {
339
+ return { check: 'question_deduplication', passed: true };
340
+ }
341
+ return {
342
+ check: 'question_deduplication',
343
+ passed: false,
344
+ details: collisions.slice(0, 5).join('; ') + (collisions.length > 5 ? ` (+${collisions.length - 5} more)` : ''),
345
+ };
346
+ }
347
+ function checkDifficultyStaircase(data) {
348
+ const failures = [];
349
+ for (const concept of data.concepts) {
350
+ if (concept.knowledgePoints.length === 0)
351
+ continue;
352
+ const difficulties = new Set();
353
+ for (const kp of concept.knowledgePoints) {
354
+ for (const problem of kp.problems) {
355
+ if (problem.difficulty != null) {
356
+ difficulties.add(problem.difficulty);
357
+ }
358
+ }
359
+ }
360
+ if (difficulties.size > 0 && difficulties.size < 2) {
361
+ failures.push(`"${concept.id}" has problems at only ${difficulties.size} difficulty level(s) — need 2+`);
362
+ }
363
+ }
364
+ if (failures.length === 0) {
365
+ return { check: 'difficulty_staircase', passed: true };
366
+ }
367
+ return {
368
+ check: 'difficulty_staircase',
369
+ passed: false,
370
+ details: failures.slice(0, 5).join('; ') + (failures.length > 5 ? ` (+${failures.length - 5} more)` : ''),
371
+ };
372
+ }
373
+ function extractStems(text) {
374
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter((w) => w.length > 4);
375
+ }
376
+ function checkCrossConceptCoverage(data) {
377
+ const stemConceptCount = new Map();
378
+ for (const concept of data.concepts) {
379
+ for (const kp of concept.knowledgePoints) {
380
+ for (const problem of kp.problems) {
381
+ const stems = extractStems(problem.question);
382
+ for (const stem of stems) {
383
+ if (!stemConceptCount.has(stem)) {
384
+ stemConceptCount.set(stem, new Set());
385
+ }
386
+ stemConceptCount.get(stem).add(concept.id);
387
+ }
388
+ }
389
+ }
390
+ }
391
+ const overused = [];
392
+ for (const [stem, concepts] of stemConceptCount) {
393
+ if (concepts.size > 3) {
394
+ overused.push(`"${stem}" appears across ${concepts.size} concepts`);
395
+ }
396
+ }
397
+ const commonWords = new Set([
398
+ 'which', 'would', 'should', 'could', 'about', 'their', 'there', 'these', 'those',
399
+ 'being', 'between', 'through', 'during', 'before', 'after', 'above', 'below',
400
+ 'following', 'statement', 'answer', 'question', 'correct', 'incorrect',
401
+ 'agent', 'property', 'owner', 'buyer', 'seller',
402
+ ]);
403
+ const meaningfulOverused = overused.filter((entry) => {
404
+ const stem = entry.match(/"([^"]+)"/)?.[1] ?? '';
405
+ return !commonWords.has(stem);
406
+ });
407
+ if (meaningfulOverused.length === 0) {
408
+ return { check: 'cross_concept_coverage', passed: true };
409
+ }
410
+ return {
411
+ check: 'cross_concept_coverage',
412
+ passed: meaningfulOverused.length <= 5,
413
+ details: meaningfulOverused.slice(0, 5).join('; ') + (meaningfulOverused.length > 5 ? ` (+${meaningfulOverused.length - 5} more)` : ''),
414
+ };
415
+ }
416
+ function checkProblemVariantDepth(data) {
417
+ const failures = [];
418
+ for (const concept of data.concepts) {
419
+ if (concept.knowledgePoints.length === 0)
420
+ continue;
421
+ for (const kp of concept.knowledgePoints) {
422
+ if (kp.problems.length < 3) {
423
+ failures.push(`"${concept.id}/${kp.id}" has ${kp.problems.length} problem(s) — need 3+`);
424
+ }
425
+ }
426
+ }
427
+ if (failures.length === 0) {
428
+ return { check: 'problem_variant_depth', passed: true };
429
+ }
430
+ return {
431
+ check: 'problem_variant_depth',
432
+ passed: false,
433
+ details: failures.slice(0, 5).join('; ') + (failures.length > 5 ? ` (+${failures.length - 5} more)` : ''),
434
+ };
435
+ }
436
+ function checkInstructionFormatting(data) {
437
+ const warnings = [];
438
+ for (const concept of data.concepts) {
439
+ for (const kp of concept.knowledgePoints) {
440
+ if (!kp.instruction)
441
+ continue;
442
+ if (kp.instruction.match(/^[\w\-./]+\.(md|txt|html)$/))
443
+ continue;
444
+ const wordCount = kp.instruction.split(/\s+/).filter(Boolean).length;
445
+ const hasContentBlocks = kp.instructionContent && kp.instructionContent.length > 0;
446
+ if (wordCount > 100 && !hasContentBlocks) {
447
+ warnings.push(`"${concept.id}/${kp.id}" instruction is ${wordCount} words with no content blocks`);
448
+ }
449
+ }
450
+ }
451
+ if (warnings.length === 0) {
452
+ return { check: 'instruction_formatting', passed: true };
453
+ }
454
+ return {
455
+ check: 'instruction_formatting',
456
+ passed: false,
457
+ details: warnings.slice(0, 5).join('; ') + (warnings.length > 5 ? ` (+${warnings.length - 5} more)` : ''),
458
+ };
459
+ }
460
+ function checkWorkedExampleCoverage(data) {
461
+ const authoredConcepts = data.concepts.filter((c) => c.knowledgePoints.length > 0);
462
+ if (authoredConcepts.length === 0) {
463
+ return { check: 'worked_example_coverage', passed: true };
464
+ }
465
+ const withExamples = authoredConcepts.filter((c) => c.knowledgePoints.some((kp) => kp.workedExample && kp.workedExample.trim().length > 0));
466
+ const coverage = withExamples.length / authoredConcepts.length;
467
+ if (coverage >= 0.5) {
468
+ return { check: 'worked_example_coverage', passed: true };
469
+ }
470
+ return {
471
+ check: 'worked_example_coverage',
472
+ passed: false,
473
+ details: `${withExamples.length}/${authoredConcepts.length} authored concepts have worked examples (${Math.round(coverage * 100)}%) — need 50%+`,
474
+ };
475
+ }
476
+ function checkImportDryRun(data) {
477
+ const conceptIds = new Set(data.concepts.map((c) => c.id));
478
+ const errors = [];
479
+ for (const concept of data.concepts) {
480
+ for (const prereq of concept.prerequisites) {
481
+ if (!conceptIds.has(prereq)) {
482
+ errors.push(`Unknown prerequisite: ${concept.id} -> ${prereq}`);
483
+ }
484
+ }
485
+ }
486
+ const graph = new Map();
487
+ for (const c of data.concepts) {
488
+ graph.set(c.id, [...c.prerequisites]);
489
+ }
490
+ const visited = new Set();
491
+ const inStack = new Set();
492
+ function hasCycle(node, path) {
493
+ if (inStack.has(node)) {
494
+ const cycleStart = path.indexOf(node);
495
+ const cycle = path.slice(cycleStart).concat(node);
496
+ errors.push(`Cycle detected: ${cycle.join(' -> ')}`);
497
+ return true;
498
+ }
499
+ if (visited.has(node))
500
+ return false;
501
+ visited.add(node);
502
+ inStack.add(node);
503
+ path.push(node);
504
+ let foundCycle = false;
505
+ for (const dep of graph.get(node) ?? []) {
506
+ if (hasCycle(dep, path)) {
507
+ foundCycle = true;
508
+ }
509
+ }
510
+ path.pop();
511
+ inStack.delete(node);
512
+ return foundCycle;
513
+ }
514
+ for (const id of graph.keys()) {
515
+ if (!visited.has(id)) {
516
+ hasCycle(id, []);
517
+ }
518
+ }
519
+ if (errors.length === 0) {
520
+ return { check: 'import_dry_run', passed: true };
521
+ }
522
+ return {
523
+ check: 'import_dry_run',
524
+ passed: false,
525
+ details: errors.join('; '),
526
+ };
527
+ }
528
+ function runReview(yamlStr) {
529
+ let raw;
530
+ try {
531
+ raw = yaml.load(yamlStr);
532
+ }
533
+ catch (e) {
534
+ const msg = e instanceof Error ? e.message : String(e);
535
+ return {
536
+ passed: false,
537
+ score: '0/10',
538
+ failures: [{ check: 'yaml_parses', passed: false, details: `YAML parse error: ${msg}` }],
539
+ warnings: [],
540
+ stats: { concepts: 0, kps: 0, problems: 0, authoredConcepts: 0, stubConcepts: 0 },
541
+ };
542
+ }
543
+ const checks = [];
544
+ const parseCheck = checkYamlParses(raw);
545
+ checks.push(parseCheck);
546
+ if (!parseCheck.passed) {
547
+ return {
548
+ passed: false,
549
+ score: '0/10',
550
+ failures: checks.filter((c) => !c.passed),
551
+ warnings: [],
552
+ stats: { concepts: 0, kps: 0, problems: 0, authoredConcepts: 0, stubConcepts: 0 },
553
+ };
554
+ }
555
+ const data = schemas_1.CourseYamlSchema.parse(raw);
556
+ const authoredConcepts = data.concepts.filter((c) => c.knowledgePoints.length > 0);
557
+ const stubConcepts = data.concepts.filter((c) => c.knowledgePoints.length === 0);
558
+ const kps = data.concepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
559
+ const problems = data.concepts.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
560
+ const stats = {
561
+ concepts: data.concepts.length,
562
+ kps,
563
+ problems,
564
+ authoredConcepts: authoredConcepts.length,
565
+ stubConcepts: stubConcepts.length,
566
+ };
567
+ checks.push(checkUniqueProblemIds(data));
568
+ checks.push(checkPrerequisitesValid(data));
569
+ checks.push(checkQuestionDeduplication(data));
570
+ checks.push(checkDifficultyStaircase(data));
571
+ checks.push(checkCrossConceptCoverage(data));
572
+ checks.push(checkProblemVariantDepth(data));
573
+ checks.push(checkInstructionFormatting(data));
574
+ checks.push(checkWorkedExampleCoverage(data));
575
+ checks.push(checkImportDryRun(data));
576
+ const passedCount = checks.filter((c) => c.passed).length;
577
+ const failures = checks.filter((c) => !c.passed);
578
+ return {
579
+ passed: failures.length === 0,
580
+ score: `${passedCount}/10`,
581
+ failures,
582
+ warnings: [],
583
+ stats,
584
+ };
585
+ }
586
+ // ─── Describe helper (mirrors packages/cli/src/commands/describe.ts) ────────
587
+ function computeGraphDepth(concepts) {
588
+ const graph = new Map();
589
+ for (const c of concepts) {
590
+ graph.set(c.id, c.prerequisites);
591
+ }
592
+ const memo = new Map();
593
+ function depth(id, visited) {
594
+ if (memo.has(id))
595
+ return memo.get(id);
596
+ if (visited.has(id))
597
+ return 0;
598
+ visited.add(id);
599
+ const prereqs = graph.get(id) ?? [];
600
+ let maxPrereqDepth = 0;
601
+ for (const prereq of prereqs) {
602
+ if (graph.has(prereq)) {
603
+ maxPrereqDepth = Math.max(maxPrereqDepth, depth(prereq, visited));
604
+ }
605
+ }
606
+ const d = maxPrereqDepth + 1;
607
+ memo.set(id, d);
608
+ return d;
609
+ }
610
+ let maxDepth = 0;
611
+ for (const c of concepts) {
612
+ maxDepth = Math.max(maxDepth, depth(c.id, new Set()));
613
+ }
614
+ return maxDepth;
615
+ }
616
+ function describeCourse(yamlStr) {
617
+ const raw = yaml.load(yamlStr);
618
+ const result = schemas_1.CourseYamlSchema.safeParse(raw);
619
+ if (!result.success) {
620
+ throw new Error(`Invalid course YAML: ${result.error.issues[0]?.message ?? 'unknown error'}`);
621
+ }
622
+ const data = result.data;
623
+ const concepts = data.concepts;
624
+ const sections = data.sections;
625
+ const authoredConcepts = concepts.filter((c) => c.knowledgePoints.length > 0);
626
+ const stubConcepts = concepts.filter((c) => c.knowledgePoints.length === 0);
627
+ const kpCount = concepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
628
+ const problemCount = concepts.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
629
+ const graphDepth = computeGraphDepth(concepts);
630
+ const conceptsWithoutKps = stubConcepts.map((c) => c.id);
631
+ const kpsWithoutProblems = [];
632
+ for (const c of concepts) {
633
+ for (const kp of c.knowledgePoints) {
634
+ if (kp.problems.length === 0) {
635
+ kpsWithoutProblems.push(`${c.id}/${kp.id}`);
636
+ }
637
+ }
638
+ }
639
+ const sectionBreakdown = [];
640
+ if (sections.length > 0) {
641
+ for (const section of sections) {
642
+ const sectionConcepts = concepts.filter((c) => c.section === section.id);
643
+ const sKps = sectionConcepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
644
+ const sProblems = sectionConcepts.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
645
+ sectionBreakdown.push({ section: section.id, concepts: sectionConcepts.length, kps: sKps, problems: sProblems });
646
+ }
647
+ const unsectioned = concepts.filter((c) => !c.section);
648
+ if (unsectioned.length > 0) {
649
+ const uKps = unsectioned.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
650
+ const uProblems = unsectioned.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
651
+ sectionBreakdown.push({ section: '(unsectioned)', concepts: unsectioned.length, kps: uKps, problems: uProblems });
652
+ }
653
+ }
654
+ return {
655
+ courseName: data.course.name,
656
+ courseId: data.course.id,
657
+ version: data.course.version,
658
+ estimatedHours: data.course.estimatedHours,
659
+ concepts: concepts.length,
660
+ authoredConcepts: authoredConcepts.length,
661
+ stubConcepts: stubConcepts.length,
662
+ knowledgePoints: kpCount,
663
+ problems: problemCount,
664
+ graphDepth,
665
+ conceptsWithoutKps: conceptsWithoutKps.length,
666
+ conceptsWithoutKpsList: conceptsWithoutKps,
667
+ kpsWithoutProblems: kpsWithoutProblems.length,
668
+ kpsWithoutProblemsList: kpsWithoutProblems,
669
+ sections: sectionBreakdown,
670
+ };
671
+ }
672
+ // ─── Fill concept helper (mirrors packages/cli/src/commands/fill-concept.ts) ─
673
+ function fillConcept(yamlStr, conceptId, options) {
674
+ const raw = yaml.load(yamlStr);
675
+ const parsed = schemas_1.CourseYamlSchema.safeParse(raw);
676
+ if (!parsed.success) {
677
+ throw new Error(`Invalid course YAML: ${parsed.error.issues[0]?.message ?? 'unknown error'}`);
678
+ }
679
+ const data = parsed.data;
680
+ const concept = data.concepts.find((c) => c.id === conceptId);
681
+ if (!concept) {
682
+ throw new Error(`Concept "${conceptId}" not found. Available: ${data.concepts.map((c) => c.id).join(', ')}`);
683
+ }
684
+ if (concept.knowledgePoints.length > 0) {
685
+ throw new Error(`Concept "${conceptId}" already has ${concept.knowledgePoints.length} KP(s). Remove them first to regenerate.`);
686
+ }
687
+ const kpCount = options.kps ?? 2;
688
+ const problemsPerKp = options.problemsPerKp ?? 3;
689
+ const newKps = [];
690
+ for (let i = 1; i <= kpCount; i++) {
691
+ const problems = [];
692
+ for (let j = 1; j <= problemsPerKp; j++) {
693
+ problems.push({
694
+ id: `${conceptId}-kp${i}-p${j}`,
695
+ type: 'multiple_choice',
696
+ question: `TODO: Write question ${j} for ${conceptId} KP${i}`,
697
+ options: ['Option A', 'Option B', 'Option C', 'Option D'],
698
+ correct: 0,
699
+ explanation: 'TODO: Explain the correct answer',
700
+ difficulty: Math.min(j + 1, 5),
701
+ });
702
+ }
703
+ newKps.push({
704
+ id: `${conceptId}-kp${i}`,
705
+ instruction: `TODO: Write instruction for ${concept.name} — knowledge point ${i}`,
706
+ workedExample: `TODO: Write a worked example for ${concept.name} — knowledge point ${i}`,
707
+ problems,
708
+ });
709
+ }
710
+ // Rebuild the raw object to preserve structure
711
+ const rawObj = raw;
712
+ const concepts = rawObj['concepts'];
713
+ const targetConcept = concepts.find((c) => c['id'] === conceptId);
714
+ if (targetConcept) {
715
+ targetConcept['knowledgePoints'] = newKps;
716
+ }
717
+ return yaml.dump(rawObj, { lineWidth: 120, noRefs: true });
718
+ }
719
+ const TOOLS = [
720
+ {
721
+ name: 'graspful_scaffold_course',
722
+ description: `Generate a course YAML skeleton with sections, concepts, and prerequisite edges. Returns a minimal valid YAML structure with TODO placeholders.
723
+
724
+ This is step 1 of the Graspful two-YAML workflow:
725
+ 1. Scaffold: Create the course graph (sections, concepts, prerequisites, difficulty levels)
726
+ 2. Fill: Add knowledge points and problems to each concept using graspful_fill_concept
727
+
728
+ The scaffold contains NO learning content — just the graph structure. You should:
729
+ - Edit the returned YAML to add more concepts, adjust prerequisites, set correct difficulty levels (1-10)
730
+ - Set estimatedMinutes per concept
731
+ - Group concepts into sections
732
+ - Then call graspful_fill_concept for each concept to add KPs and problems`,
733
+ inputSchema: {
734
+ type: 'object',
735
+ properties: {
736
+ topic: { type: 'string', description: 'Course topic name (e.g., "Linear Algebra", "AWS Solutions Architect")' },
737
+ estimatedHours: { type: 'number', description: 'Estimated total course hours (default: 10)' },
738
+ sourceDocument: { type: 'string', description: 'Reference to source material (e.g., textbook, spec URL)' },
739
+ },
740
+ required: ['topic'],
741
+ },
742
+ },
743
+ {
744
+ name: 'graspful_fill_concept',
745
+ description: `Add knowledge point (KP) and problem stubs to a specific concept in a course YAML. Returns the full updated YAML.
746
+
747
+ Each KP stub includes:
748
+ - instruction: TODO placeholder for teaching content (markdown)
749
+ - workedExample: TODO placeholder for a step-by-step example
750
+ - problems: Multiple-choice problem stubs with difficulty staircase (2, 3, 4, 5)
751
+
752
+ After filling, you should replace the TODO placeholders with real content:
753
+ - Write clear, concise instructions teaching the knowledge point
754
+ - Create a worked example showing the concept applied step by step
755
+ - Write diverse problems testing the same KP at different difficulty levels
756
+ - Ensure each KP has 3+ problems for the adaptive engine to work well
757
+
758
+ Fails if the concept already has KPs (to prevent accidental overwrites).`,
759
+ inputSchema: {
760
+ type: 'object',
761
+ properties: {
762
+ yaml: { type: 'string', description: 'The full course YAML string' },
763
+ conceptId: { type: 'string', description: 'ID of the concept to fill (must exist in the YAML and have 0 KPs)' },
764
+ kps: { type: 'number', description: 'Number of KP stubs to add (default: 2)' },
765
+ problemsPerKp: { type: 'number', description: 'Number of problem stubs per KP (default: 3)' },
766
+ },
767
+ required: ['yaml', 'conceptId'],
768
+ },
769
+ },
770
+ {
771
+ name: 'graspful_validate',
772
+ description: `Validate any Graspful YAML (course, brand, or academy manifest) against its Zod schema. Auto-detects the file type from the top-level key.
773
+
774
+ For course YAML, also checks:
775
+ - All prerequisite references point to existing concept IDs
776
+ - The prerequisite graph is a DAG (no cycles)
777
+
778
+ Returns { valid, fileType, errors, stats }. If valid is false, errors contains human-readable messages.
779
+ Stats include concept/KP/problem counts for courses.
780
+
781
+ Run this before graspful_import_course to catch errors early.`,
782
+ inputSchema: {
783
+ type: 'object',
784
+ properties: {
785
+ yaml: { type: 'string', description: 'The YAML string to validate (course, brand, or academy manifest)' },
786
+ },
787
+ required: ['yaml'],
788
+ },
789
+ },
790
+ {
791
+ name: 'graspful_review_course',
792
+ description: `Run all 10 mechanical quality checks on a course YAML. Returns a score (e.g., "8/10") with details on each failure.
793
+
794
+ The 10 checks are:
795
+ 1. yaml_parses — Valid Zod schema
796
+ 2. unique_problem_ids — No duplicate problem IDs across the course
797
+ 3. prerequisites_valid — All prerequisite refs point to real concepts
798
+ 4. question_deduplication — No near-duplicate questions at the same difficulty
799
+ 5. difficulty_staircase — Each concept has problems at 2+ difficulty levels
800
+ 6. cross_concept_coverage — No single term dominates too many concepts
801
+ 7. problem_variant_depth — Each KP has 3+ problems
802
+ 8. instruction_formatting — Long instructions have content blocks
803
+ 9. worked_example_coverage — 50%+ of authored concepts have worked examples
804
+ 10. import_dry_run — DAG is valid (no cycles, valid refs)
805
+
806
+ A score of 10/10 is required for publishing. Run this before graspful_import_course --publish.`,
807
+ inputSchema: {
808
+ type: 'object',
809
+ properties: {
810
+ yaml: { type: 'string', description: 'The full course YAML string to review' },
811
+ },
812
+ required: ['yaml'],
813
+ },
814
+ },
815
+ {
816
+ name: 'graspful_import_course',
817
+ description: `Import a course YAML into a Graspful organization. Creates the course as a draft by default.
818
+
819
+ Requires GRASPFUL_API_KEY environment variable to be set.
820
+
821
+ If publish=true, the server runs the review gate first — the course must pass all 10 quality checks to be published. If review fails, the course is imported as a draft and failures are returned.
822
+
823
+ Returns { courseId, url, published, reviewFailures? }.`,
824
+ inputSchema: {
825
+ type: 'object',
826
+ properties: {
827
+ yaml: { type: 'string', description: 'The full course YAML string to import' },
828
+ org: { type: 'string', description: 'Organization slug (e.g., "acme-learning")' },
829
+ publish: { type: 'boolean', description: 'If true, publish immediately (runs review gate). Default: false' },
830
+ },
831
+ required: ['yaml', 'org'],
832
+ },
833
+ },
834
+ {
835
+ name: 'graspful_publish_course',
836
+ description: `Publish a draft course (sets isPublished = true). The server runs the review gate — course must pass all 10 quality checks.
837
+
838
+ Requires GRASPFUL_API_KEY environment variable to be set.
839
+
840
+ Returns { courseId, published }.`,
841
+ inputSchema: {
842
+ type: 'object',
843
+ properties: {
844
+ courseId: { type: 'string', description: 'The course ID (UUID) to publish' },
845
+ org: { type: 'string', description: 'Organization slug' },
846
+ },
847
+ required: ['courseId', 'org'],
848
+ },
849
+ },
850
+ {
851
+ name: 'graspful_describe_course',
852
+ description: `Compute statistics for a course YAML without importing it. Useful for progress tracking during course authoring.
853
+
854
+ Returns:
855
+ - courseName, courseId, version, estimatedHours
856
+ - Total concepts (authored vs stubs), KPs, problems
857
+ - Prerequisite graph depth
858
+ - Missing content: concepts without KPs, KPs without problems
859
+ - Per-section breakdown
860
+
861
+ Use this to check your progress: "How many concepts still need content?"`,
862
+ inputSchema: {
863
+ type: 'object',
864
+ properties: {
865
+ yaml: { type: 'string', description: 'The full course YAML string' },
866
+ },
867
+ required: ['yaml'],
868
+ },
869
+ },
870
+ {
871
+ name: 'graspful_create_brand',
872
+ description: `Generate a brand YAML scaffold for a white-label learning site. Graspful supports multi-tenant white-labeling — each brand gets its own domain, theme, landing page, and SEO config.
873
+
874
+ Niche presets: education, healthcare, finance, tech, legal. Each sets appropriate colors, taglines, and copy.
875
+
876
+ The returned YAML has the full brand structure:
877
+ - brand: id, name, domain, tagline, orgSlug
878
+ - theme: color preset, border radius
879
+ - landing: hero, features, how-it-works, FAQ
880
+ - seo: title, description, keywords
881
+
882
+ Edit the YAML to customize, then import with graspful_import_brand.`,
883
+ inputSchema: {
884
+ type: 'object',
885
+ properties: {
886
+ niche: { type: 'string', description: 'Brand niche: education, healthcare, finance, tech, or legal' },
887
+ name: { type: 'string', description: 'Brand name (default: "{Niche} Academy")' },
888
+ domain: { type: 'string', description: 'Custom domain (default: "{slug}.graspful.com")' },
889
+ orgSlug: { type: 'string', description: 'Organization slug to associate with' },
890
+ },
891
+ required: ['niche'],
892
+ },
893
+ },
894
+ {
895
+ name: 'graspful_import_brand',
896
+ description: `Import a brand YAML into Graspful. Creates the white-label site configuration.
897
+
898
+ Requires GRASPFUL_API_KEY environment variable to be set.
899
+
900
+ Returns { slug, domain, verificationStatus }.`,
901
+ inputSchema: {
902
+ type: 'object',
903
+ properties: {
904
+ yaml: { type: 'string', description: 'The full brand YAML string to import' },
905
+ },
906
+ required: ['yaml'],
907
+ },
908
+ },
909
+ {
910
+ name: 'graspful_list_courses',
911
+ description: `List all courses in a Graspful organization.
912
+
913
+ Requires GRASPFUL_API_KEY environment variable to be set.
914
+
915
+ Returns an array of courses with their IDs, names, published status, and stats.`,
916
+ inputSchema: {
917
+ type: 'object',
918
+ properties: {
919
+ org: { type: 'string', description: 'Organization slug (e.g., "acme-learning")' },
920
+ },
921
+ required: ['org'],
922
+ },
923
+ },
924
+ ];
925
+ function textResult(text) {
926
+ return { content: [{ type: 'text', text }] };
927
+ }
928
+ function errorResult(text) {
929
+ return { content: [{ type: 'text', text }], isError: true };
930
+ }
931
+ async function handleToolCall(name, args) {
932
+ switch (name) {
933
+ case 'graspful_scaffold_course': {
934
+ const topic = args.topic;
935
+ const estimatedHours = args.estimatedHours;
936
+ const sourceDocument = args.sourceDocument;
937
+ const yamlContent = scaffoldCourse(topic, { hours: estimatedHours, source: sourceDocument });
938
+ return textResult(yamlContent);
939
+ }
940
+ case 'graspful_fill_concept': {
941
+ try {
942
+ const updatedYaml = fillConcept(args.yaml, args.conceptId, { kps: args.kps, problemsPerKp: args.problemsPerKp });
943
+ return textResult(updatedYaml);
944
+ }
945
+ catch (e) {
946
+ return errorResult(e instanceof Error ? e.message : String(e));
947
+ }
948
+ }
949
+ case 'graspful_validate': {
950
+ const result = validateYaml(args.yaml);
951
+ return textResult(JSON.stringify(result, null, 2));
952
+ }
953
+ case 'graspful_review_course': {
954
+ const result = runReview(args.yaml);
955
+ return textResult(JSON.stringify(result, null, 2));
956
+ }
957
+ case 'graspful_import_course': {
958
+ try {
959
+ const result = await apiPost(`/api/v1/orgs/${args.org}/courses/import`, { yaml: args.yaml, publish: args.publish ?? false });
960
+ return textResult(JSON.stringify(result, null, 2));
961
+ }
962
+ catch (e) {
963
+ return errorResult(`Import failed: ${e instanceof Error ? e.message : String(e)}`);
964
+ }
965
+ }
966
+ case 'graspful_publish_course': {
967
+ try {
968
+ const result = await apiPost(`/api/v1/orgs/${args.org}/courses/${args.courseId}/publish`, {});
969
+ return textResult(JSON.stringify(result, null, 2));
970
+ }
971
+ catch (e) {
972
+ return errorResult(`Publish failed: ${e instanceof Error ? e.message : String(e)}`);
973
+ }
974
+ }
975
+ case 'graspful_describe_course': {
976
+ try {
977
+ const stats = describeCourse(args.yaml);
978
+ return textResult(JSON.stringify(stats, null, 2));
979
+ }
980
+ catch (e) {
981
+ return errorResult(e instanceof Error ? e.message : String(e));
982
+ }
983
+ }
984
+ case 'graspful_create_brand': {
985
+ const yamlContent = scaffoldBrand(args.niche, {
986
+ name: args.name,
987
+ domain: args.domain,
988
+ orgSlug: args.orgSlug,
989
+ });
990
+ return textResult(yamlContent);
991
+ }
992
+ case 'graspful_import_brand': {
993
+ try {
994
+ let raw;
995
+ try {
996
+ raw = yaml.load(args.yaml);
997
+ }
998
+ catch (e) {
999
+ throw new Error(`YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
1000
+ }
1001
+ const result = await apiPost('/api/v1/brands', raw);
1002
+ return textResult(JSON.stringify(result, null, 2));
1003
+ }
1004
+ catch (e) {
1005
+ return errorResult(`Brand import failed: ${e instanceof Error ? e.message : String(e)}`);
1006
+ }
1007
+ }
1008
+ case 'graspful_list_courses': {
1009
+ try {
1010
+ const result = await apiGet(`/api/v1/orgs/${args.org}/courses`);
1011
+ return textResult(JSON.stringify(result, null, 2));
1012
+ }
1013
+ catch (e) {
1014
+ return errorResult(`List failed: ${e instanceof Error ? e.message : String(e)}`);
1015
+ }
1016
+ }
1017
+ default:
1018
+ return errorResult(`Unknown tool: ${name}`);
1019
+ }
1020
+ }
1021
+ // ─── MCP Server ─────────────────────────────────────────────────────────────
1022
+ const server = new index_js_1.Server({
1023
+ name: 'graspful',
1024
+ version: '0.1.0',
1025
+ }, {
1026
+ capabilities: {
1027
+ tools: {},
1028
+ },
1029
+ });
1030
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
1031
+ return { tools: TOOLS };
1032
+ });
1033
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
1034
+ const { name, arguments: args } = request.params;
1035
+ return handleToolCall(name, args ?? {});
1036
+ });
1037
+ // ─── Start server ───────────────────────────────────────────────────────────
1038
+ async function main() {
1039
+ const transport = new stdio_js_1.StdioServerTransport();
1040
+ await server.connect(transport);
1041
+ }
1042
+ main().catch((error) => {
1043
+ console.error('Fatal error:', error);
1044
+ process.exit(1);
1045
+ });
1046
+ //# sourceMappingURL=index.js.map