@elliotding/ai-agent-mcp 0.1.8 → 0.1.10

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.
@@ -21,7 +21,11 @@ import * as path from 'path';
21
21
  import { logger, logToolCall, logToolStep, logToolResult } from '../utils/logger';
22
22
  import { apiClient } from '../api/client';
23
23
  import { multiSourceGitManager } from '../git/multi-source-manager';
24
- import { getCursorResourcePath, getCursorTypeDir, getCursorRootDir } from '../utils/cursor-paths';
24
+ import {
25
+ getCursorResourcePath,
26
+ getCursorTypeDirForClient,
27
+ getCursorRootDirForClient,
28
+ } from '../utils/cursor-paths';
25
29
  import { MCPServerError } from '../types/errors';
26
30
  import type {
27
31
  SyncResourcesParams,
@@ -283,10 +287,24 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
283
287
  // reading the files directly from the local git checkout.
284
288
  let resourceFiles = downloadResult.files;
285
289
  if (resourceFiles.length === 0) {
286
- const gitType = sub.type as 'rule' | 'mcp';
287
- const gitFiles = await multiSourceGitManager.readResourceFiles(sub.name, gitType as any);
290
+ logger.info(
291
+ { resourceId: sub.id, resourceName: sub.name, type: sub.type },
292
+ 'sync_resources: API returned no files — triggering git-checkout fallback',
293
+ );
294
+ const gitType = sub.type as 'command' | 'skill' | 'rule' | 'mcp';
295
+ const gitFiles = await multiSourceGitManager.readResourceFiles(sub.name, gitType);
288
296
  if (gitFiles.length > 0) {
289
297
  resourceFiles = gitFiles;
298
+ logger.info(
299
+ {
300
+ resourceId: sub.id,
301
+ resourceName: sub.name,
302
+ type: sub.type,
303
+ fileCount: resourceFiles.length,
304
+ files: resourceFiles.map((f) => f.path),
305
+ },
306
+ 'sync_resources: git-checkout fallback succeeded',
307
+ );
290
308
  logToolStep('sync_resources', 'Loaded resource files from local git checkout', {
291
309
  resourceId: sub.id,
292
310
  fileCount: resourceFiles.length,
@@ -294,7 +312,7 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
294
312
  } else {
295
313
  logger.warn(
296
314
  { resourceId: sub.id, resourceName: sub.name, type: sub.type },
297
- 'No files found via API or local git skipping resource',
315
+ 'sync_resources: git-checkout fallback found no filesmarking resource failed',
298
316
  );
299
317
  tally.failed++;
300
318
  details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
@@ -305,48 +323,84 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
305
323
  // ── MCP resource ──────────────────────────────────────────────────────
306
324
  // Read mcp-config.json to determine Format A (local executable, has
307
325
  // "command" field) vs Format B (remote URL map, no "command" field).
326
+ //
327
+ // IMPORTANT: all paths in LocalAction instructions must use the CLIENT-side
328
+ // helper (tilde-based) so they resolve correctly on the user's machine,
329
+ // not on this (possibly remote Linux) server.
308
330
  if (sub.type === 'mcp') {
309
331
  const mcpConfigFile = resourceFiles.find(
310
332
  (f) => path.basename(f.path) === 'mcp-config.json',
311
333
  );
312
- const mcpJsonPath = path.join(getCursorRootDir(), 'mcp.json');
334
+ // ~/.cursor/mcp.json on the user's machine
335
+ const mcpJsonPath = `${getCursorRootDirForClient()}/mcp.json`;
336
+
337
+ logger.info(
338
+ {
339
+ resourceId: sub.id,
340
+ resourceName: sub.name,
341
+ mcpJsonPath,
342
+ hasMcpConfigFile: !!mcpConfigFile,
343
+ availableFiles: resourceFiles.map((f) => f.path),
344
+ },
345
+ 'sync_resources: processing MCP resource',
346
+ );
313
347
 
314
348
  if (mcpConfigFile) {
315
349
  let cfg: Record<string, unknown> = {};
316
350
  try { cfg = JSON.parse(mcpConfigFile.content) as Record<string, unknown>; }
317
- catch { /* malformed — cfg stays empty */ }
351
+ catch {
352
+ logger.warn(
353
+ { resourceId: sub.id, resourceName: sub.name },
354
+ 'sync_resources: failed to parse mcp-config.json — treating as empty config',
355
+ );
356
+ }
318
357
 
319
358
  if (typeof cfg['command'] === 'string') {
320
359
  // ── Format A: local executable ──────────────────────────────────
321
- // Queue write_file for each resource file + merge_mcp_json for entry.
322
- const installDir = path.join(getCursorTypeDir('mcp'), sub.name);
360
+ const installDir = `${getCursorTypeDirForClient('mcp')}/${sub.name}`;
361
+ const writeActions: string[] = [];
323
362
  for (const file of resourceFiles) {
324
363
  const normalised = path.normalize(file.path);
325
364
  if (normalised.startsWith('..')) continue;
326
- localActions.push({ action: 'write_file', path: path.join(installDir, normalised), content: file.content });
365
+ const fileDest = `${installDir}/${normalised}`;
366
+ localActions.push({ action: 'write_file', path: fileDest, content: file.content });
367
+ writeActions.push(fileDest);
327
368
  }
328
369
  const env = (cfg['env'] ?? {}) as Record<string, string>;
329
370
  const missingEnv = Object.entries(env).filter(([, v]) => v === '').map(([k]) => k);
330
- // Resolve relative args to the install directory.
331
371
  const looksLikePath = (a: string) =>
332
- a.startsWith('./') || a.startsWith('../') || a.includes(path.sep) || /\.\w+$/.test(a);
372
+ a.startsWith('./') || a.startsWith('../') || a.includes('/') || /\.\w+$/.test(a);
333
373
  const args = ((cfg['args'] ?? []) as string[]).map((a) =>
334
- path.isAbsolute(a) || !looksLikePath(a) ? a : path.join(installDir, a),
374
+ path.isAbsolute(a) || !looksLikePath(a) ? a : `${installDir}/${a.replace(/^\.\//, '')}`,
335
375
  );
376
+ const serverName = (cfg['name'] as string | undefined) ?? sub.name;
336
377
  localActions.push({
337
378
  action: 'merge_mcp_json',
338
379
  mcp_json_path: mcpJsonPath,
339
- server_name: (cfg['name'] as string | undefined) ?? sub.name,
380
+ server_name: serverName,
340
381
  entry: { ...cfg, args },
341
382
  ...(missingEnv.length > 0 ? {
342
383
  missing_env: missingEnv,
343
384
  setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${sub.name}"]: ${missingEnv.join(', ')}.`,
344
385
  } : {}),
345
386
  });
387
+ logger.info(
388
+ {
389
+ resourceId: sub.id,
390
+ resourceName: sub.name,
391
+ format: 'A',
392
+ installDir,
393
+ mcpJsonPath,
394
+ serverName,
395
+ writeFiles: writeActions,
396
+ missingEnv,
397
+ },
398
+ 'sync_resources: MCP Format A — write_file + merge_mcp_json actions queued',
399
+ );
346
400
  logToolStep('sync_resources', 'Local-executable MCP: write_file + merge_mcp_json queued', { resourceId: sub.id });
347
401
  } else {
348
402
  // ── Format B: remote URL map ────────────────────────────────────
349
- // cfg IS the mcpServers map; one merge_mcp_json action per key.
403
+ const queuedServers: string[] = [];
350
404
  for (const [serverName, entry] of Object.entries(cfg)) {
351
405
  const e = entry as Record<string, unknown>;
352
406
  const env = (e['env'] ?? {}) as Record<string, string>;
@@ -361,29 +415,57 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
361
415
  setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${serverName}"]: ${missingEnv.join(', ')}.`,
362
416
  } : {}),
363
417
  });
418
+ queuedServers.push(serverName);
364
419
  }
420
+ logger.info(
421
+ {
422
+ resourceId: sub.id,
423
+ resourceName: sub.name,
424
+ format: 'B',
425
+ mcpJsonPath,
426
+ serverKeys: queuedServers,
427
+ },
428
+ 'sync_resources: MCP Format B — merge_mcp_json actions queued',
429
+ );
365
430
  logToolStep('sync_resources', 'Remote-URL MCP: merge_mcp_json queued', {
366
431
  resourceId: sub.id, serverKeys: Object.keys(cfg),
367
432
  });
368
433
  }
369
434
  } else {
370
- // No mcp-config.json: heuristic fallback — find entry point file.
371
- const installDir = path.join(getCursorTypeDir('mcp'), sub.name);
435
+ // No mcp-config.json: heuristic fallback
436
+ const installDir = `${getCursorTypeDirForClient('mcp')}/${sub.name}`;
437
+ const writeActions: string[] = [];
372
438
  for (const file of resourceFiles) {
373
439
  const normalised = path.normalize(file.path);
374
440
  if (normalised.startsWith('..')) continue;
375
- localActions.push({ action: 'write_file', path: path.join(installDir, normalised), content: file.content });
441
+ const fileDest = `${installDir}/${normalised}`;
442
+ localActions.push({ action: 'write_file', path: fileDest, content: file.content });
443
+ writeActions.push(fileDest);
376
444
  }
377
445
  const jsEntry = resourceFiles.find((f) => f.path.endsWith('.js'));
378
446
  const pyEntry = resourceFiles.find((f) => f.path.endsWith('.py'));
379
447
  const entryFile = jsEntry ?? pyEntry ?? resourceFiles[0];
380
448
  const cmd = jsEntry ? 'node' : 'python3';
449
+ const entryPath = `${installDir}/${entryFile?.path ?? ''}`;
381
450
  localActions.push({
382
451
  action: 'merge_mcp_json',
383
- mcp_json_path: path.join(getCursorRootDir(), 'mcp.json'),
452
+ mcp_json_path: mcpJsonPath,
384
453
  server_name: sub.name,
385
- entry: { command: cmd, args: [path.join(installDir, entryFile?.path ?? '')] },
454
+ entry: { command: cmd, args: [entryPath] },
386
455
  });
456
+ logger.info(
457
+ {
458
+ resourceId: sub.id,
459
+ resourceName: sub.name,
460
+ format: 'heuristic',
461
+ installDir,
462
+ mcpJsonPath,
463
+ cmd,
464
+ entryPath,
465
+ writeFiles: writeActions,
466
+ },
467
+ 'sync_resources: MCP heuristic fallback — write_file + merge_mcp_json actions queued',
468
+ );
387
469
  logToolStep('sync_resources', 'MCP heuristic fallback: write_file + merge_mcp_json queued', { resourceId: sub.id });
388
470
  }
389
471
 
@@ -394,8 +476,10 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
394
476
 
395
477
  // ── Rule resource ─────────────────────────────────────────────────────
396
478
  // Return write_file actions; the AI writes the files locally.
479
+ // Use client-side path so the path resolves on the user's machine.
397
480
  if (sub.type === 'rule') {
398
- const typeDir = getCursorTypeDir(sub.type);
481
+ const typeDir = getCursorTypeDirForClient(sub.type);
482
+ const writeActions: string[] = [];
399
483
 
400
484
  for (const file of resourceFiles) {
401
485
  const normalised = path.normalize(file.path);
@@ -403,13 +487,26 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
403
487
  logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
404
488
  continue;
405
489
  }
490
+ const destPath = `${typeDir}/${normalised}`;
406
491
  localActions.push({
407
492
  action: 'write_file',
408
- path: path.join(typeDir, normalised),
493
+ path: destPath,
409
494
  content: file.content,
410
495
  });
496
+ writeActions.push(destPath);
411
497
  }
412
498
 
499
+ logger.info(
500
+ {
501
+ resourceId: sub.id,
502
+ resourceName: sub.name,
503
+ typeDir,
504
+ fileCount: writeActions.length,
505
+ destPaths: writeActions,
506
+ },
507
+ 'sync_resources: Rule — write_file actions queued for AI',
508
+ );
509
+
413
510
  tally.synced++;
414
511
  details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
415
512
  logToolStep('sync_resources', 'Rule: write_file actions queued for AI', {
@@ -6,12 +6,9 @@
6
6
  * For directory-based resources (skill, mcp) the entire install directory is removed.
7
7
  */
8
8
 
9
- import * as fs from 'fs/promises';
10
- import * as path from 'path';
11
9
  import { logger, logToolCall } from '../utils/logger';
12
- import { filesystemManager } from '../filesystem/manager';
13
10
  import { apiClient } from '../api/client';
14
- import { getCursorTypeDir, getCursorRootDir } from '../utils/cursor-paths.js';
11
+ import { getCursorTypeDirForClient, getCursorRootDirForClient } from '../utils/cursor-paths.js';
15
12
  import { MCPServerError, createValidationError } from '../types/errors';
16
13
  import type { UninstallResourceParams, UninstallResourceResult, LocalAction, ToolResult } from '../types/tools';
17
14
  import { promptManager } from '../prompts/index.js';
@@ -93,48 +90,30 @@ export async function uninstallResource(params: unknown): Promise<ToolResult<Uni
93
90
  logger.debug({ pattern }, 'Building local uninstall actions for Rule/MCP resource...');
94
91
 
95
92
  const localActions: LocalAction[] = [];
96
- const mcpJsonPath = path.join(getCursorRootDir(), 'mcp.json');
97
-
98
- // Rule: delete matching .md/.mdc files from ~/.cursor/rules/
99
- try {
100
- const rulesDir = getCursorTypeDir('rule');
101
- const ruleFiles = await filesystemManager.listFiles(rulesDir, /\.(md|mdc)$/);
102
- for (const relName of ruleFiles) {
103
- const baseName = path.basename(relName).replace(/\.(md|mdc)$/, '');
104
- if (baseName === pattern || baseName.includes(pattern) || relName.includes(pattern)) {
105
- const absPath = path.join(rulesDir, relName);
106
- localActions.push({ action: 'delete_file', path: absPath });
107
- removedResources.push({ id: baseName, name: baseName, path: absPath });
108
- }
109
- }
110
- } catch { /* rules dir may not exist */ }
111
-
112
- // MCP: delete install directory + remove mcp.json entry
113
- try {
114
- const mcpDir = getCursorTypeDir('mcp');
115
- const entries = await fs.readdir(mcpDir, { withFileTypes: true });
116
- for (const entry of entries) {
117
- if (!entry.isDirectory()) continue;
118
- if (entry.name === pattern || entry.name.includes(pattern)) {
119
- const dirPath = path.join(mcpDir, entry.name);
120
- localActions.push({ action: 'delete_file', path: dirPath, recursive: true });
121
- localActions.push({ action: 'remove_mcp_json_entry', mcp_json_path: mcpJsonPath, server_name: entry.name });
122
- removedResources.push({ id: entry.name, name: entry.name, path: dirPath });
123
- }
124
- }
125
- } catch { /* mcp-servers dir may not exist */ }
126
-
127
- // Also check Remote-URL MCPs whose entry is only in mcp.json (no local dir).
128
- // The pattern might match a server name in mcp.json directly.
129
- if (localActions.filter(a => a.action === 'remove_mcp_json_entry').length === 0) {
130
- // Add a conditional remove action — the AI will check if the key exists.
131
- localActions.push({
132
- action: 'remove_mcp_json_entry',
133
- mcp_json_path: mcpJsonPath,
134
- server_name: pattern,
135
- });
93
+ // Use client-side tilde-based paths; the MCP server may be running remotely
94
+ // and its os.homedir() would resolve to the server's home, not the user's.
95
+ const mcpJsonPath = `${getCursorRootDirForClient()}/mcp.json`;
96
+
97
+ // Rule: queue delete for ~/.cursor/rules/<pattern>.mdc and .md variants.
98
+ // We cannot scan the server's filesystem for the user's rules, so we emit
99
+ // delete actions for the two common extensions and let the AI skip missing files.
100
+ const rulesDir = getCursorTypeDirForClient('rule');
101
+ for (const ext of ['.mdc', '.md']) {
102
+ const filePath = `${rulesDir}/${pattern}${ext}`;
103
+ localActions.push({ action: 'delete_file', path: filePath });
104
+ removedResources.push({ id: pattern, name: pattern, path: filePath });
136
105
  }
137
106
 
107
+ // MCP: queue delete of install directory (Format A) + remove mcp.json entry.
108
+ // For Format B (remote URL only) there is no local directory, but we still
109
+ // need to remove the mcp.json entry — the AI will skip the delete if the
110
+ // directory does not exist.
111
+ const mcpDir = getCursorTypeDirForClient('mcp');
112
+ const mcpInstallDir = `${mcpDir}/${pattern}`;
113
+ localActions.push({ action: 'delete_file', path: mcpInstallDir, recursive: true });
114
+ localActions.push({ action: 'remove_mcp_json_entry', mcp_json_path: mcpJsonPath, server_name: pattern });
115
+ removedResources.push({ id: pattern, name: pattern, path: mcpInstallDir });
116
+
138
117
  if (removedResources.length === 0 && localActions.length === 0) {
139
118
  throw createValidationError(
140
119
  pattern,
@@ -29,6 +29,11 @@ export const CURSOR_TYPE_DIRS: Record<string, string> = {
29
29
  *
30
30
  * macOS / Linux : ~/.cursor
31
31
  * Windows : %APPDATA%\Cursor\User
32
+ *
33
+ * NOTE: Only use this when running code on the USER's local machine.
34
+ * When generating paths for LocalAction instructions (which are executed by the
35
+ * AI on the user's machine, not on this server), use getCursorRootDirForClient()
36
+ * instead to avoid returning the server's home directory.
32
37
  */
33
38
  export function getCursorRootDir(): string {
34
39
  if (process.platform === 'win32') {
@@ -40,6 +45,40 @@ export function getCursorRootDir(): string {
40
45
  return path.join(os.homedir(), '.cursor');
41
46
  }
42
47
 
48
+ /**
49
+ * Returns a platform-neutral Cursor root path for use in LocalAction instructions.
50
+ *
51
+ * LocalAction paths are sent to the AI Agent running on the USER's local machine,
52
+ * not executed on this (possibly remote) server. Using os.homedir() here would
53
+ * produce the server's home directory (e.g. /root/.cursor on a Linux server),
54
+ * which is wrong when the user is on macOS or Windows.
55
+ *
56
+ * We return a tilde-prefixed path ("~/.cursor") which the AI / shell on the
57
+ * user's machine will expand to the correct home directory automatically.
58
+ * For Windows we still return the APPDATA-relative form as a hint, but note
59
+ * that the AI is expected to expand %APPDATA% on the client side.
60
+ */
61
+ export function getCursorRootDirForClient(): string {
62
+ // Return a portable ~-based path; the AI on the user's machine expands it.
63
+ return '~/.cursor';
64
+ }
65
+
66
+ /**
67
+ * Returns the Cursor subdirectory for a given resource type, using a
68
+ * client-side portable path (tilde-based). Use this when building paths
69
+ * that will be included in LocalAction instructions.
70
+ */
71
+ export function getCursorTypeDirForClient(resourceType: string): string {
72
+ const subdir = CURSOR_TYPE_DIRS[resourceType.toLowerCase()];
73
+ if (!subdir) {
74
+ throw new Error(
75
+ `Unknown resource type "${resourceType}". ` +
76
+ `Supported types: ${Object.keys(CURSOR_TYPE_DIRS).join(', ')}`
77
+ );
78
+ }
79
+ return `${getCursorRootDirForClient()}/${subdir}`;
80
+ }
81
+
43
82
  /**
44
83
  * Returns the Cursor subdirectory for a given resource type.
45
84
  *