@dollhousemcp/mcp-server 1.6.2 → 1.6.4

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,363 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * OAuth Helper Process - Standalone OAuth polling script
5
+ *
6
+ * This script runs independently of the MCP server to handle OAuth device flow polling.
7
+ * It's spawned as a detached process when authentication is initiated, polls GitHub
8
+ * for the OAuth token, stores it securely, and then exits.
9
+ *
10
+ * Usage: node oauth-helper.mjs <device_code> <interval> <expires_in> <client_id>
11
+ *
12
+ * This solves the MCP server lifecycle issue where the server may shut down
13
+ * between tool calls, breaking background OAuth polling.
14
+ */
15
+
16
+ import { fileURLToPath } from 'url';
17
+ import { dirname, join } from 'path';
18
+ import fs from 'fs/promises';
19
+ import fsSync from 'fs';
20
+ import { homedir } from 'os';
21
+
22
+ // Get the directory of this script
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+
26
+ // Constants
27
+ const DEFAULT_POLL_INTERVAL = 5;
28
+ const DEFAULT_EXPIRES_IN = 900; // 15 minutes
29
+ const MAX_TOKEN_SIZE = 10000; // Maximum reasonable token size
30
+
31
+ // Parse command line arguments
32
+ const args = process.argv.slice(2);
33
+ if (args.length < 4) {
34
+ console.error('Usage: oauth-helper.mjs <device_code> <interval> <expires_in> <client_id>');
35
+ process.exit(1);
36
+ }
37
+
38
+ const [deviceCode, intervalStr, expiresInStr, clientId] = args;
39
+ const pollInterval = parseInt(intervalStr, 10) || DEFAULT_POLL_INTERVAL;
40
+ const expiresIn = parseInt(expiresInStr, 10) || DEFAULT_EXPIRES_IN;
41
+
42
+ // Validate client ID is provided (no hardcoded fallback)
43
+ if (!clientId || clientId === 'undefined') {
44
+ console.error('OAUTH_HELPER_43: Missing or undefined client ID');
45
+ console.error('⚠️ GitHub OAuth Configuration Missing\n');
46
+ console.error('The server administrator needs to configure GitHub OAuth.');
47
+ console.error('Please contact your administrator to set up the DOLLHOUSE_GITHUB_CLIENT_ID.');
48
+ console.error('\nFor administrators: Set the environment variable before starting the server.');
49
+ await log('OAUTH_HELPER_43: Process exiting - missing client ID');
50
+ process.exit(1);
51
+ }
52
+
53
+ // Log file for debugging (optional, can be disabled in production)
54
+ const LOG_FILE = join(homedir(), '.dollhouse', 'oauth-helper.log');
55
+ const LOG_ENABLED = process.env.DOLLHOUSE_OAUTH_DEBUG === 'true';
56
+
57
+ async function log(message) {
58
+ if (!LOG_ENABLED) return;
59
+
60
+ try {
61
+ const timestamp = new Date().toISOString();
62
+ const logMessage = `[${timestamp}] ${message}\n`;
63
+
64
+ // Ensure directory exists with secure permissions
65
+ const logDir = dirname(LOG_FILE);
66
+ await fs.mkdir(logDir, { recursive: true, mode: 0o700 }).catch(() => {});
67
+
68
+ // Check if log file exists
69
+ let fileExists = false;
70
+ try {
71
+ await fs.access(LOG_FILE);
72
+ fileExists = true;
73
+ } catch {
74
+ fileExists = false;
75
+ }
76
+
77
+ // Append to log file
78
+ await fs.appendFile(LOG_FILE, logMessage);
79
+
80
+ // Set secure permissions on first write
81
+ if (!fileExists) {
82
+ await fs.chmod(LOG_FILE, 0o600);
83
+ }
84
+ } catch (error) {
85
+ // Silently fail if logging doesn't work
86
+ }
87
+ }
88
+
89
+ async function sleep(ms) {
90
+ return new Promise(resolve => setTimeout(resolve, ms));
91
+ }
92
+
93
+ async function pollGitHub(deviceCode, clientId) {
94
+ const TOKEN_URL = 'https://github.com/login/oauth/access_token';
95
+
96
+ try {
97
+ const response = await fetch(TOKEN_URL, {
98
+ method: 'POST',
99
+ headers: {
100
+ 'Accept': 'application/json',
101
+ 'Content-Type': 'application/json'
102
+ },
103
+ body: JSON.stringify({
104
+ client_id: clientId,
105
+ device_code: deviceCode,
106
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
107
+ })
108
+ });
109
+
110
+ const data = await response.json();
111
+ return data;
112
+ } catch (error) {
113
+ await log(`Network error polling GitHub: ${error.message}`);
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ async function storeToken(token) {
119
+ // Validate token size to prevent DoS
120
+ if (!token || token.length > MAX_TOKEN_SIZE) {
121
+ await log('Invalid token size');
122
+ throw new Error('Invalid token received');
123
+ }
124
+
125
+ try {
126
+ // Import the compiled TokenManager
127
+ const { TokenManager } = await import('./dist/security/tokenManager.js');
128
+
129
+ // Store the token using the secure storage mechanism
130
+ await TokenManager.storeGitHubToken(token);
131
+ await log('Token stored successfully using TokenManager');
132
+ return true;
133
+ } catch (error) {
134
+ await log(`Failed to store token using TokenManager: ${error.message}`);
135
+
136
+ // Fallback: Write to a temporary file for the MCP server to pick up
137
+ try {
138
+ const tempTokenFile = join(homedir(), '.dollhouse', '.auth', 'pending_token.txt');
139
+ const tempDir = dirname(tempTokenFile);
140
+
141
+ // Create directory with secure permissions
142
+ await fs.mkdir(tempDir, { recursive: true, mode: 0o700 });
143
+
144
+ // Verify directory permissions
145
+ const dirStats = await fs.stat(tempDir);
146
+ const dirMode = dirStats.mode & parseInt('777', 8);
147
+ if (dirMode !== parseInt('700', 8)) {
148
+ await fs.chmod(tempDir, 0o700);
149
+ }
150
+
151
+ // Write token with secure permissions
152
+ await fs.writeFile(tempTokenFile, token, { mode: 0o600 });
153
+
154
+ // Verify file permissions
155
+ await fs.chmod(tempTokenFile, 0o600);
156
+
157
+ await log(`Token written to fallback file with secure permissions`);
158
+ return true;
159
+ } catch (fallbackError) {
160
+ await log(`Fallback storage also failed: ${fallbackError.message}`);
161
+ throw fallbackError;
162
+ }
163
+ }
164
+ }
165
+
166
+ function cleanupPidFileSync() {
167
+ try {
168
+ const pidFile = join(homedir(), '.dollhouse', '.auth', 'oauth-helper.pid');
169
+ if (fsSync.existsSync(pidFile)) {
170
+ fsSync.unlinkSync(pidFile);
171
+ }
172
+ } catch (error) {
173
+ // Ignore cleanup errors
174
+ }
175
+ }
176
+
177
+ async function cleanupPidFile() {
178
+ try {
179
+ const pidFile = join(homedir(), '.dollhouse', '.auth', 'oauth-helper.pid');
180
+ await fs.unlink(pidFile).catch(() => {});
181
+ await log('PID file cleaned up');
182
+ } catch (error) {
183
+ // Ignore cleanup errors
184
+ }
185
+ }
186
+
187
+ async function writePidFile() {
188
+ try {
189
+ const pidFile = join(homedir(), '.dollhouse', '.auth', 'oauth-helper.pid');
190
+ const pidDir = dirname(pidFile);
191
+
192
+ await fs.mkdir(pidDir, { recursive: true, mode: 0o700 });
193
+ await fs.writeFile(pidFile, process.pid.toString(), { mode: 0o600 });
194
+ await log(`PID file written: ${pidFile}`);
195
+ } catch (error) {
196
+ await log(`Failed to write PID file: ${error.message}`);
197
+ }
198
+ }
199
+
200
+ async function main() {
201
+ await log(`[START] OAuth helper started - PID: ${process.pid}`);
202
+ await log(`[CONFIG] Device code: ${deviceCode.substring(0, 2)}****`); // More aggressive truncation
203
+ await log(`[CONFIG] Poll interval: ${pollInterval}s, Expires in: ${expiresIn}s`);
204
+ await log(`[CONFIG] Node version: ${process.version}`);
205
+ await log(`[CONFIG] Platform: ${process.platform}`);
206
+ // Never log client ID
207
+
208
+ // Write PID file for tracking
209
+ await writePidFile();
210
+
211
+ // Write initial heartbeat
212
+ let lastHeartbeat = Date.now();
213
+ const heartbeatInterval = setInterval(async () => {
214
+ await log(`[HEARTBEAT] Process alive - Memory: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`);
215
+ lastHeartbeat = Date.now();
216
+ }, 30000); // Every 30 seconds
217
+
218
+ const startTime = Date.now();
219
+ const timeout = startTime + (expiresIn * 1000);
220
+ let attempts = 0;
221
+ let consecutiveErrors = 0;
222
+ const MAX_CONSECUTIVE_ERRORS = 5;
223
+
224
+ // Set up cleanup on exit - use synchronous cleanup for exit event
225
+ process.on('exit', () => {
226
+ cleanupPidFileSync();
227
+ });
228
+
229
+ // Use beforeExit for async cleanup when possible
230
+ process.on('beforeExit', async () => {
231
+ await log('OAuth helper completing cleanup');
232
+ await cleanupPidFile();
233
+ });
234
+
235
+ process.on('SIGINT', () => {
236
+ cleanupPidFileSync();
237
+ process.exit(0);
238
+ });
239
+
240
+ process.on('SIGTERM', () => {
241
+ cleanupPidFileSync();
242
+ process.exit(0);
243
+ });
244
+
245
+ while (Date.now() < timeout) {
246
+ attempts++;
247
+ const timeElapsed = Math.round((Date.now() - startTime) / 1000);
248
+ await log(`[POLL] Attempt ${attempts} at ${timeElapsed}s elapsed...`);
249
+
250
+ try {
251
+ const response = await pollGitHub(deviceCode, clientId);
252
+
253
+ if (response.error) {
254
+ switch (response.error) {
255
+ case 'authorization_pending':
256
+ // User hasn't authorized yet, keep polling
257
+ await log('[STATUS] Authorization pending, user has not authorized yet...');
258
+ break;
259
+
260
+ case 'slow_down':
261
+ // GitHub is asking us to slow down
262
+ await log(`[RATE_LIMIT] GitHub requested slower polling - increasing interval to ${pollInterval * 1.5}s`);
263
+ await sleep(pollInterval * 1500);
264
+ continue;
265
+
266
+ case 'expired_token':
267
+ await log('OAUTH_HELPER_264: Device code expired - authentication window closed');
268
+ console.error('OAUTH_EXPIRED: Device code expired at line 264 - authentication window closed');
269
+ clearInterval(heartbeatInterval);
270
+ await cleanupPidFile();
271
+ process.exit(1);
272
+
273
+ case 'access_denied':
274
+ await log('OAUTH_HELPER_270: User denied authorization request');
275
+ console.error('OAUTH_ACCESS_DENIED: User denied authorization at line 270');
276
+ clearInterval(heartbeatInterval);
277
+ await cleanupPidFile();
278
+ process.exit(1);
279
+
280
+ default:
281
+ await log(`OAUTH_HELPER_276: Unknown error from GitHub: ${response.error}`);
282
+ await log(`[ERROR] Error description: ${response.error_description}`);
283
+ console.error(`OAUTH_UNKNOWN_RESPONSE: Unknown error '${response.error}' at line 276`);
284
+ }
285
+ } else if (response.access_token) {
286
+ // Success! We got the token
287
+ await log('[SUCCESS] ✅ Token received from GitHub!');
288
+ consecutiveErrors = 0; // Reset error counter
289
+
290
+ // Store the token
291
+ const stored = await storeToken(response.access_token);
292
+
293
+ if (stored) {
294
+ await log('[SUCCESS] ✅ OAuth authentication completed successfully');
295
+ await log(`[STATS] Total attempts: ${attempts}, Time elapsed: ${Math.round((Date.now() - startTime) / 1000)}s`);
296
+ console.log('✅ GitHub authentication successful! Token has been stored.');
297
+ clearInterval(heartbeatInterval);
298
+ await cleanupPidFile();
299
+ process.exit(0);
300
+ } else {
301
+ await log('[ERROR] ❌ Failed to store token');
302
+ console.error('❌ Failed to store authentication token');
303
+ clearInterval(heartbeatInterval);
304
+ await cleanupPidFile();
305
+ process.exit(1);
306
+ }
307
+ } else {
308
+ // Reset error counter on successful communication
309
+ consecutiveErrors = 0;
310
+ }
311
+ } catch (error) {
312
+ await log(`[ERROR] Polling error: ${error.message}`);
313
+
314
+ // Classify error types
315
+ const isNetworkError = error.message && (
316
+ error.message.includes('ECONNREFUSED') ||
317
+ error.message.includes('ETIMEDOUT') ||
318
+ error.message.includes('ENOTFOUND') ||
319
+ error.message.includes('EAI_AGAIN') ||
320
+ error.message.includes('fetch failed')
321
+ );
322
+
323
+ if (isNetworkError) {
324
+ consecutiveErrors++;
325
+ await log(`OAUTH_HELPER_319: Network error ${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}`);
326
+
327
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
328
+ await log('OAUTH_HELPER_323: Too many consecutive network errors, exiting');
329
+ console.error(`OAUTH_NETWORK_FAILURE: Too many network errors (${MAX_CONSECUTIVE_ERRORS}) at line 323 - check internet connection`);
330
+ clearInterval(heartbeatInterval);
331
+ await cleanupPidFile();
332
+ process.exit(1);
333
+ }
334
+ } else {
335
+ // Non-network error, likely fatal
336
+ await log(`OAUTH_HELPER_330: Non-recoverable error: ${error.message}`);
337
+ console.error(`OAUTH_FATAL_ERROR: Non-recoverable error at line 330 - ${error.message}`);
338
+ clearInterval(heartbeatInterval);
339
+ await cleanupPidFile();
340
+ process.exit(1);
341
+ }
342
+ }
343
+
344
+ // Wait before next poll
345
+ await sleep(pollInterval * 1000);
346
+ }
347
+
348
+ // Timeout reached
349
+ await log('OAUTH_HELPER_342: OAuth authorization timed out');
350
+ await log(`[STATS] Total attempts: ${attempts}, Time elapsed: ${Math.round((Date.now() - startTime) / 1000)}s`);
351
+ console.error(`OAUTH_TIMEOUT: Authorization timed out at line 342 after ${Math.round((Date.now() - startTime) / 1000)}s - user did not authorize in time`);
352
+ clearInterval(heartbeatInterval);
353
+ await cleanupPidFile();
354
+ process.exit(1);
355
+ }
356
+
357
+ // Run the main function
358
+ main().catch(async (error) => {
359
+ await log(`Fatal error: ${error.message}`);
360
+ console.error('Fatal error in OAuth helper:', error);
361
+ await cleanupPidFile();
362
+ process.exit(1);
363
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dollhousemcp/mcp-server",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
4
4
  "description": "DollhouseMCP - A Model Context Protocol (MCP) server that enables dynamic AI persona management from markdown files, allowing Claude and other compatible AI assistants to activate and switch between different behavioral personas.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -94,6 +94,7 @@
94
94
  "data/ensembles/**/*.md",
95
95
  "!dist/__tests__/**",
96
96
  "!dist/**/*.test.*",
97
+ "oauth-helper.mjs",
97
98
  "README.md",
98
99
  "LICENSE",
99
100
  "CHANGELOG.md"