@graspful/mcp 0.2.3 → 0.2.5
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/README.md +54 -18
- package/dist/index.d.ts +20 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +204 -706
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -34,15 +34,25 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.TOOLS = void 0;
|
|
38
|
+
exports.mcpDistinctId = mcpDistinctId;
|
|
39
|
+
exports.handleToolCall = handleToolCall;
|
|
37
40
|
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
38
41
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
39
42
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
40
43
|
const yaml = __importStar(require("js-yaml"));
|
|
41
|
-
const crypto = __importStar(require("crypto"));
|
|
42
44
|
const posthog_node_1 = require("posthog-node");
|
|
45
|
+
const node_crypto_1 = require("node:crypto");
|
|
43
46
|
const shared_1 = require("@graspful/shared");
|
|
44
47
|
// ─── PostHog analytics ──────────────────────────────────────────────────────
|
|
45
|
-
const
|
|
48
|
+
const DEFAULT_POSTHOG_KEY = 'phc_ahQLCJsOBzeuro1yDeurs1a3xx07pIreJWeXG9T4d4';
|
|
49
|
+
const telemetryDisabled = process.env.GRASPFUL_TELEMETRY_DISABLED === '1' ||
|
|
50
|
+
process.env.NODE_ENV === 'test';
|
|
51
|
+
const posthogKey = telemetryDisabled
|
|
52
|
+
? null
|
|
53
|
+
: process.env.POSTHOG_API_KEY ||
|
|
54
|
+
process.env.NEXT_PUBLIC_POSTHOG_KEY ||
|
|
55
|
+
DEFAULT_POSTHOG_KEY;
|
|
46
56
|
const posthogClient = posthogKey
|
|
47
57
|
? new posthog_node_1.PostHog(posthogKey, {
|
|
48
58
|
host: process.env.POSTHOG_HOST || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
|
|
@@ -50,8 +60,18 @@ const posthogClient = posthogKey
|
|
|
50
60
|
flushInterval: 0,
|
|
51
61
|
})
|
|
52
62
|
: null;
|
|
63
|
+
const anonymousMcpDistinctId = `anonymous-mcp:${(0, node_crypto_1.randomUUID)()}`;
|
|
53
64
|
function mcpDistinctId() {
|
|
54
|
-
|
|
65
|
+
if (process.env.GRASPFUL_USER_ID) {
|
|
66
|
+
return process.env.GRASPFUL_USER_ID;
|
|
67
|
+
}
|
|
68
|
+
if (process.env.GRASPFUL_API_KEY) {
|
|
69
|
+
const digest = (0, node_crypto_1.createHash)('sha256')
|
|
70
|
+
.update(process.env.GRASPFUL_API_KEY)
|
|
71
|
+
.digest('hex');
|
|
72
|
+
return `credential:${digest}`;
|
|
73
|
+
}
|
|
74
|
+
return anonymousMcpDistinctId;
|
|
55
75
|
}
|
|
56
76
|
function mcpCapture(event, properties = {}) {
|
|
57
77
|
posthogClient?.capture({
|
|
@@ -72,7 +92,7 @@ function requireApiAuth() {
|
|
|
72
92
|
throw new Error(AUTH_REQUIRED_ERROR);
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
|
-
// ─── API Client
|
|
95
|
+
// ─── API Client ─────────────────────────────────────────────────────────────
|
|
76
96
|
function getApiCredentials() {
|
|
77
97
|
const baseUrl = (process.env.GRASPFUL_API_URL || 'https://api.graspful.ai').replace(/\/$/, '');
|
|
78
98
|
const apiKey = process.env.GRASPFUL_API_KEY;
|
|
@@ -81,15 +101,15 @@ function getApiCredentials() {
|
|
|
81
101
|
}
|
|
82
102
|
return { baseUrl };
|
|
83
103
|
}
|
|
84
|
-
async function
|
|
104
|
+
async function apiFetch(method, path, body) {
|
|
85
105
|
const { baseUrl, authHeader } = getApiCredentials();
|
|
86
106
|
const headers = { 'Content-Type': 'application/json' };
|
|
87
107
|
if (authHeader)
|
|
88
108
|
headers['Authorization'] = authHeader;
|
|
89
109
|
const res = await fetch(`${baseUrl}${path}`, {
|
|
90
|
-
method
|
|
110
|
+
method,
|
|
91
111
|
headers,
|
|
92
|
-
body: JSON.stringify(body),
|
|
112
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
93
113
|
});
|
|
94
114
|
if (!res.ok) {
|
|
95
115
|
const text = await res.text();
|
|
@@ -97,658 +117,34 @@ async function apiPost(path, body) {
|
|
|
97
117
|
}
|
|
98
118
|
return res.json();
|
|
99
119
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
headers['Authorization'] = authHeader;
|
|
105
|
-
const res = await fetch(`${baseUrl}${path}`, {
|
|
106
|
-
method: 'GET',
|
|
107
|
-
headers,
|
|
108
|
-
});
|
|
109
|
-
if (!res.ok) {
|
|
110
|
-
const text = await res.text();
|
|
111
|
-
throw new Error(`API error ${res.status}: ${text}`);
|
|
112
|
-
}
|
|
113
|
-
return res.json();
|
|
120
|
+
// ─── YAML helpers ───────────────────────────────────────────────────────────
|
|
121
|
+
const YAML_DUMP_OPTS = { lineWidth: 120, noRefs: true };
|
|
122
|
+
function parseYaml(yamlStr) {
|
|
123
|
+
return yaml.load(yamlStr);
|
|
114
124
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const slug = topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
118
|
-
return yaml.dump({
|
|
119
|
-
course: {
|
|
120
|
-
id: slug,
|
|
121
|
-
name: topic,
|
|
122
|
-
description: `Adaptive course on ${topic}`,
|
|
123
|
-
estimatedHours: options.hours || 10,
|
|
124
|
-
version: '2026.1',
|
|
125
|
-
sourceDocument: options.source || 'TODO: Add source document',
|
|
126
|
-
},
|
|
127
|
-
sections: [
|
|
128
|
-
{ id: 'foundations', name: 'Foundations', description: 'Core concepts' },
|
|
129
|
-
{ id: 'application', name: 'Application', description: 'Applied concepts' },
|
|
130
|
-
],
|
|
131
|
-
concepts: [
|
|
132
|
-
{
|
|
133
|
-
id: `${slug}-intro`,
|
|
134
|
-
name: `Introduction to ${topic}`,
|
|
135
|
-
section: 'foundations',
|
|
136
|
-
difficulty: 2,
|
|
137
|
-
estimatedMinutes: 15,
|
|
138
|
-
tags: ['foundational'],
|
|
139
|
-
prerequisites: [],
|
|
140
|
-
knowledgePoints: [],
|
|
141
|
-
},
|
|
142
|
-
],
|
|
143
|
-
}, { lineWidth: 120, noRefs: true });
|
|
125
|
+
function dumpYaml(obj) {
|
|
126
|
+
return yaml.dump(obj, YAML_DUMP_OPTS);
|
|
144
127
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
name,
|
|
162
|
-
domain,
|
|
163
|
-
tagline: config.tagline,
|
|
164
|
-
orgSlug: options.orgSlug || 'TODO: your-org-slug',
|
|
165
|
-
},
|
|
166
|
-
theme: {
|
|
167
|
-
preset: config.preset,
|
|
168
|
-
radius: '0.5rem',
|
|
169
|
-
},
|
|
170
|
-
landing: {
|
|
171
|
-
hero: {
|
|
172
|
-
headline: config.headline,
|
|
173
|
-
subheadline: `${name} uses adaptive learning to help you master concepts faster.`,
|
|
174
|
-
ctaText: 'Start Learning',
|
|
175
|
-
},
|
|
176
|
-
features: {
|
|
177
|
-
heading: 'Why choose us?',
|
|
178
|
-
items: [
|
|
179
|
-
{ title: 'Adaptive Learning', description: 'Content adapts to your knowledge level', icon: 'brain' },
|
|
180
|
-
{ title: 'Spaced Repetition', description: 'Review at optimal intervals for lasting memory', icon: 'clock' },
|
|
181
|
-
{ title: 'Progress Tracking', description: 'See exactly where you stand', icon: 'chart' },
|
|
182
|
-
],
|
|
183
|
-
},
|
|
184
|
-
howItWorks: {
|
|
185
|
-
heading: 'How it works',
|
|
186
|
-
items: [
|
|
187
|
-
{ title: 'Take a diagnostic', description: 'We assess what you already know' },
|
|
188
|
-
{ title: 'Learn adaptively', description: 'Focus on gaps, skip what you know' },
|
|
189
|
-
{ title: 'Master the material', description: 'Prove mastery through progressive challenges' },
|
|
190
|
-
],
|
|
128
|
+
const TOOLS = [
|
|
129
|
+
{
|
|
130
|
+
name: 'graspful_create_academy',
|
|
131
|
+
description: `Generate an academy plan and manifest scaffold for an academy-first workflow. Every academy is a connected curriculum made of one or more real courses.
|
|
132
|
+
|
|
133
|
+
Use this before authoring course YAML when the topic should be decomposed into learner-facing parts. If you do not pass courseNames, the scaffold creates the four default planning layers: foundations, core structures, operational flows, and applied judgment. The result includes authoring gates for source material, learner promise, landing-page proof, graph checks, and review before publishing.`,
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
topic: { type: 'string', description: 'Academy topic name (e.g., "PostHog TAM", "Linear Algebra")' },
|
|
138
|
+
courseNames: {
|
|
139
|
+
type: 'array',
|
|
140
|
+
description: 'Optional ordered course names to include in the manifest',
|
|
141
|
+
items: { type: 'string' },
|
|
142
|
+
},
|
|
143
|
+
version: { type: 'string', description: 'Academy version string (default: 2026.1)' },
|
|
191
144
|
},
|
|
192
|
-
|
|
193
|
-
},
|
|
194
|
-
seo: {
|
|
195
|
-
title: `${name} — Adaptive Learning`,
|
|
196
|
-
description: config.tagline,
|
|
197
|
-
keywords: [niche, 'learning', 'adaptive', 'education'],
|
|
145
|
+
required: ['topic'],
|
|
198
146
|
},
|
|
199
|
-
},
|
|
200
|
-
}
|
|
201
|
-
function detectFileType(data) {
|
|
202
|
-
if (typeof data !== 'object' || data === null)
|
|
203
|
-
return null;
|
|
204
|
-
const obj = data;
|
|
205
|
-
if ('course' in obj)
|
|
206
|
-
return 'course';
|
|
207
|
-
if ('brand' in obj)
|
|
208
|
-
return 'brand';
|
|
209
|
-
if ('academy' in obj)
|
|
210
|
-
return 'academy';
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
function detectCycles(concepts) {
|
|
214
|
-
const graph = new Map();
|
|
215
|
-
for (const c of concepts) {
|
|
216
|
-
graph.set(c.id, c.prerequisites);
|
|
217
|
-
}
|
|
218
|
-
const visited = new Set();
|
|
219
|
-
const inStack = new Set();
|
|
220
|
-
const cycles = [];
|
|
221
|
-
function dfs(node, path) {
|
|
222
|
-
if (inStack.has(node)) {
|
|
223
|
-
const cycleStart = path.indexOf(node);
|
|
224
|
-
const cycle = path.slice(cycleStart).concat(node);
|
|
225
|
-
cycles.push(`Cycle: ${cycle.join(' -> ')}`);
|
|
226
|
-
return true;
|
|
227
|
-
}
|
|
228
|
-
if (visited.has(node))
|
|
229
|
-
return false;
|
|
230
|
-
visited.add(node);
|
|
231
|
-
inStack.add(node);
|
|
232
|
-
path.push(node);
|
|
233
|
-
for (const dep of graph.get(node) ?? []) {
|
|
234
|
-
dfs(dep, path);
|
|
235
|
-
}
|
|
236
|
-
path.pop();
|
|
237
|
-
inStack.delete(node);
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
for (const id of graph.keys()) {
|
|
241
|
-
if (!visited.has(id)) {
|
|
242
|
-
dfs(id, []);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return cycles;
|
|
246
|
-
}
|
|
247
|
-
function validateYaml(yamlStr) {
|
|
248
|
-
let raw;
|
|
249
|
-
try {
|
|
250
|
-
raw = yaml.load(yamlStr);
|
|
251
|
-
}
|
|
252
|
-
catch (e) {
|
|
253
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
254
|
-
return { valid: false, errors: [`YAML parse error: ${msg}`], stats: {} };
|
|
255
|
-
}
|
|
256
|
-
const fileType = detectFileType(raw);
|
|
257
|
-
if (!fileType) {
|
|
258
|
-
return { valid: false, errors: ['Could not detect file type. Expected top-level key: course, brand, or academy'], stats: {} };
|
|
259
|
-
}
|
|
260
|
-
const schemaMap = {
|
|
261
|
-
course: shared_1.CourseYamlSchema,
|
|
262
|
-
brand: shared_1.BrandYamlSchema,
|
|
263
|
-
academy: shared_1.AcademyManifestSchema,
|
|
264
|
-
};
|
|
265
|
-
const result = schemaMap[fileType].safeParse(raw);
|
|
266
|
-
if (!result.success) {
|
|
267
|
-
const errors = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
|
|
268
|
-
return { valid: false, fileType, errors, stats: {} };
|
|
269
|
-
}
|
|
270
|
-
// For courses, also check DAG
|
|
271
|
-
const dagErrors = [];
|
|
272
|
-
let stats = { fileType };
|
|
273
|
-
if (fileType === 'course') {
|
|
274
|
-
const data = result.data;
|
|
275
|
-
const conceptIds = new Set(data.concepts.map((c) => c.id));
|
|
276
|
-
for (const concept of data.concepts) {
|
|
277
|
-
for (const prereq of concept.prerequisites) {
|
|
278
|
-
if (!conceptIds.has(prereq)) {
|
|
279
|
-
dagErrors.push(`Concept "${concept.id}" has unknown prerequisite "${prereq}"`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
const cycles = detectCycles(data.concepts.map((c) => ({ id: c.id, prerequisites: c.prerequisites })));
|
|
284
|
-
dagErrors.push(...cycles);
|
|
285
|
-
const kpCount = data.concepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
|
|
286
|
-
const problemCount = data.concepts.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
|
|
287
|
-
stats = { fileType, concepts: data.concepts.length, knowledgePoints: kpCount, problems: problemCount };
|
|
288
|
-
}
|
|
289
|
-
if (dagErrors.length > 0) {
|
|
290
|
-
return { valid: false, fileType, errors: dagErrors, stats };
|
|
291
|
-
}
|
|
292
|
-
return { valid: true, fileType, errors: [], stats };
|
|
293
|
-
}
|
|
294
|
-
// ─── Review helpers (mirrors packages/cli/src/commands/review.ts) ───────────
|
|
295
|
-
function checkYamlParses(raw) {
|
|
296
|
-
const result = shared_1.CourseYamlSchema.safeParse(raw);
|
|
297
|
-
if (result.success) {
|
|
298
|
-
return { check: 'yaml_parses', passed: true };
|
|
299
|
-
}
|
|
300
|
-
const errors = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
|
|
301
|
-
return {
|
|
302
|
-
check: 'yaml_parses',
|
|
303
|
-
passed: false,
|
|
304
|
-
details: `${errors.length} schema error(s): ${errors.slice(0, 5).join('; ')}${errors.length > 5 ? ` (+${errors.length - 5} more)` : ''}`,
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
function checkUniqueProblemIds(data) {
|
|
308
|
-
const duplicates = [];
|
|
309
|
-
const seen = new Set();
|
|
310
|
-
for (const concept of data.concepts) {
|
|
311
|
-
for (const kp of concept.knowledgePoints) {
|
|
312
|
-
for (const problem of kp.problems) {
|
|
313
|
-
if (seen.has(problem.id)) {
|
|
314
|
-
duplicates.push(problem.id);
|
|
315
|
-
}
|
|
316
|
-
seen.add(problem.id);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
if (duplicates.length === 0) {
|
|
321
|
-
return { check: 'unique_problem_ids', passed: true };
|
|
322
|
-
}
|
|
323
|
-
return {
|
|
324
|
-
check: 'unique_problem_ids',
|
|
325
|
-
passed: false,
|
|
326
|
-
details: `Duplicate problem IDs: ${duplicates.join(', ')}`,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
function checkPrerequisitesValid(data) {
|
|
330
|
-
const conceptIds = new Set(data.concepts.map((c) => c.id));
|
|
331
|
-
const invalid = [];
|
|
332
|
-
for (const concept of data.concepts) {
|
|
333
|
-
for (const prereq of concept.prerequisites) {
|
|
334
|
-
if (!conceptIds.has(prereq)) {
|
|
335
|
-
invalid.push(`${concept.id} -> ${prereq}`);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
if (invalid.length === 0) {
|
|
340
|
-
return { check: 'prerequisites_valid', passed: true };
|
|
341
|
-
}
|
|
342
|
-
return {
|
|
343
|
-
check: 'prerequisites_valid',
|
|
344
|
-
passed: false,
|
|
345
|
-
details: `Unknown prerequisites: ${invalid.join(', ')}`,
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
function normalizeText(text) {
|
|
349
|
-
return text.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
|
|
350
|
-
}
|
|
351
|
-
function checkQuestionDeduplication(data) {
|
|
352
|
-
const seen = new Map();
|
|
353
|
-
const collisions = [];
|
|
354
|
-
for (const concept of data.concepts) {
|
|
355
|
-
for (const kp of concept.knowledgePoints) {
|
|
356
|
-
for (const problem of kp.problems) {
|
|
357
|
-
const normalized = normalizeText(problem.question);
|
|
358
|
-
const hash = crypto.createHash('md5').update(normalized).digest('hex').substring(0, 12);
|
|
359
|
-
const key = `${hash}-d${problem.difficulty ?? 'none'}`;
|
|
360
|
-
const existing = seen.get(key);
|
|
361
|
-
if (existing) {
|
|
362
|
-
collisions.push(`"${problem.id}" collides with "${existing.problemId}" (same question text at same difficulty)`);
|
|
363
|
-
}
|
|
364
|
-
else {
|
|
365
|
-
seen.set(key, { problemId: problem.id });
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
if (collisions.length === 0) {
|
|
371
|
-
return { check: 'question_deduplication', passed: true };
|
|
372
|
-
}
|
|
373
|
-
return {
|
|
374
|
-
check: 'question_deduplication',
|
|
375
|
-
passed: false,
|
|
376
|
-
details: collisions.slice(0, 5).join('; ') + (collisions.length > 5 ? ` (+${collisions.length - 5} more)` : ''),
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
function checkDifficultyStaircase(data) {
|
|
380
|
-
const failures = [];
|
|
381
|
-
for (const concept of data.concepts) {
|
|
382
|
-
if (concept.knowledgePoints.length === 0)
|
|
383
|
-
continue;
|
|
384
|
-
const difficulties = new Set();
|
|
385
|
-
for (const kp of concept.knowledgePoints) {
|
|
386
|
-
for (const problem of kp.problems) {
|
|
387
|
-
if (problem.difficulty != null) {
|
|
388
|
-
difficulties.add(problem.difficulty);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
if (difficulties.size > 0 && difficulties.size < 2) {
|
|
393
|
-
failures.push(`"${concept.id}" has problems at only ${difficulties.size} difficulty level(s) — need 2+`);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
if (failures.length === 0) {
|
|
397
|
-
return { check: 'difficulty_staircase', passed: true };
|
|
398
|
-
}
|
|
399
|
-
return {
|
|
400
|
-
check: 'difficulty_staircase',
|
|
401
|
-
passed: false,
|
|
402
|
-
details: failures.slice(0, 5).join('; ') + (failures.length > 5 ? ` (+${failures.length - 5} more)` : ''),
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
function extractStems(text) {
|
|
406
|
-
return text.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter((w) => w.length > 4);
|
|
407
|
-
}
|
|
408
|
-
function checkCrossConceptCoverage(data) {
|
|
409
|
-
const stemConceptCount = new Map();
|
|
410
|
-
for (const concept of data.concepts) {
|
|
411
|
-
for (const kp of concept.knowledgePoints) {
|
|
412
|
-
for (const problem of kp.problems) {
|
|
413
|
-
const stems = extractStems(problem.question);
|
|
414
|
-
for (const stem of stems) {
|
|
415
|
-
if (!stemConceptCount.has(stem)) {
|
|
416
|
-
stemConceptCount.set(stem, new Set());
|
|
417
|
-
}
|
|
418
|
-
stemConceptCount.get(stem).add(concept.id);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
const overused = [];
|
|
424
|
-
for (const [stem, concepts] of stemConceptCount) {
|
|
425
|
-
if (concepts.size > 3) {
|
|
426
|
-
overused.push(`"${stem}" appears across ${concepts.size} concepts`);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
const commonWords = new Set([
|
|
430
|
-
'which', 'would', 'should', 'could', 'about', 'their', 'there', 'these', 'those',
|
|
431
|
-
'being', 'between', 'through', 'during', 'before', 'after', 'above', 'below',
|
|
432
|
-
'following', 'statement', 'answer', 'question', 'correct', 'incorrect',
|
|
433
|
-
'agent', 'property', 'owner', 'buyer', 'seller',
|
|
434
|
-
]);
|
|
435
|
-
const meaningfulOverused = overused.filter((entry) => {
|
|
436
|
-
const stem = entry.match(/"([^"]+)"/)?.[1] ?? '';
|
|
437
|
-
return !commonWords.has(stem);
|
|
438
|
-
});
|
|
439
|
-
if (meaningfulOverused.length === 0) {
|
|
440
|
-
return { check: 'cross_concept_coverage', passed: true };
|
|
441
|
-
}
|
|
442
|
-
return {
|
|
443
|
-
check: 'cross_concept_coverage',
|
|
444
|
-
passed: meaningfulOverused.length <= 5,
|
|
445
|
-
details: meaningfulOverused.slice(0, 5).join('; ') + (meaningfulOverused.length > 5 ? ` (+${meaningfulOverused.length - 5} more)` : ''),
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
function checkProblemVariantDepth(data) {
|
|
449
|
-
const failures = [];
|
|
450
|
-
for (const concept of data.concepts) {
|
|
451
|
-
if (concept.knowledgePoints.length === 0)
|
|
452
|
-
continue;
|
|
453
|
-
for (const kp of concept.knowledgePoints) {
|
|
454
|
-
if (kp.problems.length < 3) {
|
|
455
|
-
failures.push(`"${concept.id}/${kp.id}" has ${kp.problems.length} problem(s) — need 3+`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
if (failures.length === 0) {
|
|
460
|
-
return { check: 'problem_variant_depth', passed: true };
|
|
461
|
-
}
|
|
462
|
-
return {
|
|
463
|
-
check: 'problem_variant_depth',
|
|
464
|
-
passed: false,
|
|
465
|
-
details: failures.slice(0, 5).join('; ') + (failures.length > 5 ? ` (+${failures.length - 5} more)` : ''),
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
function checkInstructionFormatting(data) {
|
|
469
|
-
const warnings = [];
|
|
470
|
-
for (const concept of data.concepts) {
|
|
471
|
-
for (const kp of concept.knowledgePoints) {
|
|
472
|
-
if (!kp.instruction)
|
|
473
|
-
continue;
|
|
474
|
-
if (kp.instruction.match(/^[\w\-./]+\.(md|txt|html)$/))
|
|
475
|
-
continue;
|
|
476
|
-
const wordCount = kp.instruction.split(/\s+/).filter(Boolean).length;
|
|
477
|
-
const hasContentBlocks = kp.instructionContent && kp.instructionContent.length > 0;
|
|
478
|
-
if (wordCount > 100 && !hasContentBlocks) {
|
|
479
|
-
warnings.push(`"${concept.id}/${kp.id}" instruction is ${wordCount} words with no content blocks`);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
if (warnings.length === 0) {
|
|
484
|
-
return { check: 'instruction_formatting', passed: true };
|
|
485
|
-
}
|
|
486
|
-
return {
|
|
487
|
-
check: 'instruction_formatting',
|
|
488
|
-
passed: false,
|
|
489
|
-
details: warnings.slice(0, 5).join('; ') + (warnings.length > 5 ? ` (+${warnings.length - 5} more)` : ''),
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
function checkWorkedExampleCoverage(data) {
|
|
493
|
-
const authoredConcepts = data.concepts.filter((c) => c.knowledgePoints.length > 0);
|
|
494
|
-
if (authoredConcepts.length === 0) {
|
|
495
|
-
return { check: 'worked_example_coverage', passed: true };
|
|
496
|
-
}
|
|
497
|
-
const withExamples = authoredConcepts.filter((c) => c.knowledgePoints.some((kp) => kp.workedExample && kp.workedExample.trim().length > 0));
|
|
498
|
-
const coverage = withExamples.length / authoredConcepts.length;
|
|
499
|
-
if (coverage >= 0.5) {
|
|
500
|
-
return { check: 'worked_example_coverage', passed: true };
|
|
501
|
-
}
|
|
502
|
-
return {
|
|
503
|
-
check: 'worked_example_coverage',
|
|
504
|
-
passed: false,
|
|
505
|
-
details: `${withExamples.length}/${authoredConcepts.length} authored concepts have worked examples (${Math.round(coverage * 100)}%) — need 50%+`,
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
function checkImportDryRun(data) {
|
|
509
|
-
const conceptIds = new Set(data.concepts.map((c) => c.id));
|
|
510
|
-
const errors = [];
|
|
511
|
-
for (const concept of data.concepts) {
|
|
512
|
-
for (const prereq of concept.prerequisites) {
|
|
513
|
-
if (!conceptIds.has(prereq)) {
|
|
514
|
-
errors.push(`Unknown prerequisite: ${concept.id} -> ${prereq}`);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
const graph = new Map();
|
|
519
|
-
for (const c of data.concepts) {
|
|
520
|
-
graph.set(c.id, [...c.prerequisites]);
|
|
521
|
-
}
|
|
522
|
-
const visited = new Set();
|
|
523
|
-
const inStack = new Set();
|
|
524
|
-
function hasCycle(node, path) {
|
|
525
|
-
if (inStack.has(node)) {
|
|
526
|
-
const cycleStart = path.indexOf(node);
|
|
527
|
-
const cycle = path.slice(cycleStart).concat(node);
|
|
528
|
-
errors.push(`Cycle detected: ${cycle.join(' -> ')}`);
|
|
529
|
-
return true;
|
|
530
|
-
}
|
|
531
|
-
if (visited.has(node))
|
|
532
|
-
return false;
|
|
533
|
-
visited.add(node);
|
|
534
|
-
inStack.add(node);
|
|
535
|
-
path.push(node);
|
|
536
|
-
let foundCycle = false;
|
|
537
|
-
for (const dep of graph.get(node) ?? []) {
|
|
538
|
-
if (hasCycle(dep, path)) {
|
|
539
|
-
foundCycle = true;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
path.pop();
|
|
543
|
-
inStack.delete(node);
|
|
544
|
-
return foundCycle;
|
|
545
|
-
}
|
|
546
|
-
for (const id of graph.keys()) {
|
|
547
|
-
if (!visited.has(id)) {
|
|
548
|
-
hasCycle(id, []);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
if (errors.length === 0) {
|
|
552
|
-
return { check: 'import_dry_run', passed: true };
|
|
553
|
-
}
|
|
554
|
-
return {
|
|
555
|
-
check: 'import_dry_run',
|
|
556
|
-
passed: false,
|
|
557
|
-
details: errors.join('; '),
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
function runReview(yamlStr) {
|
|
561
|
-
let raw;
|
|
562
|
-
try {
|
|
563
|
-
raw = yaml.load(yamlStr);
|
|
564
|
-
}
|
|
565
|
-
catch (e) {
|
|
566
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
567
|
-
return {
|
|
568
|
-
passed: false,
|
|
569
|
-
score: '0/10',
|
|
570
|
-
failures: [{ check: 'yaml_parses', passed: false, details: `YAML parse error: ${msg}` }],
|
|
571
|
-
warnings: [],
|
|
572
|
-
stats: { concepts: 0, kps: 0, problems: 0, authoredConcepts: 0, stubConcepts: 0 },
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
const checks = [];
|
|
576
|
-
const parseCheck = checkYamlParses(raw);
|
|
577
|
-
checks.push(parseCheck);
|
|
578
|
-
if (!parseCheck.passed) {
|
|
579
|
-
return {
|
|
580
|
-
passed: false,
|
|
581
|
-
score: '0/10',
|
|
582
|
-
failures: checks.filter((c) => !c.passed),
|
|
583
|
-
warnings: [],
|
|
584
|
-
stats: { concepts: 0, kps: 0, problems: 0, authoredConcepts: 0, stubConcepts: 0 },
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
const data = shared_1.CourseYamlSchema.parse(raw);
|
|
588
|
-
const authoredConcepts = data.concepts.filter((c) => c.knowledgePoints.length > 0);
|
|
589
|
-
const stubConcepts = data.concepts.filter((c) => c.knowledgePoints.length === 0);
|
|
590
|
-
const kps = data.concepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
|
|
591
|
-
const problems = data.concepts.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
|
|
592
|
-
const stats = {
|
|
593
|
-
concepts: data.concepts.length,
|
|
594
|
-
kps,
|
|
595
|
-
problems,
|
|
596
|
-
authoredConcepts: authoredConcepts.length,
|
|
597
|
-
stubConcepts: stubConcepts.length,
|
|
598
|
-
};
|
|
599
|
-
checks.push(checkUniqueProblemIds(data));
|
|
600
|
-
checks.push(checkPrerequisitesValid(data));
|
|
601
|
-
checks.push(checkQuestionDeduplication(data));
|
|
602
|
-
checks.push(checkDifficultyStaircase(data));
|
|
603
|
-
checks.push(checkCrossConceptCoverage(data));
|
|
604
|
-
checks.push(checkProblemVariantDepth(data));
|
|
605
|
-
checks.push(checkInstructionFormatting(data));
|
|
606
|
-
checks.push(checkWorkedExampleCoverage(data));
|
|
607
|
-
checks.push(checkImportDryRun(data));
|
|
608
|
-
const passedCount = checks.filter((c) => c.passed).length;
|
|
609
|
-
const failures = checks.filter((c) => !c.passed);
|
|
610
|
-
return {
|
|
611
|
-
passed: failures.length === 0,
|
|
612
|
-
score: `${passedCount}/10`,
|
|
613
|
-
failures,
|
|
614
|
-
warnings: [],
|
|
615
|
-
stats,
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
// ─── Describe helper (mirrors packages/cli/src/commands/describe.ts) ────────
|
|
619
|
-
function computeGraphDepth(concepts) {
|
|
620
|
-
const graph = new Map();
|
|
621
|
-
for (const c of concepts) {
|
|
622
|
-
graph.set(c.id, c.prerequisites);
|
|
623
|
-
}
|
|
624
|
-
const memo = new Map();
|
|
625
|
-
function depth(id, visited) {
|
|
626
|
-
if (memo.has(id))
|
|
627
|
-
return memo.get(id);
|
|
628
|
-
if (visited.has(id))
|
|
629
|
-
return 0;
|
|
630
|
-
visited.add(id);
|
|
631
|
-
const prereqs = graph.get(id) ?? [];
|
|
632
|
-
let maxPrereqDepth = 0;
|
|
633
|
-
for (const prereq of prereqs) {
|
|
634
|
-
if (graph.has(prereq)) {
|
|
635
|
-
maxPrereqDepth = Math.max(maxPrereqDepth, depth(prereq, visited));
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
const d = maxPrereqDepth + 1;
|
|
639
|
-
memo.set(id, d);
|
|
640
|
-
return d;
|
|
641
|
-
}
|
|
642
|
-
let maxDepth = 0;
|
|
643
|
-
for (const c of concepts) {
|
|
644
|
-
maxDepth = Math.max(maxDepth, depth(c.id, new Set()));
|
|
645
|
-
}
|
|
646
|
-
return maxDepth;
|
|
647
|
-
}
|
|
648
|
-
function describeCourse(yamlStr) {
|
|
649
|
-
const raw = yaml.load(yamlStr);
|
|
650
|
-
const result = shared_1.CourseYamlSchema.safeParse(raw);
|
|
651
|
-
if (!result.success) {
|
|
652
|
-
throw new Error(`Invalid course YAML: ${result.error.issues[0]?.message ?? 'unknown error'}`);
|
|
653
|
-
}
|
|
654
|
-
const data = result.data;
|
|
655
|
-
const concepts = data.concepts;
|
|
656
|
-
const sections = data.sections;
|
|
657
|
-
const authoredConcepts = concepts.filter((c) => c.knowledgePoints.length > 0);
|
|
658
|
-
const stubConcepts = concepts.filter((c) => c.knowledgePoints.length === 0);
|
|
659
|
-
const kpCount = concepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
|
|
660
|
-
const problemCount = concepts.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
|
|
661
|
-
const graphDepth = computeGraphDepth(concepts);
|
|
662
|
-
const conceptsWithoutKps = stubConcepts.map((c) => c.id);
|
|
663
|
-
const kpsWithoutProblems = [];
|
|
664
|
-
for (const c of concepts) {
|
|
665
|
-
for (const kp of c.knowledgePoints) {
|
|
666
|
-
if (kp.problems.length === 0) {
|
|
667
|
-
kpsWithoutProblems.push(`${c.id}/${kp.id}`);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
const sectionBreakdown = [];
|
|
672
|
-
if (sections.length > 0) {
|
|
673
|
-
for (const section of sections) {
|
|
674
|
-
const sectionConcepts = concepts.filter((c) => c.section === section.id);
|
|
675
|
-
const sKps = sectionConcepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
|
|
676
|
-
const sProblems = sectionConcepts.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
|
|
677
|
-
sectionBreakdown.push({ section: section.id, concepts: sectionConcepts.length, kps: sKps, problems: sProblems });
|
|
678
|
-
}
|
|
679
|
-
const unsectioned = concepts.filter((c) => !c.section);
|
|
680
|
-
if (unsectioned.length > 0) {
|
|
681
|
-
const uKps = unsectioned.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
|
|
682
|
-
const uProblems = unsectioned.reduce((sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0), 0);
|
|
683
|
-
sectionBreakdown.push({ section: '(unsectioned)', concepts: unsectioned.length, kps: uKps, problems: uProblems });
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
return {
|
|
687
|
-
courseName: data.course.name,
|
|
688
|
-
courseId: data.course.id,
|
|
689
|
-
version: data.course.version,
|
|
690
|
-
estimatedHours: data.course.estimatedHours,
|
|
691
|
-
concepts: concepts.length,
|
|
692
|
-
authoredConcepts: authoredConcepts.length,
|
|
693
|
-
stubConcepts: stubConcepts.length,
|
|
694
|
-
knowledgePoints: kpCount,
|
|
695
|
-
problems: problemCount,
|
|
696
|
-
graphDepth,
|
|
697
|
-
conceptsWithoutKps: conceptsWithoutKps.length,
|
|
698
|
-
conceptsWithoutKpsList: conceptsWithoutKps,
|
|
699
|
-
kpsWithoutProblems: kpsWithoutProblems.length,
|
|
700
|
-
kpsWithoutProblemsList: kpsWithoutProblems,
|
|
701
|
-
sections: sectionBreakdown,
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
// ─── Fill concept helper (mirrors packages/cli/src/commands/fill-concept.ts) ─
|
|
705
|
-
function fillConcept(yamlStr, conceptId, options) {
|
|
706
|
-
const raw = yaml.load(yamlStr);
|
|
707
|
-
const parsed = shared_1.CourseYamlSchema.safeParse(raw);
|
|
708
|
-
if (!parsed.success) {
|
|
709
|
-
throw new Error(`Invalid course YAML: ${parsed.error.issues[0]?.message ?? 'unknown error'}`);
|
|
710
|
-
}
|
|
711
|
-
const data = parsed.data;
|
|
712
|
-
const concept = data.concepts.find((c) => c.id === conceptId);
|
|
713
|
-
if (!concept) {
|
|
714
|
-
throw new Error(`Concept "${conceptId}" not found. Available: ${data.concepts.map((c) => c.id).join(', ')}`);
|
|
715
|
-
}
|
|
716
|
-
if (concept.knowledgePoints.length > 0) {
|
|
717
|
-
throw new Error(`Concept "${conceptId}" already has ${concept.knowledgePoints.length} KP(s). Remove them first to regenerate.`);
|
|
718
|
-
}
|
|
719
|
-
const kpCount = options.kps ?? 2;
|
|
720
|
-
const problemsPerKp = options.problemsPerKp ?? 3;
|
|
721
|
-
const newKps = [];
|
|
722
|
-
for (let i = 1; i <= kpCount; i++) {
|
|
723
|
-
const problems = [];
|
|
724
|
-
for (let j = 1; j <= problemsPerKp; j++) {
|
|
725
|
-
problems.push({
|
|
726
|
-
id: `${conceptId}-kp${i}-p${j}`,
|
|
727
|
-
type: 'multiple_choice',
|
|
728
|
-
question: `TODO: Write question ${j} for ${conceptId} KP${i}`,
|
|
729
|
-
options: ['Option A', 'Option B', 'Option C', 'Option D'],
|
|
730
|
-
correct: 0,
|
|
731
|
-
explanation: 'TODO: Explain the correct answer',
|
|
732
|
-
difficulty: Math.min(j + 1, 5),
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
newKps.push({
|
|
736
|
-
id: `${conceptId}-kp${i}`,
|
|
737
|
-
instruction: `TODO: Write instruction for ${concept.name} — knowledge point ${i}`,
|
|
738
|
-
workedExample: `TODO: Write a worked example for ${concept.name} — knowledge point ${i}`,
|
|
739
|
-
problems,
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
// Rebuild the raw object to preserve structure
|
|
743
|
-
const rawObj = raw;
|
|
744
|
-
const concepts = rawObj['concepts'];
|
|
745
|
-
const targetConcept = concepts.find((c) => c['id'] === conceptId);
|
|
746
|
-
if (targetConcept) {
|
|
747
|
-
targetConcept['knowledgePoints'] = newKps;
|
|
748
|
-
}
|
|
749
|
-
return yaml.dump(rawObj, { lineWidth: 120, noRefs: true });
|
|
750
|
-
}
|
|
751
|
-
const TOOLS = [
|
|
147
|
+
},
|
|
752
148
|
{
|
|
753
149
|
name: 'graspful_scaffold_course',
|
|
754
150
|
description: `Generate a course YAML skeleton with sections, concepts, and prerequisite edges. Returns a minimal valid YAML structure with TODO placeholders.
|
|
@@ -793,7 +189,7 @@ Fails if the concept already has KPs (to prevent accidental overwrites).`,
|
|
|
793
189
|
properties: {
|
|
794
190
|
yaml: { type: 'string', description: 'The full course YAML string' },
|
|
795
191
|
conceptId: { type: 'string', description: 'ID of the concept to fill (must exist in the YAML and have 0 KPs)' },
|
|
796
|
-
kps: { type: 'number', description: 'Number of KP stubs to add (default:
|
|
192
|
+
kps: { type: 'number', description: 'Number of KP stubs to add as a starting point (default: 3, not a cap)' },
|
|
797
193
|
problemsPerKp: { type: 'number', description: 'Number of problem stubs per KP (default: 3)' },
|
|
798
194
|
},
|
|
799
195
|
required: ['yaml', 'conceptId'],
|
|
@@ -829,7 +225,7 @@ The 10 checks are:
|
|
|
829
225
|
3. prerequisites_valid — All prerequisite refs point to real concepts
|
|
830
226
|
4. question_deduplication — No near-duplicate questions at the same difficulty
|
|
831
227
|
5. difficulty_staircase — Each concept has problems at 2+ difficulty levels
|
|
832
|
-
6.
|
|
228
|
+
6. problem_teaching_alignment — Problems only assess material introduced in the current lesson path
|
|
833
229
|
7. problem_variant_depth — Each KP has 3+ problems
|
|
834
230
|
8. instruction_formatting — Long instructions have content blocks
|
|
835
231
|
9. worked_example_coverage — 50%+ of authored concepts have worked examples
|
|
@@ -844,6 +240,29 @@ A score of 10/10 is required for publishing. Run this before graspful_import_cou
|
|
|
844
240
|
required: ['yaml'],
|
|
845
241
|
},
|
|
846
242
|
},
|
|
243
|
+
{
|
|
244
|
+
name: 'graspful_import_academy',
|
|
245
|
+
description: `Import an academy manifest and its referenced course YAMLs into a Graspful organization.
|
|
246
|
+
|
|
247
|
+
IMPORTANT: Requires authentication. If not authenticated, run \`graspful register\` in a terminal first or set the \`GRASPFUL_API_KEY\` environment variable. Without auth, this tool will fail.
|
|
248
|
+
|
|
249
|
+
If publish=true, Graspful imports the academy first and then attempts to publish each imported course. Returns the academy result plus publishedCourseIds and publishFailures.`,
|
|
250
|
+
inputSchema: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
manifestYaml: { type: 'string', description: 'The full academy manifest YAML string' },
|
|
254
|
+
courseYamls: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
description: 'Object mapping manifest file paths to the full course YAML strings',
|
|
257
|
+
},
|
|
258
|
+
org: { type: 'string', description: 'Organization slug (e.g., "acme-learning")' },
|
|
259
|
+
publish: { type: 'boolean', description: 'If true, publish every imported course after academy import. Default: false' },
|
|
260
|
+
replace: { type: 'boolean', description: 'Replace existing academy/course content on re-import. Default: false' },
|
|
261
|
+
archiveMissing: { type: 'boolean', description: 'Archive removed content on re-import. Default: false' },
|
|
262
|
+
},
|
|
263
|
+
required: ['manifestYaml', 'courseYamls', 'org'],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
847
266
|
{
|
|
848
267
|
name: 'graspful_import_course',
|
|
849
268
|
description: `Import a course YAML into a Graspful organization. Creates the course as a draft by default.
|
|
@@ -917,6 +336,7 @@ Edit the YAML to customize, then import with \`graspful_import_brand\`.`,
|
|
|
917
336
|
properties: {
|
|
918
337
|
niche: { type: 'string', description: 'Brand niche: education, healthcare, finance, tech, or legal' },
|
|
919
338
|
name: { type: 'string', description: 'Brand name (default: "{Niche} Academy")' },
|
|
339
|
+
topic: { type: 'string', description: 'Academy topic for more specific landing-page copy' },
|
|
920
340
|
domain: { type: 'string', description: 'Custom domain (default: "{slug}.graspful.ai")' },
|
|
921
341
|
orgSlug: { type: 'string', description: 'Organization slug to associate with' },
|
|
922
342
|
},
|
|
@@ -954,6 +374,7 @@ Returns an array of courses with their IDs, names, published status, and stats.`
|
|
|
954
374
|
},
|
|
955
375
|
},
|
|
956
376
|
];
|
|
377
|
+
exports.TOOLS = TOOLS;
|
|
957
378
|
function textResult(text) {
|
|
958
379
|
return { content: [{ type: 'text', text }] };
|
|
959
380
|
}
|
|
@@ -962,39 +383,113 @@ function errorResult(text) {
|
|
|
962
383
|
}
|
|
963
384
|
async function handleToolCall(name, args) {
|
|
964
385
|
switch (name) {
|
|
386
|
+
case 'graspful_create_academy': {
|
|
387
|
+
const topic = args.topic;
|
|
388
|
+
const obj = (0, shared_1.scaffoldAcademyObject)(topic, {
|
|
389
|
+
courseNames: args.courseNames,
|
|
390
|
+
version: args.version,
|
|
391
|
+
});
|
|
392
|
+
mcpCapture('academy scaffolded', { topic, course_count: obj.courses.length });
|
|
393
|
+
return textResult(dumpYaml(obj));
|
|
394
|
+
}
|
|
965
395
|
case 'graspful_scaffold_course': {
|
|
966
396
|
const topic = args.topic;
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
397
|
+
const obj = (0, shared_1.scaffoldCourseObject)(topic, {
|
|
398
|
+
hours: args.estimatedHours,
|
|
399
|
+
source: args.sourceDocument,
|
|
400
|
+
});
|
|
401
|
+
mcpCapture('course scaffolded', { topic, estimated_hours: args.estimatedHours });
|
|
402
|
+
return textResult(dumpYaml(obj));
|
|
972
403
|
}
|
|
973
404
|
case 'graspful_fill_concept': {
|
|
974
405
|
try {
|
|
975
406
|
const conceptId = args.conceptId;
|
|
976
|
-
const
|
|
407
|
+
const raw = parseYaml(args.yaml);
|
|
408
|
+
const updated = (0, shared_1.fillConceptInRaw)(raw, conceptId, {
|
|
409
|
+
kps: args.kps,
|
|
410
|
+
problemsPerKp: args.problemsPerKp,
|
|
411
|
+
});
|
|
977
412
|
mcpCapture('concept filled', { concept_id: conceptId });
|
|
978
|
-
return textResult(
|
|
413
|
+
return textResult(dumpYaml(updated));
|
|
979
414
|
}
|
|
980
415
|
catch (e) {
|
|
981
416
|
return errorResult(e instanceof Error ? e.message : String(e));
|
|
982
417
|
}
|
|
983
418
|
}
|
|
984
419
|
case 'graspful_validate': {
|
|
985
|
-
|
|
420
|
+
let raw;
|
|
421
|
+
try {
|
|
422
|
+
raw = parseYaml(args.yaml);
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
426
|
+
return textResult(JSON.stringify({ valid: false, errors: [`YAML parse error: ${msg}`], stats: {} }, null, 2));
|
|
427
|
+
}
|
|
428
|
+
const result = (0, shared_1.validateParsedYaml)(raw);
|
|
986
429
|
mcpCapture('course validated', { valid: result.valid, error_count: result.errors.length, file_type: result.fileType });
|
|
987
430
|
return textResult(JSON.stringify(result, null, 2));
|
|
988
431
|
}
|
|
989
432
|
case 'graspful_review_course': {
|
|
990
|
-
|
|
433
|
+
let raw;
|
|
434
|
+
try {
|
|
435
|
+
raw = parseYaml(args.yaml);
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
439
|
+
return textResult(JSON.stringify({
|
|
440
|
+
passed: false,
|
|
441
|
+
score: '0/10',
|
|
442
|
+
failures: [{ check: 'yaml_parses', passed: false, details: `YAML parse error: ${msg}` }],
|
|
443
|
+
warnings: [],
|
|
444
|
+
stats: { concepts: 0, kps: 0, problems: 0, authoredConcepts: 0, stubConcepts: 0 },
|
|
445
|
+
}, null, 2));
|
|
446
|
+
}
|
|
447
|
+
const result = (0, shared_1.runQualityGate)(raw);
|
|
991
448
|
mcpCapture('course reviewed', { score: result.score, passed: result.passed });
|
|
992
449
|
return textResult(JSON.stringify(result, null, 2));
|
|
993
450
|
}
|
|
451
|
+
case 'graspful_import_academy': {
|
|
452
|
+
try {
|
|
453
|
+
requireApiAuth();
|
|
454
|
+
const result = await apiFetch('POST', `/api/v1/orgs/${args.org}/academies/import`, {
|
|
455
|
+
manifestYaml: args.manifestYaml,
|
|
456
|
+
courseYamls: args.courseYamls,
|
|
457
|
+
replace: args.replace ?? false,
|
|
458
|
+
archiveMissing: args.archiveMissing ?? false,
|
|
459
|
+
});
|
|
460
|
+
const publishedCourseIds = [];
|
|
461
|
+
const publishFailures = [];
|
|
462
|
+
if (args.publish) {
|
|
463
|
+
for (const courseResult of result.courseResults) {
|
|
464
|
+
try {
|
|
465
|
+
await apiFetch('POST', `/api/v1/orgs/${args.org}/courses/${courseResult.courseId}/publish`, {});
|
|
466
|
+
publishedCourseIds.push(courseResult.courseId);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
publishFailures.push(`${courseResult.courseId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
mcpCapture('academy imported', {
|
|
474
|
+
academy_id: result.academyId,
|
|
475
|
+
org: args.org,
|
|
476
|
+
course_count: result.courseCount,
|
|
477
|
+
published_count: publishedCourseIds.length,
|
|
478
|
+
});
|
|
479
|
+
return textResult(JSON.stringify({
|
|
480
|
+
...result,
|
|
481
|
+
publishedCourseIds,
|
|
482
|
+
publishFailures,
|
|
483
|
+
}, null, 2));
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
return errorResult(`Academy import failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
994
489
|
case 'graspful_import_course': {
|
|
995
490
|
try {
|
|
996
491
|
requireApiAuth();
|
|
997
|
-
const result = await
|
|
492
|
+
const result = await apiFetch('POST', `/api/v1/orgs/${args.org}/courses/import`, { yaml: args.yaml, publish: args.publish ?? false });
|
|
998
493
|
mcpCapture('course imported', { course_id: result.courseId, org: args.org, published: result.published });
|
|
999
494
|
return textResult(JSON.stringify(result, null, 2));
|
|
1000
495
|
}
|
|
@@ -1005,7 +500,7 @@ async function handleToolCall(name, args) {
|
|
|
1005
500
|
case 'graspful_publish_course': {
|
|
1006
501
|
try {
|
|
1007
502
|
requireApiAuth();
|
|
1008
|
-
const result = await
|
|
503
|
+
const result = await apiFetch('POST', `/api/v1/orgs/${args.org}/courses/${args.courseId}/publish`, {});
|
|
1009
504
|
mcpCapture('course published', { course_id: result.courseId, org: args.org, published: result.published });
|
|
1010
505
|
return textResult(JSON.stringify(result, null, 2));
|
|
1011
506
|
}
|
|
@@ -1015,7 +510,9 @@ async function handleToolCall(name, args) {
|
|
|
1015
510
|
}
|
|
1016
511
|
case 'graspful_describe_course': {
|
|
1017
512
|
try {
|
|
1018
|
-
const
|
|
513
|
+
const raw = parseYaml(args.yaml);
|
|
514
|
+
const parsed = shared_1.CourseYamlSchema.parse(raw);
|
|
515
|
+
const stats = (0, shared_1.describeCourse)(parsed);
|
|
1019
516
|
mcpCapture('course described', stats);
|
|
1020
517
|
return textResult(JSON.stringify(stats, null, 2));
|
|
1021
518
|
}
|
|
@@ -1025,25 +522,25 @@ async function handleToolCall(name, args) {
|
|
|
1025
522
|
}
|
|
1026
523
|
case 'graspful_create_brand': {
|
|
1027
524
|
const niche = args.niche;
|
|
1028
|
-
const
|
|
525
|
+
const obj = (0, shared_1.scaffoldBrandObject)(niche, {
|
|
1029
526
|
name: args.name,
|
|
527
|
+
topic: args.topic,
|
|
1030
528
|
domain: args.domain,
|
|
1031
529
|
orgSlug: args.orgSlug,
|
|
1032
530
|
});
|
|
1033
531
|
mcpCapture('brand scaffolded', { niche });
|
|
1034
|
-
return textResult(
|
|
532
|
+
return textResult(dumpYaml(obj));
|
|
1035
533
|
}
|
|
1036
534
|
case 'graspful_import_brand': {
|
|
1037
535
|
try {
|
|
1038
536
|
requireApiAuth();
|
|
1039
537
|
let raw;
|
|
1040
538
|
try {
|
|
1041
|
-
raw =
|
|
539
|
+
raw = parseYaml(args.yaml);
|
|
1042
540
|
}
|
|
1043
541
|
catch (e) {
|
|
1044
542
|
throw new Error(`YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
|
|
1045
543
|
}
|
|
1046
|
-
// Unwrap YAML structure to flat DTO (brand YAML has nested brand: key)
|
|
1047
544
|
const parsed = raw;
|
|
1048
545
|
const brandSection = (parsed.brand || {});
|
|
1049
546
|
const dto = {
|
|
@@ -1058,7 +555,7 @@ async function handleToolCall(name, args) {
|
|
|
1058
555
|
seo: parsed.seo || {},
|
|
1059
556
|
pricing: parsed.pricing || {},
|
|
1060
557
|
};
|
|
1061
|
-
const result = await
|
|
558
|
+
const result = await apiFetch('POST', '/api/v1/brands', dto);
|
|
1062
559
|
mcpCapture('brand imported', { slug: result.slug, domain: result.domain });
|
|
1063
560
|
return textResult(JSON.stringify(result, null, 2));
|
|
1064
561
|
}
|
|
@@ -1069,7 +566,7 @@ async function handleToolCall(name, args) {
|
|
|
1069
566
|
case 'graspful_list_courses': {
|
|
1070
567
|
try {
|
|
1071
568
|
requireApiAuth();
|
|
1072
|
-
const result = await
|
|
569
|
+
const result = await apiFetch('GET', `/api/v1/orgs/${args.org}/courses`);
|
|
1073
570
|
mcpCapture('courses listed', { org: args.org, count: result.length });
|
|
1074
571
|
return textResult(JSON.stringify(result, null, 2));
|
|
1075
572
|
}
|
|
@@ -1081,36 +578,37 @@ async function handleToolCall(name, args) {
|
|
|
1081
578
|
return errorResult(`Unknown tool: ${name}`);
|
|
1082
579
|
}
|
|
1083
580
|
}
|
|
1084
|
-
// ─── MCP Server
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
});
|
|
1100
|
-
|
|
1101
|
-
async function main() {
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
}
|
|
1105
|
-
main().catch((error) => {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
});
|
|
1109
|
-
async function shutdown() {
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
581
|
+
// ─── MCP Server (only when run directly) ────────────────────────────────────
|
|
582
|
+
if (require.main === module) {
|
|
583
|
+
const server = new index_js_1.Server({
|
|
584
|
+
name: 'graspful',
|
|
585
|
+
version: '0.2.4',
|
|
586
|
+
}, {
|
|
587
|
+
capabilities: {
|
|
588
|
+
tools: {},
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
592
|
+
return { tools: TOOLS };
|
|
593
|
+
});
|
|
594
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
595
|
+
const { name, arguments: args } = request.params;
|
|
596
|
+
return handleToolCall(name, args ?? {});
|
|
597
|
+
});
|
|
598
|
+
async function main() {
|
|
599
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
600
|
+
await server.connect(transport);
|
|
601
|
+
}
|
|
602
|
+
main().catch((error) => {
|
|
603
|
+
console.error('Fatal error:', error);
|
|
604
|
+
process.exit(1);
|
|
605
|
+
});
|
|
606
|
+
async function shutdown() {
|
|
607
|
+
if (posthogClient)
|
|
608
|
+
await posthogClient.shutdown();
|
|
609
|
+
process.exit(0);
|
|
610
|
+
}
|
|
611
|
+
process.on('SIGTERM', shutdown);
|
|
612
|
+
process.on('SIGINT', shutdown);
|
|
1113
613
|
}
|
|
1114
|
-
process.on('SIGTERM', shutdown);
|
|
1115
|
-
process.on('SIGINT', shutdown);
|
|
1116
614
|
//# sourceMappingURL=index.js.map
|