@elliotding/ai-agent-mcp 0.1.5 → 0.1.7

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.
@@ -23,7 +23,13 @@ import { apiClient } from '../api/client';
23
23
  import { multiSourceGitManager } from '../git/multi-source-manager';
24
24
  import { getCursorResourcePath, getCursorTypeDir, getCursorRootDir } from '../utils/cursor-paths';
25
25
  import { MCPServerError } from '../types/errors';
26
- import type { SyncResourcesParams, SyncResourcesResult, McpSetupItem, ToolResult } from '../types/tools';
26
+ import type {
27
+ SyncResourcesParams,
28
+ SyncResourcesResult,
29
+ LocalAction,
30
+ MergeMcpJsonAction,
31
+ ToolResult,
32
+ } from '../types/tools';
27
33
  import { telemetry } from '../telemetry/index.js';
28
34
  import { promptManager } from '../prompts/index.js';
29
35
 
@@ -44,274 +50,6 @@ function extractFrontmatterDescription(content: string): string | undefined {
44
50
  return undefined;
45
51
  }
46
52
 
47
- /**
48
- * Two supported mcp-config.json formats:
49
- *
50
- * Format A — Local executable (e.g. jenkins):
51
- * Has a top-level "command" field.
52
- * { "name": "jenkins", "command": "python3", "args": ["server.py"], "env": {...} }
53
- * → One entry written to mcpServers using resolved absolute args.
54
- *
55
- * Format B — Remote URL entries (e.g. acm):
56
- * No "command" field; the object IS the mcpServers map (one or more entries).
57
- * { "acm-dev": { "url": "...", "transport": "sse" }, "acm": { "url": "..." } }
58
- * → Each key merged directly into mcpServers as-is (no path resolution needed).
59
- *
60
- * Detection: if parsed JSON has a "command" key at the top level → Format A, else Format B.
61
- */
62
- interface LocalMcpDescriptor {
63
- name?: string;
64
- command: string;
65
- args?: string[];
66
- env?: Record<string, string>;
67
- }
68
- type RemoteMcpEntries = Record<string, unknown>; // mcpServers-compatible map
69
-
70
- /**
71
- * Register a downloaded MCP resource into ~/.cursor/mcp.json.
72
- *
73
- * Supports:
74
- * - Format A (local executable): resolves relative args to absolute paths, writes one entry.
75
- * - Format B (remote URL map): merges all entries directly into mcpServers.
76
- * - No mcp-config.json: heuristic fallback (scans for .py/.js entry point, logs WARN).
77
- *
78
- * The write is idempotent — re-running after a re-download updates existing entries.
79
- *
80
- * Returns a McpSetupItem when the registered server needs manual configuration
81
- * (empty env vars, or a command that might differ across platforms), or null
82
- * when no action is required from the user.
83
- */
84
- async function registerMcpServer(serverName: string, installDir: string): Promise<McpSetupItem | null> {
85
- // ── 1. Load mcp-config.json ────────────────────────────────────────────
86
- const configFilePath = path.join(installDir, 'mcp-config.json');
87
- let rawConfig: unknown = null;
88
-
89
- try {
90
- const raw = await fs.readFile(configFilePath, 'utf-8');
91
- rawConfig = JSON.parse(raw);
92
- logger.debug({ serverName, configFilePath }, 'registerMcpServer: loaded mcp-config.json');
93
- } catch {
94
- logger.warn(
95
- { serverName, configFilePath },
96
- 'registerMcpServer: mcp-config.json not found — falling back to heuristic detection. ' +
97
- 'Add an mcp-config.json to this resource for reliable registration.'
98
- );
99
- }
100
-
101
- // ── 2. Determine what to merge into mcp.json ──────────────────────────
102
- // entriesToMerge: map of serverKey → entry object (may have multiple keys for Format B)
103
- let entriesToMerge: Record<string, unknown> = {};
104
-
105
- if (rawConfig !== null && typeof rawConfig === 'object') {
106
- const cfg = rawConfig as Record<string, unknown>;
107
-
108
- if (typeof cfg['command'] === 'string') {
109
- // ── Format A: local executable ───────────────────────────────────
110
- const descriptor = cfg as unknown as LocalMcpDescriptor;
111
- const key = descriptor.name ?? serverName;
112
- // Only resolve args that look like relative file paths (contain a dot or
113
- // path separator). Plain words like "mcp", "start", "--port" are kept as-is.
114
- const looksLikePath = (a: string) =>
115
- a.startsWith('./') || a.startsWith('../') || a.includes(path.sep) || /\.\w+$/.test(a);
116
- const resolvedArgs = (descriptor.args ?? []).map(a =>
117
- path.isAbsolute(a) || !looksLikePath(a) ? a : path.join(installDir, a)
118
- );
119
- entriesToMerge[key] = {
120
- command: descriptor.command,
121
- args: resolvedArgs,
122
- ...(descriptor.env && Object.keys(descriptor.env).length > 0
123
- ? { env: descriptor.env }
124
- : {}),
125
- };
126
- logger.info(
127
- { serverName, key, command: descriptor.command },
128
- 'registerMcpServer: Format A (local executable)'
129
- );
130
- } else {
131
- // ── Format B: remote URL entries map ─────────────────────────────
132
- // The entire object is a ready-to-merge mcpServers map.
133
- entriesToMerge = cfg as RemoteMcpEntries;
134
- logger.info(
135
- { serverName, keys: Object.keys(entriesToMerge) },
136
- 'registerMcpServer: Format B (remote URL entries)'
137
- );
138
- }
139
- } else {
140
- // ── Heuristic fallback (no mcp-config.json) ───────────────────────
141
- let entryFile: string | null = null;
142
- let command = 'python3';
143
-
144
- try {
145
- const entries = await fs.readdir(installDir);
146
- if (entries.includes(`${serverName}.py`)) {
147
- entryFile = path.join(installDir, `${serverName}.py`); command = 'python3';
148
- } else if (entries.includes(`${serverName}.js`)) {
149
- entryFile = path.join(installDir, `${serverName}.js`); command = 'node';
150
- }
151
- if (!entryFile) {
152
- const py = entries.find(f => f.endsWith('.py') && f !== '__init__.py');
153
- if (py) { entryFile = path.join(installDir, py); command = 'python3'; }
154
- }
155
- if (!entryFile) {
156
- const js = entries.find(f => f.endsWith('.js') || f.endsWith('.mjs'));
157
- if (js) { entryFile = path.join(installDir, js); command = 'node'; }
158
- }
159
- } catch (err) {
160
- logger.warn({ serverName, installDir, err }, 'registerMcpServer: could not read install directory');
161
- return null;
162
- }
163
-
164
- if (!entryFile) {
165
- logger.warn(
166
- { serverName, installDir },
167
- 'registerMcpServer: no entry point found and no mcp-config.json — skipping registration'
168
- );
169
- return null;
170
- }
171
- entriesToMerge[serverName] = { command, args: [entryFile] };
172
- }
173
-
174
- // ── 3. Read / create ~/.cursor/mcp.json ───────────────────────────────
175
- const mcpJsonPath = path.join(getCursorRootDir(), 'mcp.json');
176
- let mcpConfig: { mcpServers: Record<string, unknown> } = { mcpServers: {} };
177
-
178
- try {
179
- const raw = await fs.readFile(mcpJsonPath, 'utf-8');
180
- const parsed = JSON.parse(raw);
181
- if (parsed && typeof parsed === 'object' && 'mcpServers' in parsed) {
182
- mcpConfig = parsed as typeof mcpConfig;
183
- }
184
- } catch {
185
- // File doesn't exist or is corrupt — start fresh
186
- }
187
-
188
- // Smart-merge each entry into mcpServers:
189
- // - Structural fields (command, args, url, transport, …): always take the
190
- // value from mcp-config.json (server is authoritative for structure).
191
- // - env field: preserve user-filled non-empty values; only add keys that
192
- // are new or were previously empty (avoids wiping tokens / URLs the user
193
- // has already configured).
194
- for (const [key, incoming] of Object.entries(entriesToMerge)) {
195
- const existing = mcpConfig.mcpServers[key];
196
-
197
- if (!existing || typeof existing !== 'object') {
198
- // No prior entry — write as-is.
199
- mcpConfig.mcpServers[key] = incoming;
200
- continue;
201
- }
202
-
203
- const incomingEntry = incoming as Record<string, unknown>;
204
- const existingEntry = existing as Record<string, unknown>;
205
-
206
- // Merge env: keep user values that are non-empty strings; fill in the rest
207
- // from the incoming template (which uses empty strings as placeholders).
208
- const mergedEnv: Record<string, string> = {};
209
- const incomingEnv = (incomingEntry['env'] ?? {}) as Record<string, string>;
210
- const existingEnv = (existingEntry['env'] ?? {}) as Record<string, string>;
211
-
212
- for (const envKey of Object.keys(incomingEnv)) {
213
- const userVal = existingEnv[envKey];
214
- // Preserve whatever the user typed; fall back to the template placeholder.
215
- mergedEnv[envKey] = (typeof userVal === 'string' && userVal !== '')
216
- ? userVal
217
- : (incomingEnv[envKey] ?? '');
218
- }
219
-
220
- // Structural fields from server override local, env is smart-merged.
221
- mcpConfig.mcpServers[key] = {
222
- ...incomingEntry,
223
- ...(Object.keys(mergedEnv).length > 0 ? { env: mergedEnv } : {}),
224
- };
225
- }
226
-
227
- // ── 4. Atomic write ────────────────────────────────────────────────────
228
- const tmpPath = `${mcpJsonPath}.tmp-${process.pid}`;
229
- try {
230
- await fs.writeFile(tmpPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
231
- await fs.rename(tmpPath, mcpJsonPath);
232
- logger.info(
233
- { serverName, mergedKeys: Object.keys(entriesToMerge), mcpJsonPath },
234
- 'MCP server(s) registered in mcp.json'
235
- );
236
- } catch (err) {
237
- await fs.unlink(tmpPath).catch(() => undefined);
238
- logger.error({ serverName, err }, 'registerMcpServer: failed to write mcp.json');
239
- return null;
240
- }
241
-
242
- // ── 5. Detect setup requirements ──────────────────────────────────────
243
- // Collect env keys that are still empty (user must fill in) and flag
244
- // commands that may differ across platforms (python vs python3, etc.).
245
- const AMBIGUOUS_COMMANDS = new Set(['python', 'python3', 'node', 'npx', 'uvx']);
246
- const missingEnvKeys: string[] = [];
247
- let commandNeedsVerification = false;
248
- let registeredCommand = '';
249
-
250
- for (const entry of Object.values(entriesToMerge)) {
251
- const e = entry as Record<string, unknown>;
252
- const env = (e['env'] ?? {}) as Record<string, string>;
253
- for (const [k, v] of Object.entries(env)) {
254
- if (v === '') missingEnvKeys.push(k);
255
- }
256
- if (typeof e['command'] === 'string') {
257
- registeredCommand = e['command'];
258
- if (AMBIGUOUS_COMMANDS.has(registeredCommand)) {
259
- commandNeedsVerification = true;
260
- }
261
- }
262
- }
263
-
264
- if (missingEnvKeys.length === 0 && !commandNeedsVerification) {
265
- return null; // No user action needed
266
- }
267
-
268
- // Locate the best available setup/readme doc in the install directory so the
269
- // user can be pointed to it. Priority: SETUP.md > README.md > README*.md > *.md
270
- let setupDocPath: string | null = null;
271
- try {
272
- const entries = await fs.readdir(installDir);
273
- const mdFiles = entries.filter(f => /\.md$/i.test(f));
274
- const pick = (name: string) => mdFiles.find(f => f.toLowerCase() === name.toLowerCase());
275
- const found =
276
- pick('SETUP.md') ??
277
- pick('README.md') ??
278
- mdFiles.find(f => f.toLowerCase().startsWith('readme')) ??
279
- mdFiles[0];
280
- if (found) {
281
- setupDocPath = path.join(installDir, found);
282
- }
283
- } catch {
284
- // installDir might not exist yet for remote-URL MCPs — ignore
285
- }
286
-
287
- const hints: string[] = [];
288
- if (commandNeedsVerification) {
289
- hints.push(
290
- `The command "${registeredCommand}" may differ on your machine ` +
291
- `(e.g. "python" vs "python3"). ` +
292
- `Please verify the command in ${mcpJsonPath} under mcpServers["${serverName}"].`
293
- );
294
- }
295
- if (missingEnvKeys.length > 0) {
296
- hints.push(
297
- `Fill in the following environment variables in ${mcpJsonPath} ` +
298
- `under mcpServers["${serverName}"].env: ${missingEnvKeys.join(', ')}.`
299
- );
300
- }
301
- if (setupDocPath) {
302
- hints.push(`Refer to the setup guide for details: ${setupDocPath}`);
303
- }
304
-
305
- return {
306
- server_name: serverName,
307
- mcp_json_path: mcpJsonPath,
308
- missing_env: missingEnvKeys,
309
- command_needs_verification: commandNeedsVerification,
310
- command: registeredCommand,
311
- setup_hint: hints.join(' '),
312
- ...(setupDocPath ? { setup_doc: setupDocPath } : {}),
313
- };
314
- }
315
53
 
316
54
  export async function syncResources(params: unknown): Promise<ToolResult<SyncResourcesResult>> {
317
55
  const startTime = Date.now();
@@ -368,8 +106,12 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
368
106
  });
369
107
  }
370
108
 
371
- // ── Step 3: Download each subscribed resource to the local Cursor dir ──
372
- logToolStep('sync_resources', 'Step 3: Downloading resources to Cursor directories', {
109
+ // ── Step 3: Download each subscribed resource ──────────────────────────
110
+ // Command / Skill → registered as MCP Prompts on the server (no local I/O)
111
+ // Rule / MCP → file content is returned as LocalAction instructions
112
+ // so that the AI Agent executes the writes on the user's
113
+ // LOCAL machine (not on this potentially remote server).
114
+ logToolStep('sync_resources', 'Step 3: Processing subscribed resources', {
373
115
  count: subscriptions.total,
374
116
  });
375
117
 
@@ -382,7 +124,8 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
382
124
  version: string;
383
125
  }> = [];
384
126
 
385
- const pendingSetup: McpSetupItem[] = [];
127
+ // Accumulated local file-system actions the AI must perform on the user's machine.
128
+ const localActions: LocalAction[] = [];
386
129
 
387
130
  for (let i = 0; i < subscriptions.subscriptions.length; i++) {
388
131
  const sub = subscriptions.subscriptions[i];
@@ -437,18 +180,40 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
437
180
  duration: Date.now() - tDl,
438
181
  });
439
182
 
183
+ // When the API returns no files (expected for Command/Skill in MCP Prompt
184
+ // mode — content lives in the server-side git repo, not the API), fall back
185
+ // to reading the files directly from the local git checkout.
186
+ let sourceFiles = downloadResult.files;
187
+ if (sourceFiles.length === 0) {
188
+ sourceFiles = await multiSourceGitManager.readResourceFiles(
189
+ sub.name,
190
+ sub.type as 'command' | 'skill',
191
+ );
192
+ if (sourceFiles.length > 0) {
193
+ logToolStep('sync_resources', 'Loaded resource files from local git checkout', {
194
+ resourceId: sub.id,
195
+ fileCount: sourceFiles.length,
196
+ });
197
+ } else {
198
+ logger.warn(
199
+ { resourceId: sub.id, resourceName: sub.name },
200
+ 'No files found via API or local git — prompt will have empty content',
201
+ );
202
+ }
203
+ }
204
+
440
205
  // Primary Markdown content selection:
441
206
  // - skill: prefer SKILL.md (canonical entrypoint for all skill content)
442
207
  // - command: prefer the file whose name matches the resource name
443
208
  // - fallback: first .md file, then first file of any type
444
209
  const isSkill = sub.type === 'skill';
445
210
  const primaryFile = isSkill
446
- ? (downloadResult.files.find((f) => path.basename(f.path) === 'SKILL.md') ??
447
- downloadResult.files.find((f) => f.path.endsWith('.md')) ??
448
- downloadResult.files[0])
449
- : (downloadResult.files.find((f) => path.basename(f.path).replace(/\.md$/, '') === sub.name) ??
450
- downloadResult.files.find((f) => f.path.endsWith('.md')) ??
451
- downloadResult.files[0]);
211
+ ? (sourceFiles.find((f) => path.basename(f.path) === 'SKILL.md') ??
212
+ sourceFiles.find((f) => f.path.endsWith('.md')) ??
213
+ sourceFiles[0])
214
+ : (sourceFiles.find((f) => path.basename(f.path).replace(/\.md$/, '') === sub.name) ??
215
+ sourceFiles.find((f) => f.path.endsWith('.md')) ??
216
+ sourceFiles[0]);
452
217
 
453
218
  const rawContent = primaryFile?.content ?? '';
454
219
 
@@ -535,141 +300,171 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
535
300
  }
536
301
 
537
302
  if (isRemoteUrlMcp) {
538
- // Remote-URL MCP: no local files to write; just register in mcp.json.
539
- // Parse and merge the entries directly from the downloaded content.
303
+ // Remote-URL MCP (Format B): no local files needed.
304
+ // Return a merge_mcp_json action so the AI updates ~/.cursor/mcp.json
305
+ // on the user's LOCAL machine, not on this (possibly remote) server.
540
306
  const configContent = firstFile!.content;
541
307
  const mcpJsonPath = path.join(getCursorRootDir(), 'mcp.json');
542
- let mcpConfig: { mcpServers: Record<string, unknown> } = { mcpServers: {} };
543
- try {
544
- const raw = await fs.readFile(mcpJsonPath, 'utf-8');
545
- const p = JSON.parse(raw);
546
- if (p && typeof p === 'object' && 'mcpServers' in p) {
547
- mcpConfig = p as typeof mcpConfig;
548
- }
549
- } catch { /* file missing or corrupt — start fresh */ }
550
-
551
308
  const entries = JSON.parse(configContent) as Record<string, unknown>;
552
- // Smart-merge: structural fields from server; preserve user env values.
553
- for (const [key, incoming] of Object.entries(entries)) {
554
- const existing = mcpConfig.mcpServers[key];
555
- if (!existing || typeof existing !== 'object') {
556
- mcpConfig.mcpServers[key] = incoming;
557
- } else {
558
- const inc = incoming as Record<string, unknown>;
559
- const ext = existing as Record<string, unknown>;
560
- const inEnv = (inc['env'] ?? {}) as Record<string, string>;
561
- const exEnv = (ext['env'] ?? {}) as Record<string, string>;
562
- const mergedEnv: Record<string, string> = {};
563
- for (const k of Object.keys(inEnv)) {
564
- const userVal = exEnv[k];
565
- mergedEnv[k] = (typeof userVal === 'string' && userVal !== '') ? userVal : (inEnv[k] ?? '');
566
- }
567
- mcpConfig.mcpServers[key] = {
568
- ...inc,
569
- ...(Object.keys(mergedEnv).length > 0 ? { env: mergedEnv } : {}),
570
- };
571
- }
572
- }
573
-
574
- const tmpPath = `${mcpJsonPath}.tmp-${process.pid}`;
575
- await fs.writeFile(tmpPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
576
- await fs.rename(tmpPath, mcpJsonPath);
577
309
 
578
- // Detect missing env vars in remote-URL entries (no local command to check).
579
- const remoteMissingEnv: string[] = [];
580
- for (const entry of Object.values(entries)) {
310
+ for (const [serverName, entry] of Object.entries(entries)) {
581
311
  const e = entry as Record<string, unknown>;
582
312
  const env = (e['env'] ?? {}) as Record<string, string>;
583
- for (const [k, v] of Object.entries(env)) {
584
- if (v === '') remoteMissingEnv.push(k);
585
- }
586
- }
587
- if (remoteMissingEnv.length > 0) {
588
- pendingSetup.push({
589
- server_name: sub.name,
313
+ const missingEnv = Object.entries(env)
314
+ .filter(([, v]) => v === '')
315
+ .map(([k]) => k);
316
+
317
+ const action: MergeMcpJsonAction = {
318
+ action: 'merge_mcp_json',
590
319
  mcp_json_path: mcpJsonPath,
591
- missing_env: remoteMissingEnv,
592
- command_needs_verification: false,
593
- command: '',
594
- setup_hint:
595
- `Fill in the following environment variables in ${mcpJsonPath} ` +
596
- `under the relevant mcpServers entries for "${sub.name}": ` +
597
- `${remoteMissingEnv.join(', ')}.`,
598
- });
320
+ server_name: serverName,
321
+ entry: e,
322
+ ...(missingEnv.length > 0 ? {
323
+ missing_env: missingEnv,
324
+ setup_hint:
325
+ `Fill in the following environment variables in ${mcpJsonPath} ` +
326
+ `under mcpServers["${serverName}"]: ${missingEnv.join(', ')}.`,
327
+ } : {}),
328
+ };
329
+ localActions.push(action);
599
330
  }
600
331
 
601
332
  tally.synced++;
602
333
  details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
603
- logToolStep('sync_resources', 'Remote-URL MCP registered in mcp.json (no local files)', {
334
+ logToolStep('sync_resources', 'Remote-URL MCP: merge_mcp_json action queued for AI', {
604
335
  resourceId: sub.id,
605
- mergedKeys: Object.keys(entries),
336
+ serverKeys: Object.keys(entries),
606
337
  });
607
338
  continue;
608
339
  }
609
340
 
610
- // Incremental mode: skip file write if local directory already exists.
611
- // MCP resources (local-executable type) still call registerMcpServer
612
- // to keep mcp.json in sync even when files have not changed.
613
- if (mode === 'incremental') {
614
- let alreadyPresent = false;
615
- try {
616
- await fs.access(destPath);
617
- alreadyPresent = true;
618
- } catch { /* not present — fall through to write */ }
341
+ // ── Rule resource ─────────────────────────────────────────────────────
342
+ // Return write_file actions; the AI writes the files locally.
343
+ if (sub.type === 'rule') {
344
+ const typeDir = getCursorTypeDir(sub.type);
619
345
 
620
- if (alreadyPresent) {
621
- if (sub.type === 'mcp') {
622
- const setupItem = await registerMcpServer(sub.name, destPath);
623
- if (setupItem) pendingSetup.push(setupItem);
346
+ for (const file of downloadResult.files) {
347
+ const normalised = path.normalize(file.path);
348
+ if (normalised.startsWith('..')) {
349
+ logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
350
+ continue;
624
351
  }
625
- tally.cached++;
626
- details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
627
- logToolStep('sync_resources', 'Resource already present (incremental — skipping file write)', {
628
- resourceId: sub.id, destPath,
352
+ localActions.push({
353
+ action: 'write_file',
354
+ path: path.join(typeDir, normalised),
355
+ content: file.content,
629
356
  });
630
- continue;
631
357
  }
358
+
359
+ tally.synced++;
360
+ details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
361
+ logToolStep('sync_resources', 'Rule: write_file actions queued for AI', {
362
+ resourceId: sub.id,
363
+ fileCount: downloadResult.files.length,
364
+ });
365
+ continue;
632
366
  }
633
367
 
634
- // Ensure the Cursor type directory exists (e.g. ~/.cursor/skills/).
635
- const typeDir = getCursorTypeDir(sub.type);
636
- await fs.mkdir(typeDir, { recursive: true });
368
+ // ── Local-executable MCP resource (Format A has "command" field) ───
369
+ // Return write_file + merge_mcp_json actions; the AI performs them locally.
370
+ if (sub.type === 'mcp') {
371
+ const typeDir = getCursorTypeDir(sub.type);
372
+ const installDir = path.join(typeDir, sub.name);
373
+
374
+ // Queue file writes.
375
+ for (const file of downloadResult.files) {
376
+ const normalised = path.normalize(file.path);
377
+ if (normalised.startsWith('..')) {
378
+ logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
379
+ continue;
380
+ }
381
+ localActions.push({
382
+ action: 'write_file',
383
+ path: path.join(installDir, normalised),
384
+ content: file.content,
385
+ });
386
+ }
637
387
 
638
- // Determine write strategy based on resource type:
639
- // Directory-based (skill, mcp): create <typeDir>/<name>/ and write files under it.
640
- // File-based (command, rule): write each file directly into <typeDir>/ — no subdir.
641
- const isDirectoryType = sub.type === 'skill' || sub.type === 'mcp';
642
- const writeRoot = isDirectoryType ? destPath : typeDir;
388
+ // Build the mcp.json entry from the downloaded descriptor.
389
+ // We replicate the Format-A detection logic from registerMcpServer()
390
+ // but without touching the server filesystem.
391
+ const mcpJsonPath = path.join(getCursorRootDir(), 'mcp.json');
392
+ let mcpEntry: Record<string, unknown> = {};
393
+ let missingEnv: string[] = [];
394
+ let setupHint: string | undefined;
395
+ let setupDoc: string | undefined;
396
+
397
+ const descriptorFile = downloadResult.files.find(
398
+ (f) => path.basename(f.path) === 'mcp-config.json',
399
+ );
400
+ if (descriptorFile) {
401
+ try {
402
+ const cfg = JSON.parse(descriptorFile.content) as Record<string, unknown>;
403
+ if (typeof cfg['command'] === 'string') {
404
+ // Format A: single-server descriptor
405
+ mcpEntry = cfg;
406
+ } else {
407
+ // Format B disguised as local — treat whole object as entries map
408
+ mcpEntry = cfg;
409
+ }
410
+ } catch { /* malformed — leave entry empty */ }
411
+ }
643
412
 
644
- if (isDirectoryType) {
645
- await fs.mkdir(writeRoot, { recursive: true });
646
- }
413
+ // Detect missing env vars.
414
+ const envBlock = (mcpEntry['env'] ?? {}) as Record<string, string>;
415
+ missingEnv = Object.entries(envBlock).filter(([, v]) => v === '').map(([k]) => k);
416
+ if (missingEnv.length > 0) {
417
+ setupHint =
418
+ `Fill in the following environment variables in ${mcpJsonPath} ` +
419
+ `under mcpServers["${sub.name}"]: ${missingEnv.join(', ')}.`;
420
+ }
647
421
 
648
- for (const file of downloadResult.files) {
649
- // Reject path traversal attempts in file.path
650
- const normalised = path.normalize(file.path);
651
- if (normalised.startsWith('..')) {
652
- logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
653
- continue;
422
+ // Check for a setup doc among downloaded files.
423
+ const readmeFile = downloadResult.files.find((f) =>
424
+ /readme/i.test(path.basename(f.path)) && f.path.endsWith('.md'),
425
+ );
426
+ if (readmeFile) {
427
+ setupDoc = path.join(installDir, readmeFile.path);
654
428
  }
655
- const writePath = path.join(writeRoot, normalised);
656
- await fs.mkdir(path.dirname(writePath), { recursive: true });
657
- await fs.writeFile(writePath, file.content, 'utf-8');
658
- }
659
429
 
660
- // After writing local MCP files, register the server in ~/.cursor/mcp.json.
661
- if (sub.type === 'mcp') {
662
- const setupItem = await registerMcpServer(sub.name, destPath);
663
- if (setupItem) pendingSetup.push(setupItem);
430
+ const mergeMcpAction: MergeMcpJsonAction = {
431
+ action: 'merge_mcp_json',
432
+ mcp_json_path: mcpJsonPath,
433
+ server_name: sub.name,
434
+ entry: Object.keys(mcpEntry).length > 0 ? mcpEntry : {
435
+ // Fallback: auto-detect entry point from file list.
436
+ command: (() => {
437
+ const jsEntry = downloadResult.files.find((f) => f.path.endsWith('.js'));
438
+ const pyEntry = downloadResult.files.find((f) => f.path.endsWith('.py'));
439
+ if (jsEntry) return 'node';
440
+ if (pyEntry) return 'python3';
441
+ return 'node';
442
+ })(),
443
+ args: [(() => {
444
+ const jsEntry = downloadResult.files.find((f) => f.path.endsWith('.js'));
445
+ const pyEntry = downloadResult.files.find((f) => f.path.endsWith('.py'));
446
+ const entryFile = jsEntry ?? pyEntry ?? downloadResult.files[0];
447
+ return path.join(installDir, entryFile?.path ?? '');
448
+ })()],
449
+ },
450
+ ...(missingEnv.length > 0 ? { missing_env: missingEnv, setup_hint: setupHint } : {}),
451
+ ...(setupDoc ? { setup_doc: setupDoc } : {}),
452
+ };
453
+ localActions.push(mergeMcpAction);
454
+
455
+ tally.synced++;
456
+ details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
457
+ logToolStep('sync_resources', 'Local-executable MCP: write_file + merge_mcp_json actions queued for AI', {
458
+ resourceId: sub.id,
459
+ fileCount: downloadResult.files.length,
460
+ });
461
+ continue;
664
462
  }
665
463
 
666
- tally.synced++;
667
- details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
668
- logToolStep('sync_resources', 'Resource written to Cursor directory', {
669
- resourceId: sub.id,
670
- destPath,
671
- fileCount: downloadResult.files.length,
672
- });
464
+ // Fallback for any unrecognised types (should not happen in practice).
465
+ logger.warn({ resourceId: sub.id, type: sub.type }, 'Unrecognised resource type — skipping');
466
+ tally.failed++;
467
+ details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
673
468
 
674
469
  } catch (error) {
675
470
  logger.error({
@@ -693,7 +488,7 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
693
488
  health_score: healthScore,
694
489
  summary: tally,
695
490
  details,
696
- ...(pendingSetup.length > 0 ? { pending_setup: pendingSetup } : {}),
491
+ ...(localActions.length > 0 ? { local_actions_required: localActions } : {}),
697
492
  };
698
493
 
699
494
  const duration = Date.now() - startTime;
@@ -762,7 +557,16 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
762
557
  // Tool definition for registry
763
558
  export const syncResourcesTool = {
764
559
  name: 'sync_resources',
765
- description: 'Synchronize subscribed resources to local filesystem',
560
+ description:
561
+ 'Synchronize subscribed AI resources. ' +
562
+ 'Command and Skill resources are registered as MCP Prompts on the server. ' +
563
+ 'Rule and MCP resources are returned as `local_actions_required` — an ordered list of ' +
564
+ 'write_file, merge_mcp_json, or other actions that the AI Agent MUST execute on the ' +
565
+ 'USER\'S LOCAL MACHINE after receiving the response. ' +
566
+ 'Execute every action in the list before reporting success to the user. ' +
567
+ 'For merge_mcp_json actions: read the existing ~/.cursor/mcp.json, smart-merge the ' +
568
+ 'provided entry (preserve existing user env values), then write the file back. ' +
569
+ 'For write_file actions: create any missing parent directories then write the file.',
766
570
  inputSchema: {
767
571
  type: 'object' as const,
768
572
  properties: {