hmn-masterclass-mcp 1.0.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.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { main } = require('../src/server');
4
+
5
+ main().catch((err) => {
6
+ process.stderr.write(`Fatal: ${err.message}\n`);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "hmn-masterclass-mcp",
3
+ "version": "1.0.0",
4
+ "description": "HMN Masterclass MCP Server — adaptive learning for AI upskilling",
5
+ "bin": { "hmn-masterclass": "./bin/hmn-masterclass.js" },
6
+ "main": "src/server.js",
7
+ "scripts": { "start": "node bin/hmn-masterclass.js" },
8
+ "dependencies": {
9
+ "@modelcontextprotocol/sdk": "^1.20.2",
10
+ "zod": "^3.25.76",
11
+ "axios": "^1.12.0"
12
+ }
13
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Activity logger — appends sanitized JSON-line events to ~/.hmn/activity.jsonl.
3
+ * Thread-safe via appendFileSync (simplest approach in single-process MCP context).
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { ensureConfigDir } = require('../util/config');
9
+ const { sanitizeEvent } = require('../util/privacy');
10
+
11
+ const ACTIVITY_FILE = 'activity.jsonl';
12
+
13
+ class ActivityLogger {
14
+ constructor(config) {
15
+ this.config = config;
16
+ this.trackingEnabled = config.tracking_enabled !== false;
17
+ ensureConfigDir();
18
+ this.filePath = path.join(require('../util/config').getConfigPath(), ACTIVITY_FILE);
19
+ }
20
+
21
+ /**
22
+ * Log a single event to activity.jsonl.
23
+ * Applies privacy sanitization before writing.
24
+ * Silently skips if tracking is disabled (except for explicit user actions).
25
+ *
26
+ * @param {Object} event - ActivityEvent object
27
+ * @param {Object} [options] - { force: boolean } — force logging even if tracking disabled
28
+ */
29
+ logEvent(event, options = {}) {
30
+ // Allow force-logging for explicit user actions (log_build, submit_homework)
31
+ if (!this.trackingEnabled && !options.force) {
32
+ return;
33
+ }
34
+
35
+ try {
36
+ const enriched = {
37
+ timestamp: new Date().toISOString(),
38
+ participant_email: this.config.email,
39
+ ...event
40
+ };
41
+
42
+ const sanitized = sanitizeEvent(enriched);
43
+ const line = JSON.stringify(sanitized) + '\n';
44
+
45
+ fs.appendFileSync(this.filePath, line, 'utf-8');
46
+ } catch (err) {
47
+ // Never throw from logger — just write to stderr
48
+ process.stderr.write(`[hmn-mcp] Logger error: ${err.message}\n`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Set tracking enabled/disabled state.
54
+ */
55
+ setTracking(enabled) {
56
+ this.trackingEnabled = enabled;
57
+ }
58
+
59
+ /**
60
+ * Get the path to the activity log file.
61
+ */
62
+ getFilePath() {
63
+ return this.filePath;
64
+ }
65
+ }
66
+
67
+ module.exports = { ActivityLogger };
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Background sync engine — reads events from activity.jsonl after sync_cursor,
3
+ * batches them, and POSTs to the platform API.
4
+ *
5
+ * Non-blocking: sync failures never affect MCP tool responses.
6
+ * Exponential backoff: 60s -> 120s -> 240s -> 600s max on failure.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { getConfigPath } = require('../util/config');
12
+
13
+ const SYNC_CURSOR_FILE = 'sync_cursor.txt';
14
+ const ACTIVITY_FILE = 'activity.jsonl';
15
+ const MAX_BATCH_SIZE = 100;
16
+ const MAX_BACKOFF_MS = 600000; // 10 minutes
17
+
18
+ class SyncEngine {
19
+ constructor(config, httpClient, logger) {
20
+ this.config = config;
21
+ this.http = httpClient;
22
+ this.logger = logger;
23
+ this.intervalId = null;
24
+ this.currentIntervalMs = config.sync_interval_ms || 60000;
25
+ this.baseIntervalMs = this.currentIntervalMs;
26
+ this.backoffMultiplier = 1;
27
+ this.authError = false;
28
+
29
+ const configDir = getConfigPath();
30
+ this.cursorPath = path.join(configDir, SYNC_CURSOR_FILE);
31
+ this.activityPath = path.join(configDir, ACTIVITY_FILE);
32
+ }
33
+
34
+ /**
35
+ * Start the background sync interval.
36
+ */
37
+ start() {
38
+ // Run an initial sync after a short delay to catch any pending events
39
+ setTimeout(() => this.sync(), 5000);
40
+
41
+ this.intervalId = setInterval(() => {
42
+ this.sync();
43
+ }, this.currentIntervalMs);
44
+
45
+ return this;
46
+ }
47
+
48
+ /**
49
+ * Stop the background sync.
50
+ */
51
+ stop() {
52
+ if (this.intervalId) {
53
+ clearInterval(this.intervalId);
54
+ this.intervalId = null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Trigger an immediate sync (used after log_build, submit_homework).
60
+ */
61
+ async syncNow() {
62
+ return this.sync();
63
+ }
64
+
65
+ /**
66
+ * Read the current sync cursor (line number of last synced line).
67
+ */
68
+ readCursor() {
69
+ try {
70
+ if (fs.existsSync(this.cursorPath)) {
71
+ const raw = fs.readFileSync(this.cursorPath, 'utf-8').trim();
72
+ const num = parseInt(raw, 10);
73
+ return isNaN(num) ? 0 : num;
74
+ }
75
+ } catch {
76
+ // Ignore
77
+ }
78
+ return 0;
79
+ }
80
+
81
+ /**
82
+ * Write the sync cursor.
83
+ */
84
+ writeCursor(lineNumber) {
85
+ try {
86
+ fs.writeFileSync(this.cursorPath, String(lineNumber), 'utf-8');
87
+ } catch (err) {
88
+ process.stderr.write(`[hmn-mcp] Failed to write sync cursor: ${err.message}\n`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Read unsynced lines from activity.jsonl.
94
+ */
95
+ readUnsyncedEvents() {
96
+ try {
97
+ if (!fs.existsSync(this.activityPath)) {
98
+ return { events: [], totalLines: 0 };
99
+ }
100
+
101
+ const content = fs.readFileSync(this.activityPath, 'utf-8');
102
+ const lines = content.split('\n').filter((line) => line.trim().length > 0);
103
+ const cursor = this.readCursor();
104
+
105
+ // Get lines after the cursor
106
+ const unsyncedLines = lines.slice(cursor);
107
+ const events = [];
108
+
109
+ for (const line of unsyncedLines.slice(0, MAX_BATCH_SIZE)) {
110
+ try {
111
+ events.push(JSON.parse(line));
112
+ } catch {
113
+ // Skip malformed lines
114
+ }
115
+ }
116
+
117
+ return { events, totalLines: lines.length, cursor };
118
+ } catch (err) {
119
+ process.stderr.write(`[hmn-mcp] Failed to read activity log: ${err.message}\n`);
120
+ return { events: [], totalLines: 0, cursor: 0 };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Core sync method — reads unsynced events and POSTs them to the API.
126
+ */
127
+ async sync() {
128
+ // Don't retry if we got a 401
129
+ if (this.authError) return;
130
+
131
+ try {
132
+ const { events, totalLines, cursor } = this.readUnsyncedEvents();
133
+
134
+ if (events.length === 0) return;
135
+
136
+ await this.http.post('/api/masterclass/activity', {
137
+ participant_email: this.config.email,
138
+ events
139
+ });
140
+
141
+ // Success — advance cursor
142
+ const newCursor = cursor + events.length;
143
+ this.writeCursor(newCursor);
144
+
145
+ // Reset backoff on success
146
+ this.resetBackoff();
147
+
148
+ process.stderr.write(
149
+ `[hmn-mcp] Synced ${events.length} events (cursor: ${newCursor}/${totalLines})\n`
150
+ );
151
+ } catch (err) {
152
+ // Check for auth errors — don't retry
153
+ if (err.message && err.message.includes('401')) {
154
+ this.authError = true;
155
+ process.stderr.write(
156
+ `[hmn-mcp] Auth error during sync — will not retry until next manual trigger.\n`
157
+ );
158
+ return;
159
+ }
160
+
161
+ // Apply exponential backoff
162
+ this.applyBackoff();
163
+ process.stderr.write(
164
+ `[hmn-mcp] Sync failed (next retry in ${this.currentIntervalMs / 1000}s): ${err.message}\n`
165
+ );
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Apply exponential backoff — doubles the interval up to MAX_BACKOFF_MS.
171
+ */
172
+ applyBackoff() {
173
+ this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MS / this.baseIntervalMs);
174
+ this.currentIntervalMs = Math.min(this.baseIntervalMs * this.backoffMultiplier, MAX_BACKOFF_MS);
175
+
176
+ // Restart interval with new timing
177
+ if (this.intervalId) {
178
+ clearInterval(this.intervalId);
179
+ this.intervalId = setInterval(() => this.sync(), this.currentIntervalMs);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Reset backoff to base interval after a successful sync.
185
+ */
186
+ resetBackoff() {
187
+ if (this.backoffMultiplier !== 1) {
188
+ this.backoffMultiplier = 1;
189
+ this.currentIntervalMs = this.baseIntervalMs;
190
+
191
+ // Restart interval with base timing
192
+ if (this.intervalId) {
193
+ clearInterval(this.intervalId);
194
+ this.intervalId = setInterval(() => this.sync(), this.currentIntervalMs);
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Reset auth error flag (called when user manually triggers sync).
201
+ */
202
+ resetAuthError() {
203
+ this.authError = false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Factory: create and start the sync engine.
209
+ */
210
+ function startSync(config, httpClient, logger) {
211
+ const engine = new SyncEngine(config, httpClient, logger);
212
+ engine.start();
213
+ return engine;
214
+ }
215
+
216
+ module.exports = { SyncEngine, startSync };
package/src/server.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @hmn/masterclass-mcp — MCP Server
3
+ *
4
+ * Adaptive learning server for HMN Masterclass participants.
5
+ * Exposes tools for progress tracking, personalized prompts, build logging,
6
+ * and homework submission. Passively logs activity and syncs to the platform.
7
+ */
8
+
9
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
10
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
11
+ const { loadConfig } = require('./util/config');
12
+ const { createHttpClient } = require('./util/http-client');
13
+ const { ActivityLogger } = require('./activity/logger');
14
+ const { startSync } = require('./activity/sync');
15
+ const { registerProgressTools } = require('./tools/progress');
16
+ const { registerContentTools } = require('./tools/content');
17
+ const { registerLoggingTools } = require('./tools/logging');
18
+ const { registerTrackingTools } = require('./tools/tracking');
19
+
20
+ function createServer(config) {
21
+ const server = new McpServer({
22
+ name: 'hmn-masterclass',
23
+ version: '1.0.0'
24
+ });
25
+
26
+ // Initialize HTTP client
27
+ const http = createHttpClient(config);
28
+
29
+ // Initialize activity logger
30
+ const logger = new ActivityLogger(config);
31
+
32
+ // Initialize sync engine (background interval)
33
+ const syncEngine = startSync(config, http, logger);
34
+
35
+ // Register all tool groups
36
+ registerProgressTools(server, http, logger);
37
+ registerContentTools(server, http, logger);
38
+ registerLoggingTools(server, http, logger, syncEngine);
39
+ registerTrackingTools(server, http, logger);
40
+
41
+ // Log session_start event
42
+ logger.logEvent({
43
+ event_type: 'session_start',
44
+ data: {
45
+ timestamp: new Date().toISOString()
46
+ }
47
+ });
48
+
49
+ // Handle process exit — log session_end and stop sync
50
+ const cleanup = () => {
51
+ logger.logEvent({
52
+ event_type: 'session_end',
53
+ data: {
54
+ timestamp: new Date().toISOString()
55
+ }
56
+ }, { force: true });
57
+ syncEngine.stop();
58
+ };
59
+
60
+ process.on('SIGINT', () => {
61
+ cleanup();
62
+ process.exit(0);
63
+ });
64
+
65
+ process.on('SIGTERM', () => {
66
+ cleanup();
67
+ process.exit(0);
68
+ });
69
+
70
+ process.on('exit', () => {
71
+ // Best-effort — synchronous only at this point
72
+ try {
73
+ logger.logEvent({
74
+ event_type: 'session_end',
75
+ data: {
76
+ timestamp: new Date().toISOString()
77
+ }
78
+ }, { force: true });
79
+ } catch {
80
+ // Can't do anything here
81
+ }
82
+ });
83
+
84
+ return server;
85
+ }
86
+
87
+ async function main() {
88
+ const config = loadConfig();
89
+
90
+ // Validate required config
91
+ if (!config.email) {
92
+ process.stderr.write(
93
+ 'Error: HMN_EMAIL environment variable is required.\n' +
94
+ 'Set it in your MCP server config:\n' +
95
+ ' "env": { "HMN_EMAIL": "your.email@company.com" }\n'
96
+ );
97
+ process.exit(1);
98
+ }
99
+
100
+ if (!config.api_key) {
101
+ process.stderr.write(
102
+ 'Error: HMN_API_KEY environment variable is required.\n' +
103
+ 'Generate one at https://behmn.com/dashboard → Settings → API Key\n' +
104
+ 'Then set it in your MCP server config:\n' +
105
+ ' "env": { "HMN_API_KEY": "hmn_ak_..." }\n'
106
+ );
107
+ process.exit(1);
108
+ }
109
+
110
+ process.stderr.write(
111
+ `[hmn-mcp] Starting HMN Masterclass MCP server for ${config.email}\n` +
112
+ `[hmn-mcp] API host: ${config.api_host}\n` +
113
+ `[hmn-mcp] Tracking: ${config.tracking_enabled ? 'enabled' : 'disabled'}\n` +
114
+ `[hmn-mcp] Sync interval: ${config.sync_interval_ms / 1000}s\n`
115
+ );
116
+
117
+ const server = createServer(config);
118
+ const transport = new StdioServerTransport();
119
+ await server.connect(transport);
120
+ }
121
+
122
+ module.exports = { createServer, main };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Content tools — hmn_get_prompt and hmn_my_profile.
3
+ */
4
+
5
+ const { z } = require('zod');
6
+
7
+ function registerContentTools(server, http, logger) {
8
+
9
+ // ─── hmn_get_prompt ───────────────────────────────────────────────────
10
+ server.tool(
11
+ 'hmn_get_prompt',
12
+ 'Get personalized prompts for a specific module and section',
13
+ {
14
+ module_number: z.number().describe('Module number (e.g. 1, 2, 3)'),
15
+ section_id: z.string().describe('Section identifier (e.g. "competitive-intel", "client-brief")')
16
+ },
17
+ async ({ module_number, section_id }) => {
18
+ try {
19
+ const profile = await http.get('/api/masterclass/me');
20
+ const modules = profile.modules || [];
21
+
22
+ // Find the matching module
23
+ const mod = modules.find(
24
+ (m) => (m.module_number || m.number) === module_number
25
+ );
26
+
27
+ if (!mod) {
28
+ return {
29
+ content: [{
30
+ type: 'text',
31
+ text: `Module ${module_number} not found. Available modules: ${modules.map((m) => m.module_number || m.number).join(', ')}`
32
+ }]
33
+ };
34
+ }
35
+
36
+ // Find the matching section
37
+ const section = (mod.sections || []).find(
38
+ (s) => s.id === section_id || s.title?.toLowerCase().includes(section_id.toLowerCase())
39
+ );
40
+
41
+ if (!section) {
42
+ const sectionIds = (mod.sections || []).map((s) => s.id || s.title);
43
+ return {
44
+ content: [{
45
+ type: 'text',
46
+ text: `Section "${section_id}" not found in Module ${module_number}. Available sections: ${sectionIds.join(', ')}`
47
+ }]
48
+ };
49
+ }
50
+
51
+ // Get personalized content for this section
52
+ const personalizedContent = profile.personalizedContent || profile.personalized_content || {};
53
+ const moduleKey = `module_${module_number}`;
54
+ const sectionContent = personalizedContent[moduleKey]?.[section_id] ||
55
+ personalizedContent[section_id] ||
56
+ null;
57
+
58
+ let text = `## Module ${module_number}: ${mod.title}\n`;
59
+ text += `### ${section.title || section_id}\n\n`;
60
+
61
+ if (section.description) {
62
+ text += `${section.description}\n\n`;
63
+ }
64
+
65
+ // Format personalized examples
66
+ if (sectionContent) {
67
+ if (sectionContent.examples && sectionContent.examples.length > 0) {
68
+ text += `### Personalized Examples\n\n`;
69
+ for (const example of sectionContent.examples) {
70
+ text += `**${example.title || 'Example'}**\n`;
71
+ if (example.context) text += `_Context:_ ${example.context}\n`;
72
+ if (example.prompt) {
73
+ text += `\n\`\`\`\n${example.prompt}\n\`\`\`\n\n`;
74
+ }
75
+ if (example.expected_outcome) {
76
+ text += `_Expected outcome:_ ${example.expected_outcome}\n\n`;
77
+ }
78
+ }
79
+ }
80
+
81
+ if (sectionContent.homework) {
82
+ text += `### Homework\n\n`;
83
+ text += `${sectionContent.homework.description || sectionContent.homework}\n\n`;
84
+
85
+ if (sectionContent.homework.checklist) {
86
+ text += `**Checklist:**\n`;
87
+ for (const item of sectionContent.homework.checklist) {
88
+ text += `- [ ] ${item}\n`;
89
+ }
90
+ text += `\n`;
91
+ }
92
+ }
93
+
94
+ if (sectionContent.callouts && sectionContent.callouts.length > 0) {
95
+ text += `### Key Notes\n\n`;
96
+ for (const callout of sectionContent.callouts) {
97
+ text += `> ${callout}\n\n`;
98
+ }
99
+ }
100
+ } else {
101
+ text += `_No personalized content available for this section yet. `;
102
+ text += `Complete the section activities and your content will be generated based on your profile._\n`;
103
+ }
104
+
105
+ return { content: [{ type: 'text', text }] };
106
+ } catch (err) {
107
+ return { content: [{ type: 'text', text: `Error fetching prompt: ${err.message}` }] };
108
+ }
109
+ }
110
+ );
111
+
112
+ // ─── hmn_my_profile ───────────────────────────────────────────────────
113
+ server.tool(
114
+ 'hmn_my_profile',
115
+ 'View your full AI profile — maturity level, pain points, use cases, learning goals',
116
+ {},
117
+ async () => {
118
+ try {
119
+ const profile = await http.get('/api/masterclass/me');
120
+
121
+ const profileData = profile.profile_data || profile.profileData || profile;
122
+ const maturityLevel = profile.maturity_level || profile.maturityLevel || 0;
123
+
124
+ let text = `## Your AI Profile\n\n`;
125
+
126
+ // Basic info
127
+ text += `**Name:** ${profile.name || profile.full_name || 'N/A'}\n`;
128
+ text += `**Email:** ${profile.email || 'N/A'}\n`;
129
+ text += `**Organization:** ${profile.organization || profile.company || 'N/A'}\n`;
130
+ text += `**Role:** ${profile.role || profile.job_title || 'N/A'}\n\n`;
131
+
132
+ // Maturity level
133
+ text += `### AI Maturity Level\n`;
134
+ text += `**Level ${maturityLevel}** — ${getLevelLabel(maturityLevel)}\n\n`;
135
+
136
+ // Background
137
+ if (profileData.background || profileData.experience) {
138
+ text += `### Background\n`;
139
+ text += `${profileData.background || profileData.experience}\n\n`;
140
+ }
141
+
142
+ // Pain points
143
+ if (profileData.pain_points || profileData.painPoints) {
144
+ const pains = profileData.pain_points || profileData.painPoints;
145
+ text += `### Pain Points\n`;
146
+ if (Array.isArray(pains)) {
147
+ for (const pain of pains) {
148
+ text += `- ${pain}\n`;
149
+ }
150
+ } else {
151
+ text += `${pains}\n`;
152
+ }
153
+ text += `\n`;
154
+ }
155
+
156
+ // Use cases
157
+ if (profileData.use_cases || profileData.useCases) {
158
+ const cases = profileData.use_cases || profileData.useCases;
159
+ text += `### Use Cases\n`;
160
+ if (Array.isArray(cases)) {
161
+ for (const uc of cases) {
162
+ text += `- ${uc}\n`;
163
+ }
164
+ } else {
165
+ text += `${cases}\n`;
166
+ }
167
+ text += `\n`;
168
+ }
169
+
170
+ // Wants to learn
171
+ if (profileData.wants_to_learn || profileData.wantsToLearn || profileData.learning_goals) {
172
+ const goals = profileData.wants_to_learn || profileData.wantsToLearn || profileData.learning_goals;
173
+ text += `### Wants to Learn\n`;
174
+ if (Array.isArray(goals)) {
175
+ for (const goal of goals) {
176
+ text += `- ${goal}\n`;
177
+ }
178
+ } else {
179
+ text += `${goals}\n`;
180
+ }
181
+ text += `\n`;
182
+ }
183
+
184
+ // Tools already using
185
+ if (profileData.tools_using || profileData.toolsUsing || profileData.current_tools) {
186
+ const tools = profileData.tools_using || profileData.toolsUsing || profileData.current_tools;
187
+ text += `### Tools Currently Using\n`;
188
+ if (Array.isArray(tools)) {
189
+ for (const tool of tools) {
190
+ text += `- ${tool}\n`;
191
+ }
192
+ } else {
193
+ text += `${tools}\n`;
194
+ }
195
+ text += `\n`;
196
+ }
197
+
198
+ return { content: [{ type: 'text', text }] };
199
+ } catch (err) {
200
+ return { content: [{ type: 'text', text: `Error fetching profile: ${err.message}` }] };
201
+ }
202
+ }
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Map maturity level number to label.
208
+ */
209
+ function getLevelLabel(level) {
210
+ const labels = {
211
+ 0: 'Not Assessed',
212
+ 1: 'AI Curious',
213
+ 2: 'AI Experimenting',
214
+ 3: 'AI Connecting',
215
+ 4: 'AI Collaborating',
216
+ 5: 'AI Leading'
217
+ };
218
+ return labels[level] || `Level ${level}`;
219
+ }
220
+
221
+ module.exports = { registerContentTools };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Logging tools — hmn_log_build and hmn_submit_homework.
3
+ */
4
+
5
+ const { z } = require('zod');
6
+
7
+ function registerLoggingTools(server, http, logger, syncEngine) {
8
+
9
+ // ─── hmn_log_build ────────────────────────────────────────────────────
10
+ server.tool(
11
+ 'hmn_log_build',
12
+ 'Log something you built — tell the system what you created',
13
+ {
14
+ description: z.string().describe('What you built — describe it in plain language'),
15
+ tools_used: z.array(z.string()).optional().describe('Tools you used (e.g. ["Firecrawl", "Claude", "Chrome DevTools"])')
16
+ },
17
+ async ({ description, tools_used }) => {
18
+ try {
19
+ // Write build_logged event to activity log
20
+ logger.logEvent({
21
+ event_type: 'build_logged',
22
+ data: {
23
+ description,
24
+ tools_used: tools_used || []
25
+ }
26
+ }, { force: true });
27
+
28
+ // Trigger immediate sync so the platform sees it right away
29
+ if (syncEngine) {
30
+ syncEngine.resetAuthError();
31
+ syncEngine.syncNow().catch(() => {
32
+ // Non-blocking — ignore sync errors
33
+ });
34
+ }
35
+
36
+ let text = `## Build Logged\n\n`;
37
+ text += `**What you built:** ${description}\n`;
38
+ if (tools_used && tools_used.length > 0) {
39
+ text += `**Tools used:** ${tools_used.join(', ')}\n`;
40
+ }
41
+ text += `\nYour build has been recorded and will be synced to the platform. `;
42
+ text += `Builds like this help demonstrate your AI maturity growth.\n`;
43
+
44
+ return { content: [{ type: 'text', text }] };
45
+ } catch (err) {
46
+ return { content: [{ type: 'text', text: `Error logging build: ${err.message}` }] };
47
+ }
48
+ }
49
+ );
50
+
51
+ // ─── hmn_submit_homework ──────────────────────────────────────────────
52
+ server.tool(
53
+ 'hmn_submit_homework',
54
+ 'Submit your homework assignment',
55
+ {
56
+ module_number: z.number().describe('Module number (e.g. 1, 2, 3)'),
57
+ description: z.string().describe('Describe what you did for the homework'),
58
+ files_created: z.array(z.string()).optional().describe('File paths or names you created for this assignment')
59
+ },
60
+ async ({ module_number, description, files_created }) => {
61
+ try {
62
+ // POST to platform to mark progress
63
+ let progressResult = null;
64
+ try {
65
+ progressResult = await http.post('/api/masterclass/progress', {
66
+ module_number,
67
+ description,
68
+ files_created: files_created || []
69
+ });
70
+ } catch (apiErr) {
71
+ // Log the event even if the API call fails
72
+ process.stderr.write(
73
+ `[hmn-mcp] Progress API error (event still logged locally): ${apiErr.message}\n`
74
+ );
75
+ }
76
+
77
+ // Write homework_submitted event to activity log
78
+ logger.logEvent({
79
+ event_type: 'homework_submitted',
80
+ data: {
81
+ module_number,
82
+ description,
83
+ file_names: files_created || [],
84
+ success: progressResult !== null
85
+ }
86
+ }, { force: true });
87
+
88
+ // Trigger immediate sync
89
+ if (syncEngine) {
90
+ syncEngine.resetAuthError();
91
+ syncEngine.syncNow().catch(() => {
92
+ // Non-blocking
93
+ });
94
+ }
95
+
96
+ let text = `## Homework Submitted\n\n`;
97
+ text += `**Module ${module_number}**\n`;
98
+ text += `**Description:** ${description}\n`;
99
+ if (files_created && files_created.length > 0) {
100
+ text += `**Files:** ${files_created.join(', ')}\n`;
101
+ }
102
+ text += `\n`;
103
+
104
+ if (progressResult) {
105
+ text += `Your submission has been recorded on the platform.\n`;
106
+ if (progressResult.next_assignment) {
107
+ text += `\n### Next Assignment\n`;
108
+ text += `${progressResult.next_assignment}\n`;
109
+ }
110
+ } else {
111
+ text += `Your submission has been logged locally and will sync when the platform is available.\n`;
112
+ }
113
+
114
+ return { content: [{ type: 'text', text }] };
115
+ } catch (err) {
116
+ return { content: [{ type: 'text', text: `Error submitting homework: ${err.message}` }] };
117
+ }
118
+ }
119
+ );
120
+ }
121
+
122
+ module.exports = { registerLoggingTools };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Progress tools — hmn_check_progress and hmn_whats_next.
3
+ */
4
+
5
+ const { z } = require('zod');
6
+
7
+ function registerProgressTools(server, http, logger) {
8
+
9
+ // ─── hmn_check_progress ───────────────────────────────────────────────
10
+ server.tool(
11
+ 'hmn_check_progress',
12
+ 'Check your masterclass progress — current level, completed sections, what\'s next',
13
+ {},
14
+ async () => {
15
+ try {
16
+ const profile = await http.get('/api/masterclass/me');
17
+
18
+ const maturityLevel = profile.maturity_level || profile.maturityLevel || 0;
19
+ const maturityLabel = getLevelLabel(maturityLevel);
20
+ const modules = profile.modules || [];
21
+ const totalSections = modules.reduce(
22
+ (acc, m) => acc + (m.sections?.length || 0), 0
23
+ );
24
+ const completedSections = modules.reduce(
25
+ (acc, m) => acc + (m.sections?.filter((s) => s.completed).length || 0), 0
26
+ );
27
+ const completionPct = totalSections > 0
28
+ ? Math.round((completedSections / totalSections) * 100)
29
+ : 0;
30
+
31
+ // Find next uncompleted sections
32
+ const nextSections = [];
33
+ for (const mod of modules) {
34
+ for (const section of (mod.sections || [])) {
35
+ if (!section.completed && nextSections.length < 3) {
36
+ nextSections.push({
37
+ module: mod.module_number || mod.number,
38
+ title: mod.title,
39
+ section: section.id || section.title,
40
+ sectionTitle: section.title
41
+ });
42
+ }
43
+ }
44
+ }
45
+
46
+ let text = `## Your Masterclass Progress\n\n`;
47
+ text += `**Maturity Level:** ${maturityLevel} — ${maturityLabel}\n`;
48
+ text += `**Completion:** ${completedSections}/${totalSections} sections (${completionPct}%)\n\n`;
49
+
50
+ if (nextSections.length > 0) {
51
+ text += `### Up Next\n`;
52
+ for (const ns of nextSections) {
53
+ text += `- **Module ${ns.module}** (${ns.title}): ${ns.sectionTitle}\n`;
54
+ }
55
+ } else if (completionPct === 100) {
56
+ text += `You've completed all sections! Check with your instructor about next steps.\n`;
57
+ }
58
+
59
+ return { content: [{ type: 'text', text }] };
60
+ } catch (err) {
61
+ return { content: [{ type: 'text', text: `Error checking progress: ${err.message}` }] };
62
+ }
63
+ }
64
+ );
65
+
66
+ // ─── hmn_whats_next ───────────────────────────────────────────────────
67
+ server.tool(
68
+ 'hmn_whats_next',
69
+ 'Get a personalized recommendation for what to work on next',
70
+ {},
71
+ async () => {
72
+ try {
73
+ const [profile, activitySummary] = await Promise.all([
74
+ http.get('/api/masterclass/me'),
75
+ http.get('/api/masterclass/activity/summary').catch(() => null)
76
+ ]);
77
+
78
+ const modules = profile.modules || [];
79
+ const maturityLevel = profile.maturity_level || profile.maturityLevel || 0;
80
+
81
+ // Find incomplete sections
82
+ const incompleteSections = [];
83
+ for (const mod of modules) {
84
+ for (const section of (mod.sections || [])) {
85
+ if (!section.completed) {
86
+ incompleteSections.push({
87
+ module: mod.module_number || mod.number,
88
+ moduleTitle: mod.title,
89
+ sectionId: section.id || section.title,
90
+ sectionTitle: section.title
91
+ });
92
+ }
93
+ }
94
+ }
95
+
96
+ // Analyze activity patterns
97
+ const toolsUsed = activitySummary?.tools_used || [];
98
+ const toolsNeverUsed = activitySummary?.tools_never_used || [];
99
+ const totalBuilds = activitySummary?.total_builds || 0;
100
+ const homeworkStatus = activitySummary?.homework_submitted || [];
101
+ const pendingHomework = homeworkStatus.filter((h) => !h.submitted);
102
+
103
+ let text = `## What to Work on Next\n\n`;
104
+
105
+ // Priority 1: Pending homework
106
+ if (pendingHomework.length > 0) {
107
+ const hw = pendingHomework[0];
108
+ text += `### Priority: Submit Homework\n`;
109
+ text += `You have outstanding homework for **Module ${hw.module}**. `;
110
+ text += `Use \`hmn_submit_homework\` when you're ready to submit.\n\n`;
111
+ }
112
+
113
+ // Priority 2: Next incomplete section
114
+ if (incompleteSections.length > 0) {
115
+ const next = incompleteSections[0];
116
+ text += `### Next Section\n`;
117
+ text += `**Module ${next.module}** (${next.moduleTitle}): ${next.sectionTitle}\n`;
118
+ text += `Use \`hmn_get_prompt\` to get personalized prompts for this section.\n\n`;
119
+ }
120
+
121
+ // Priority 3: Try unused tools
122
+ if (toolsNeverUsed.length > 0) {
123
+ text += `### Try Something New\n`;
124
+ text += `You haven't used these tools yet: **${toolsNeverUsed.slice(0, 3).join(', ')}**. `;
125
+ text += `Experimenting with new tools is key to advancing your maturity level.\n\n`;
126
+ }
127
+
128
+ // Priority 4: Build more
129
+ if (totalBuilds < 3) {
130
+ text += `### Build Challenge\n`;
131
+ text += `You've logged ${totalBuilds} build${totalBuilds !== 1 ? 's' : ''} so far. `;
132
+ text += `Try building something with AI and log it with \`hmn_log_build\`.\n\n`;
133
+ }
134
+
135
+ // Maturity hint
136
+ if (activitySummary?.maturity_evidence?.suggested_level &&
137
+ activitySummary.maturity_evidence.suggested_level > maturityLevel) {
138
+ text += `### Level Up Potential\n`;
139
+ text += `Your activity suggests you might be ready for **Level ${activitySummary.maturity_evidence.suggested_level}**. `;
140
+ text += `Keep building — your instructor will review your progress.\n`;
141
+ }
142
+
143
+ if (incompleteSections.length === 0 && pendingHomework.length === 0) {
144
+ text += `You've completed all assigned work! Talk to your instructor about advanced challenges.\n`;
145
+ }
146
+
147
+ return { content: [{ type: 'text', text }] };
148
+ } catch (err) {
149
+ return { content: [{ type: 'text', text: `Error generating recommendation: ${err.message}` }] };
150
+ }
151
+ }
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Map maturity level number to label.
157
+ */
158
+ function getLevelLabel(level) {
159
+ const labels = {
160
+ 0: 'Not Assessed',
161
+ 1: 'AI Curious',
162
+ 2: 'AI Experimenting',
163
+ 3: 'AI Connecting',
164
+ 4: 'AI Collaborating',
165
+ 5: 'AI Leading'
166
+ };
167
+ return labels[level] || `Level ${level}`;
168
+ }
169
+
170
+ module.exports = { registerProgressTools };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Tracking control tools — hmn_pause_tracking and hmn_resume_tracking.
3
+ */
4
+
5
+ const { z } = require('zod');
6
+ const { loadConfig, saveConfig } = require('../util/config');
7
+
8
+ function registerTrackingTools(server, http, logger) {
9
+
10
+ // ─── hmn_pause_tracking ───────────────────────────────────────────────
11
+ server.tool(
12
+ 'hmn_pause_tracking',
13
+ 'Pause passive activity tracking — your explicit actions (log_build, submit_homework) still work',
14
+ {},
15
+ async () => {
16
+ try {
17
+ saveConfig({ tracking_enabled: false });
18
+ logger.setTracking(false);
19
+
20
+ let text = `## Tracking Paused\n\n`;
21
+ text += `Passive activity tracking is now **disabled**.\n\n`;
22
+ text += `**What still works:**\n`;
23
+ text += `- \`hmn_log_build\` — explicitly log something you built\n`;
24
+ text += `- \`hmn_submit_homework\` — submit homework assignments\n`;
25
+ text += `- \`hmn_check_progress\` — check your progress\n`;
26
+ text += `- All other MCP tools\n\n`;
27
+ text += `**What's paused:**\n`;
28
+ text += `- Automatic logging of tasks, sessions, and tool usage\n\n`;
29
+ text += `Use \`hmn_resume_tracking\` to re-enable passive tracking.\n`;
30
+
31
+ return { content: [{ type: 'text', text }] };
32
+ } catch (err) {
33
+ return { content: [{ type: 'text', text: `Error pausing tracking: ${err.message}` }] };
34
+ }
35
+ }
36
+ );
37
+
38
+ // ─── hmn_resume_tracking ──────────────────────────────────────────────
39
+ server.tool(
40
+ 'hmn_resume_tracking',
41
+ 'Resume passive activity tracking',
42
+ {},
43
+ async () => {
44
+ try {
45
+ saveConfig({ tracking_enabled: true });
46
+ logger.setTracking(true);
47
+
48
+ let text = `## Tracking Resumed\n\n`;
49
+ text += `Passive activity tracking is now **enabled**.\n\n`;
50
+ text += `Your activity (tasks, tool usage, sessions) will be logged locally `;
51
+ text += `and synced to the platform to help personalize your learning experience.\n\n`;
52
+ text += `**Privacy:** Only metadata is tracked (tool names, task counts, session durations). `;
53
+ text += `File contents and full prompts are never logged. `;
54
+ text += `Prompts are truncated to 200 characters and secrets are redacted.\n`;
55
+
56
+ return { content: [{ type: 'text', text }] };
57
+ } catch (err) {
58
+ return { content: [{ type: 'text', text: `Error resuming tracking: ${err.message}` }] };
59
+ }
60
+ }
61
+ );
62
+ }
63
+
64
+ module.exports = { registerTrackingTools };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Configuration management — reads from env vars + ~/.hmn/config.json.
3
+ * Env vars always take precedence over the config file.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ const CONFIG_DIR = path.join(os.homedir(), '.hmn');
11
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
12
+
13
+ /**
14
+ * Ensure ~/.hmn/ directory exists.
15
+ */
16
+ function ensureConfigDir() {
17
+ if (!fs.existsSync(CONFIG_DIR)) {
18
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Get the path to the ~/.hmn/ directory.
24
+ */
25
+ function getConfigPath() {
26
+ ensureConfigDir();
27
+ return CONFIG_DIR;
28
+ }
29
+
30
+ /**
31
+ * Load config from env vars + ~/.hmn/config.json.
32
+ * Env vars take precedence over file values.
33
+ */
34
+ function loadConfig() {
35
+ ensureConfigDir();
36
+
37
+ // Read file config if it exists
38
+ let fileConfig = {};
39
+ if (fs.existsSync(CONFIG_FILE)) {
40
+ try {
41
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
42
+ fileConfig = JSON.parse(raw);
43
+ } catch {
44
+ // Corrupted config file — ignore it
45
+ }
46
+ }
47
+
48
+ // Merge: env vars override file config
49
+ const config = {
50
+ email: process.env.HMN_EMAIL || fileConfig.email || '',
51
+ api_key: process.env.HMN_API_KEY || fileConfig.api_key || '',
52
+ api_host: process.env.HMN_API_HOST || fileConfig.api_host || 'https://behmn.com',
53
+ tracking_enabled:
54
+ fileConfig.tracking_enabled !== undefined
55
+ ? fileConfig.tracking_enabled
56
+ : true,
57
+ sync_interval_ms:
58
+ fileConfig.sync_interval_ms !== undefined
59
+ ? fileConfig.sync_interval_ms
60
+ : 60000
61
+ };
62
+
63
+ return config;
64
+ }
65
+
66
+ /**
67
+ * Write updates to ~/.hmn/config.json.
68
+ * Merges with existing config — does not overwrite unrelated keys.
69
+ */
70
+ function saveConfig(updates) {
71
+ ensureConfigDir();
72
+
73
+ let existing = {};
74
+ if (fs.existsSync(CONFIG_FILE)) {
75
+ try {
76
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
77
+ existing = JSON.parse(raw);
78
+ } catch {
79
+ // Start fresh
80
+ }
81
+ }
82
+
83
+ const merged = { ...existing, ...updates };
84
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
85
+ return merged;
86
+ }
87
+
88
+ module.exports = {
89
+ loadConfig,
90
+ saveConfig,
91
+ getConfigPath,
92
+ ensureConfigDir
93
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * HTTP client — Axios instance with auth headers and error handling.
3
+ */
4
+
5
+ const axios = require('axios');
6
+
7
+ /**
8
+ * Create an authenticated HTTP client for the HMN platform API.
9
+ *
10
+ * @param {Object} config - { api_key, api_host }
11
+ * @returns {import('axios').AxiosInstance} — response interceptor returns .data directly
12
+ */
13
+ function createHttpClient(config) {
14
+ const client = axios.create({
15
+ baseURL: config.api_host,
16
+ headers: {
17
+ 'Authorization': `Bearer ${config.api_key}`,
18
+ 'X-HMN-API-Key': config.api_key,
19
+ 'Content-Type': 'application/json'
20
+ },
21
+ timeout: 10000
22
+ });
23
+
24
+ // Unwrap response → return .data directly
25
+ client.interceptors.response.use(
26
+ (response) => response.data,
27
+ (error) => {
28
+ const status = error.response?.status;
29
+ const message =
30
+ error.response?.data?.message ||
31
+ error.response?.data?.error ||
32
+ error.message;
33
+
34
+ if (status === 401) {
35
+ return Promise.reject(
36
+ new Error(
37
+ `HMN API auth error (401): Invalid or expired API key. ` +
38
+ `Regenerate at ${config.api_host}/dashboard → Settings → API Key.`
39
+ )
40
+ );
41
+ }
42
+
43
+ if (status === 404) {
44
+ return Promise.reject(
45
+ new Error(
46
+ `HMN API not found (404): ${message}. ` +
47
+ `Check that your HMN_API_HOST is correct (current: ${config.api_host}).`
48
+ )
49
+ );
50
+ }
51
+
52
+ return Promise.reject(
53
+ new Error(`HMN API error (${status || 'network'}): ${message}`)
54
+ );
55
+ }
56
+ );
57
+
58
+ return client;
59
+ }
60
+
61
+ module.exports = { createHttpClient };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Privacy utilities — truncation, path stripping, secret redaction.
3
+ * All sanitization happens before any event is written to disk or sent to the API.
4
+ */
5
+
6
+ const path = require('path');
7
+
8
+ const SECRET_PATTERNS = [
9
+ // Generic API keys / tokens
10
+ /(?:api[_-]?key|apikey|token|secret|password|passwd|authorization|bearer)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi,
11
+ // AWS
12
+ /AKIA[0-9A-Z]{16}/g,
13
+ // GitHub
14
+ /gh[pso]_[A-Za-z0-9_]{36,}/g,
15
+ // Slack
16
+ /xox[bporas]-[A-Za-z0-9-]+/g,
17
+ // Generic long hex/base64 strings that look like secrets (32+ chars)
18
+ /(?:sk|pk|key|token|secret)[_-][A-Za-z0-9]{32,}/gi,
19
+ // HMN API keys
20
+ /hmn_ak_[A-Za-z0-9_\-]{16,}/g,
21
+ // Bearer tokens in text
22
+ /Bearer\s+[A-Za-z0-9_\-./+=]{20,}/gi,
23
+ // Connection strings
24
+ /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+/gi
25
+ ];
26
+
27
+ /**
28
+ * Truncate prompt text to 200 characters.
29
+ */
30
+ function sanitizePrompt(text) {
31
+ if (!text || typeof text !== 'string') return '';
32
+ return text.length > 200 ? text.slice(0, 200) + '...' : text;
33
+ }
34
+
35
+ /**
36
+ * Extract only the last folder name from a full path.
37
+ * /Users/matty/Documents/my-project/src/index.js → my-project
38
+ */
39
+ function sanitizePath(fullPath) {
40
+ if (!fullPath || typeof fullPath !== 'string') return '';
41
+ // Normalize and split
42
+ const normalized = path.normalize(fullPath);
43
+ const parts = normalized.split(path.sep).filter(Boolean);
44
+ // If it looks like a file path, return the parent folder name
45
+ if (parts.length === 0) return '';
46
+ // Check if last segment has an extension (it's a file)
47
+ const last = parts[parts.length - 1];
48
+ if (last.includes('.') && parts.length > 1) {
49
+ return parts[parts.length - 2] + '/';
50
+ }
51
+ return last + '/';
52
+ }
53
+
54
+ /**
55
+ * Strip patterns matching API keys, tokens, passwords from text.
56
+ */
57
+ function redactSecrets(text) {
58
+ if (!text || typeof text !== 'string') return '';
59
+ let cleaned = text;
60
+ for (const pattern of SECRET_PATTERNS) {
61
+ // Reset lastIndex for global patterns
62
+ pattern.lastIndex = 0;
63
+ cleaned = cleaned.replace(pattern, '[REDACTED]');
64
+ }
65
+ return cleaned;
66
+ }
67
+
68
+ /**
69
+ * Apply all sanitization to an ActivityEvent before logging.
70
+ */
71
+ function sanitizeEvent(event) {
72
+ if (!event) return event;
73
+
74
+ const sanitized = { ...event };
75
+
76
+ if (sanitized.data) {
77
+ sanitized.data = { ...sanitized.data };
78
+
79
+ // Truncate prompt summaries
80
+ if (sanitized.data.prompt_summary) {
81
+ sanitized.data.prompt_summary = redactSecrets(
82
+ sanitizePrompt(sanitized.data.prompt_summary)
83
+ );
84
+ }
85
+
86
+ // Strip file paths to folder names
87
+ if (sanitized.data.project_dir) {
88
+ sanitized.data.project_dir = sanitizePath(sanitized.data.project_dir);
89
+ }
90
+
91
+ // Sanitize file names — keep names but redact any secret-looking names
92
+ if (Array.isArray(sanitized.data.file_names)) {
93
+ sanitized.data.file_names = sanitized.data.file_names.map((f) =>
94
+ redactSecrets(path.basename(f))
95
+ );
96
+ }
97
+
98
+ // Redact secrets from description
99
+ if (sanitized.data.description) {
100
+ sanitized.data.description = redactSecrets(sanitized.data.description);
101
+ }
102
+
103
+ // Redact secrets from tool names (unlikely but safe)
104
+ if (Array.isArray(sanitized.data.tools_used)) {
105
+ sanitized.data.tools_used = sanitized.data.tools_used.map((t) =>
106
+ redactSecrets(t)
107
+ );
108
+ }
109
+ }
110
+
111
+ return sanitized;
112
+ }
113
+
114
+ module.exports = {
115
+ sanitizePrompt,
116
+ sanitizePath,
117
+ redactSecrets,
118
+ sanitizeEvent
119
+ };