@elliotding/ai-agent-mcp 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai-resource-telemetry.json +13 -1
- package/dist/tools/sync-resources.d.ts.map +1 -1
- package/dist/tools/sync-resources.js +148 -332
- package/dist/tools/sync-resources.js.map +1 -1
- package/dist/tools/uninstall-resource.d.ts.map +1 -1
- package/dist/tools/uninstall-resource.js +66 -150
- package/dist/tools/uninstall-resource.js.map +1 -1
- package/dist/types/tools.d.ts +53 -0
- package/dist/types/tools.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/tools/sync-resources.ts +162 -380
- package/src/tools/uninstall-resource.ts +70 -162
- package/src/types/tools.ts +74 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"client_version": "0.1.5",
|
|
3
3
|
"users": {
|
|
4
4
|
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJDU1BfTUNQX0FVVEgiLCJpc3MiOiJjbGllbnQtc2VydmljZS1wbGF0Zm9ybSIsImlhdCI6MTc3MjA3NjIxNSwiZW1haWwiOiJlbGxpb3QuZGluZ0B6b29tLnVzIn0.xw7Np0MynXqhL4ay_vN1v5Ac332aga0tgybPQsC7WMc": {
|
|
5
|
-
"last_reported_at": "2026-03-
|
|
5
|
+
"last_reported_at": "2026-03-25T05:35:45.923Z",
|
|
6
6
|
"pending_events": [],
|
|
7
7
|
"subscribed_rules": [],
|
|
8
8
|
"configured_mcps": [
|
|
@@ -23,6 +23,18 @@
|
|
|
23
23
|
"pending_events": [],
|
|
24
24
|
"subscribed_rules": [],
|
|
25
25
|
"configured_mcps": []
|
|
26
|
+
},
|
|
27
|
+
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJDU1BfTUNQX0FVVEgiLCJpc3MiOiJjbGllbnQtc2VydmljZS1wbGF0Zm9ybSIsImlhdCI6MTc3NDQwMzQwOSwiZW1haWwiOiJlbGxpb3QuZGluZ0B6b29tLnVzIn0.0vNcEhEkUWuIWMF-Bf6AVendKEPTtfMDyUYpY7jFAvo": {
|
|
28
|
+
"last_reported_at": null,
|
|
29
|
+
"pending_events": [],
|
|
30
|
+
"subscribed_rules": [],
|
|
31
|
+
"configured_mcps": []
|
|
32
|
+
},
|
|
33
|
+
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJDU1BfTUNQX0FVVEgiLCJpc3MiOiJjbGllbnQtc2VydmljZS1wbGF0Zm9ybSIsImlhdCI6MTc3NDQwNzgxMCwiZW1haWwiOiJlbGxpb3QuZGluZ0B6b29tLnVzIn0.fAeDQv8Q39lPTn9H5I5qNDMaQ8k2eadTKeExiKBSizI": {
|
|
34
|
+
"last_reported_at": null,
|
|
35
|
+
"pending_events": [],
|
|
36
|
+
"subscribed_rules": [],
|
|
37
|
+
"configured_mcps": []
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-resources.d.ts","sourceRoot":"","sources":["../../src/tools/sync-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AASH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"sync-resources.d.ts","sourceRoot":"","sources":["../../src/tools/sync-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AASH,OAAO,KAAK,EAEV,mBAAmB,EAGnB,UAAU,EACX,MAAM,gBAAgB,CAAC;AAsBxB,wBAAsB,aAAa,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,UAAU,CAAC,mBAAmB,CAAC,CAAC,CA+d7F;AAGD,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwC7B,CAAC"}
|
|
@@ -80,224 +80,6 @@ function extractFrontmatterDescription(content) {
|
|
|
80
80
|
}
|
|
81
81
|
return undefined;
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
84
|
-
* Register a downloaded MCP resource into ~/.cursor/mcp.json.
|
|
85
|
-
*
|
|
86
|
-
* Supports:
|
|
87
|
-
* - Format A (local executable): resolves relative args to absolute paths, writes one entry.
|
|
88
|
-
* - Format B (remote URL map): merges all entries directly into mcpServers.
|
|
89
|
-
* - No mcp-config.json: heuristic fallback (scans for .py/.js entry point, logs WARN).
|
|
90
|
-
*
|
|
91
|
-
* The write is idempotent — re-running after a re-download updates existing entries.
|
|
92
|
-
*
|
|
93
|
-
* Returns a McpSetupItem when the registered server needs manual configuration
|
|
94
|
-
* (empty env vars, or a command that might differ across platforms), or null
|
|
95
|
-
* when no action is required from the user.
|
|
96
|
-
*/
|
|
97
|
-
async function registerMcpServer(serverName, installDir) {
|
|
98
|
-
// ── 1. Load mcp-config.json ────────────────────────────────────────────
|
|
99
|
-
const configFilePath = path.join(installDir, 'mcp-config.json');
|
|
100
|
-
let rawConfig = null;
|
|
101
|
-
try {
|
|
102
|
-
const raw = await fs.readFile(configFilePath, 'utf-8');
|
|
103
|
-
rawConfig = JSON.parse(raw);
|
|
104
|
-
logger_1.logger.debug({ serverName, configFilePath }, 'registerMcpServer: loaded mcp-config.json');
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
logger_1.logger.warn({ serverName, configFilePath }, 'registerMcpServer: mcp-config.json not found — falling back to heuristic detection. ' +
|
|
108
|
-
'Add an mcp-config.json to this resource for reliable registration.');
|
|
109
|
-
}
|
|
110
|
-
// ── 2. Determine what to merge into mcp.json ──────────────────────────
|
|
111
|
-
// entriesToMerge: map of serverKey → entry object (may have multiple keys for Format B)
|
|
112
|
-
let entriesToMerge = {};
|
|
113
|
-
if (rawConfig !== null && typeof rawConfig === 'object') {
|
|
114
|
-
const cfg = rawConfig;
|
|
115
|
-
if (typeof cfg['command'] === 'string') {
|
|
116
|
-
// ── Format A: local executable ───────────────────────────────────
|
|
117
|
-
const descriptor = cfg;
|
|
118
|
-
const key = descriptor.name ?? serverName;
|
|
119
|
-
// Only resolve args that look like relative file paths (contain a dot or
|
|
120
|
-
// path separator). Plain words like "mcp", "start", "--port" are kept as-is.
|
|
121
|
-
const looksLikePath = (a) => a.startsWith('./') || a.startsWith('../') || a.includes(path.sep) || /\.\w+$/.test(a);
|
|
122
|
-
const resolvedArgs = (descriptor.args ?? []).map(a => path.isAbsolute(a) || !looksLikePath(a) ? a : path.join(installDir, a));
|
|
123
|
-
entriesToMerge[key] = {
|
|
124
|
-
command: descriptor.command,
|
|
125
|
-
args: resolvedArgs,
|
|
126
|
-
...(descriptor.env && Object.keys(descriptor.env).length > 0
|
|
127
|
-
? { env: descriptor.env }
|
|
128
|
-
: {}),
|
|
129
|
-
};
|
|
130
|
-
logger_1.logger.info({ serverName, key, command: descriptor.command }, 'registerMcpServer: Format A (local executable)');
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
// ── Format B: remote URL entries map ─────────────────────────────
|
|
134
|
-
// The entire object is a ready-to-merge mcpServers map.
|
|
135
|
-
entriesToMerge = cfg;
|
|
136
|
-
logger_1.logger.info({ serverName, keys: Object.keys(entriesToMerge) }, 'registerMcpServer: Format B (remote URL entries)');
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
// ── Heuristic fallback (no mcp-config.json) ───────────────────────
|
|
141
|
-
let entryFile = null;
|
|
142
|
-
let command = 'python3';
|
|
143
|
-
try {
|
|
144
|
-
const entries = await fs.readdir(installDir);
|
|
145
|
-
if (entries.includes(`${serverName}.py`)) {
|
|
146
|
-
entryFile = path.join(installDir, `${serverName}.py`);
|
|
147
|
-
command = 'python3';
|
|
148
|
-
}
|
|
149
|
-
else if (entries.includes(`${serverName}.js`)) {
|
|
150
|
-
entryFile = path.join(installDir, `${serverName}.js`);
|
|
151
|
-
command = 'node';
|
|
152
|
-
}
|
|
153
|
-
if (!entryFile) {
|
|
154
|
-
const py = entries.find(f => f.endsWith('.py') && f !== '__init__.py');
|
|
155
|
-
if (py) {
|
|
156
|
-
entryFile = path.join(installDir, py);
|
|
157
|
-
command = 'python3';
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (!entryFile) {
|
|
161
|
-
const js = entries.find(f => f.endsWith('.js') || f.endsWith('.mjs'));
|
|
162
|
-
if (js) {
|
|
163
|
-
entryFile = path.join(installDir, js);
|
|
164
|
-
command = 'node';
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
catch (err) {
|
|
169
|
-
logger_1.logger.warn({ serverName, installDir, err }, 'registerMcpServer: could not read install directory');
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
if (!entryFile) {
|
|
173
|
-
logger_1.logger.warn({ serverName, installDir }, 'registerMcpServer: no entry point found and no mcp-config.json — skipping registration');
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
entriesToMerge[serverName] = { command, args: [entryFile] };
|
|
177
|
-
}
|
|
178
|
-
// ── 3. Read / create ~/.cursor/mcp.json ───────────────────────────────
|
|
179
|
-
const mcpJsonPath = path.join((0, cursor_paths_1.getCursorRootDir)(), 'mcp.json');
|
|
180
|
-
let mcpConfig = { mcpServers: {} };
|
|
181
|
-
try {
|
|
182
|
-
const raw = await fs.readFile(mcpJsonPath, 'utf-8');
|
|
183
|
-
const parsed = JSON.parse(raw);
|
|
184
|
-
if (parsed && typeof parsed === 'object' && 'mcpServers' in parsed) {
|
|
185
|
-
mcpConfig = parsed;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
// File doesn't exist or is corrupt — start fresh
|
|
190
|
-
}
|
|
191
|
-
// Smart-merge each entry into mcpServers:
|
|
192
|
-
// - Structural fields (command, args, url, transport, …): always take the
|
|
193
|
-
// value from mcp-config.json (server is authoritative for structure).
|
|
194
|
-
// - env field: preserve user-filled non-empty values; only add keys that
|
|
195
|
-
// are new or were previously empty (avoids wiping tokens / URLs the user
|
|
196
|
-
// has already configured).
|
|
197
|
-
for (const [key, incoming] of Object.entries(entriesToMerge)) {
|
|
198
|
-
const existing = mcpConfig.mcpServers[key];
|
|
199
|
-
if (!existing || typeof existing !== 'object') {
|
|
200
|
-
// No prior entry — write as-is.
|
|
201
|
-
mcpConfig.mcpServers[key] = incoming;
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
const incomingEntry = incoming;
|
|
205
|
-
const existingEntry = existing;
|
|
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 = {};
|
|
209
|
-
const incomingEnv = (incomingEntry['env'] ?? {});
|
|
210
|
-
const existingEnv = (existingEntry['env'] ?? {});
|
|
211
|
-
for (const envKey of Object.keys(incomingEnv)) {
|
|
212
|
-
const userVal = existingEnv[envKey];
|
|
213
|
-
// Preserve whatever the user typed; fall back to the template placeholder.
|
|
214
|
-
mergedEnv[envKey] = (typeof userVal === 'string' && userVal !== '')
|
|
215
|
-
? userVal
|
|
216
|
-
: (incomingEnv[envKey] ?? '');
|
|
217
|
-
}
|
|
218
|
-
// Structural fields from server override local, env is smart-merged.
|
|
219
|
-
mcpConfig.mcpServers[key] = {
|
|
220
|
-
...incomingEntry,
|
|
221
|
-
...(Object.keys(mergedEnv).length > 0 ? { env: mergedEnv } : {}),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
// ── 4. Atomic write ────────────────────────────────────────────────────
|
|
225
|
-
const tmpPath = `${mcpJsonPath}.tmp-${process.pid}`;
|
|
226
|
-
try {
|
|
227
|
-
await fs.writeFile(tmpPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
|
|
228
|
-
await fs.rename(tmpPath, mcpJsonPath);
|
|
229
|
-
logger_1.logger.info({ serverName, mergedKeys: Object.keys(entriesToMerge), mcpJsonPath }, 'MCP server(s) registered in mcp.json');
|
|
230
|
-
}
|
|
231
|
-
catch (err) {
|
|
232
|
-
await fs.unlink(tmpPath).catch(() => undefined);
|
|
233
|
-
logger_1.logger.error({ serverName, err }, 'registerMcpServer: failed to write mcp.json');
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
// ── 5. Detect setup requirements ──────────────────────────────────────
|
|
237
|
-
// Collect env keys that are still empty (user must fill in) and flag
|
|
238
|
-
// commands that may differ across platforms (python vs python3, etc.).
|
|
239
|
-
const AMBIGUOUS_COMMANDS = new Set(['python', 'python3', 'node', 'npx', 'uvx']);
|
|
240
|
-
const missingEnvKeys = [];
|
|
241
|
-
let commandNeedsVerification = false;
|
|
242
|
-
let registeredCommand = '';
|
|
243
|
-
for (const entry of Object.values(entriesToMerge)) {
|
|
244
|
-
const e = entry;
|
|
245
|
-
const env = (e['env'] ?? {});
|
|
246
|
-
for (const [k, v] of Object.entries(env)) {
|
|
247
|
-
if (v === '')
|
|
248
|
-
missingEnvKeys.push(k);
|
|
249
|
-
}
|
|
250
|
-
if (typeof e['command'] === 'string') {
|
|
251
|
-
registeredCommand = e['command'];
|
|
252
|
-
if (AMBIGUOUS_COMMANDS.has(registeredCommand)) {
|
|
253
|
-
commandNeedsVerification = true;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (missingEnvKeys.length === 0 && !commandNeedsVerification) {
|
|
258
|
-
return null; // No user action needed
|
|
259
|
-
}
|
|
260
|
-
// Locate the best available setup/readme doc in the install directory so the
|
|
261
|
-
// user can be pointed to it. Priority: SETUP.md > README.md > README*.md > *.md
|
|
262
|
-
let setupDocPath = null;
|
|
263
|
-
try {
|
|
264
|
-
const entries = await fs.readdir(installDir);
|
|
265
|
-
const mdFiles = entries.filter(f => /\.md$/i.test(f));
|
|
266
|
-
const pick = (name) => mdFiles.find(f => f.toLowerCase() === name.toLowerCase());
|
|
267
|
-
const found = pick('SETUP.md') ??
|
|
268
|
-
pick('README.md') ??
|
|
269
|
-
mdFiles.find(f => f.toLowerCase().startsWith('readme')) ??
|
|
270
|
-
mdFiles[0];
|
|
271
|
-
if (found) {
|
|
272
|
-
setupDocPath = path.join(installDir, found);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
catch {
|
|
276
|
-
// installDir might not exist yet for remote-URL MCPs — ignore
|
|
277
|
-
}
|
|
278
|
-
const hints = [];
|
|
279
|
-
if (commandNeedsVerification) {
|
|
280
|
-
hints.push(`The command "${registeredCommand}" may differ on your machine ` +
|
|
281
|
-
`(e.g. "python" vs "python3"). ` +
|
|
282
|
-
`Please verify the command in ${mcpJsonPath} under mcpServers["${serverName}"].`);
|
|
283
|
-
}
|
|
284
|
-
if (missingEnvKeys.length > 0) {
|
|
285
|
-
hints.push(`Fill in the following environment variables in ${mcpJsonPath} ` +
|
|
286
|
-
`under mcpServers["${serverName}"].env: ${missingEnvKeys.join(', ')}.`);
|
|
287
|
-
}
|
|
288
|
-
if (setupDocPath) {
|
|
289
|
-
hints.push(`Refer to the setup guide for details: ${setupDocPath}`);
|
|
290
|
-
}
|
|
291
|
-
return {
|
|
292
|
-
server_name: serverName,
|
|
293
|
-
mcp_json_path: mcpJsonPath,
|
|
294
|
-
missing_env: missingEnvKeys,
|
|
295
|
-
command_needs_verification: commandNeedsVerification,
|
|
296
|
-
command: registeredCommand,
|
|
297
|
-
setup_hint: hints.join(' '),
|
|
298
|
-
...(setupDocPath ? { setup_doc: setupDocPath } : {}),
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
83
|
async function syncResources(params) {
|
|
302
84
|
const startTime = Date.now();
|
|
303
85
|
const typedParams = params;
|
|
@@ -343,13 +125,18 @@ async function syncResources(params) {
|
|
|
343
125
|
},
|
|
344
126
|
});
|
|
345
127
|
}
|
|
346
|
-
// ── Step 3: Download each subscribed resource
|
|
347
|
-
|
|
128
|
+
// ── Step 3: Download each subscribed resource ──────────────────────────
|
|
129
|
+
// Command / Skill → registered as MCP Prompts on the server (no local I/O)
|
|
130
|
+
// Rule / MCP → file content is returned as LocalAction instructions
|
|
131
|
+
// so that the AI Agent executes the writes on the user's
|
|
132
|
+
// LOCAL machine (not on this potentially remote server).
|
|
133
|
+
(0, logger_1.logToolStep)('sync_resources', 'Step 3: Processing subscribed resources', {
|
|
348
134
|
count: subscriptions.total,
|
|
349
135
|
});
|
|
350
136
|
const tally = { total: subscriptions.total, synced: 0, cached: 0, failed: 0 };
|
|
351
137
|
const details = [];
|
|
352
|
-
|
|
138
|
+
// Accumulated local file-system actions the AI must perform on the user's machine.
|
|
139
|
+
const localActions = [];
|
|
353
140
|
for (let i = 0; i < subscriptions.subscriptions.length; i++) {
|
|
354
141
|
const sub = subscriptions.subscriptions[i];
|
|
355
142
|
if (!sub)
|
|
@@ -485,134 +272,155 @@ async function syncResources(params) {
|
|
|
485
272
|
catch { /* malformed JSON — treat as normal MCP */ }
|
|
486
273
|
}
|
|
487
274
|
if (isRemoteUrlMcp) {
|
|
488
|
-
// Remote-URL MCP: no local files
|
|
489
|
-
//
|
|
275
|
+
// Remote-URL MCP (Format B): no local files needed.
|
|
276
|
+
// Return a merge_mcp_json action so the AI updates ~/.cursor/mcp.json
|
|
277
|
+
// on the user's LOCAL machine, not on this (possibly remote) server.
|
|
490
278
|
const configContent = firstFile.content;
|
|
491
279
|
const mcpJsonPath = path.join((0, cursor_paths_1.getCursorRootDir)(), 'mcp.json');
|
|
492
|
-
let mcpConfig = { mcpServers: {} };
|
|
493
|
-
try {
|
|
494
|
-
const raw = await fs.readFile(mcpJsonPath, 'utf-8');
|
|
495
|
-
const p = JSON.parse(raw);
|
|
496
|
-
if (p && typeof p === 'object' && 'mcpServers' in p) {
|
|
497
|
-
mcpConfig = p;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
catch { /* file missing or corrupt — start fresh */ }
|
|
501
280
|
const entries = JSON.parse(configContent);
|
|
502
|
-
|
|
503
|
-
for (const [key, incoming] of Object.entries(entries)) {
|
|
504
|
-
const existing = mcpConfig.mcpServers[key];
|
|
505
|
-
if (!existing || typeof existing !== 'object') {
|
|
506
|
-
mcpConfig.mcpServers[key] = incoming;
|
|
507
|
-
}
|
|
508
|
-
else {
|
|
509
|
-
const inc = incoming;
|
|
510
|
-
const ext = existing;
|
|
511
|
-
const inEnv = (inc['env'] ?? {});
|
|
512
|
-
const exEnv = (ext['env'] ?? {});
|
|
513
|
-
const mergedEnv = {};
|
|
514
|
-
for (const k of Object.keys(inEnv)) {
|
|
515
|
-
const userVal = exEnv[k];
|
|
516
|
-
mergedEnv[k] = (typeof userVal === 'string' && userVal !== '') ? userVal : (inEnv[k] ?? '');
|
|
517
|
-
}
|
|
518
|
-
mcpConfig.mcpServers[key] = {
|
|
519
|
-
...inc,
|
|
520
|
-
...(Object.keys(mergedEnv).length > 0 ? { env: mergedEnv } : {}),
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
const tmpPath = `${mcpJsonPath}.tmp-${process.pid}`;
|
|
525
|
-
await fs.writeFile(tmpPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
|
|
526
|
-
await fs.rename(tmpPath, mcpJsonPath);
|
|
527
|
-
// Detect missing env vars in remote-URL entries (no local command to check).
|
|
528
|
-
const remoteMissingEnv = [];
|
|
529
|
-
for (const entry of Object.values(entries)) {
|
|
281
|
+
for (const [serverName, entry] of Object.entries(entries)) {
|
|
530
282
|
const e = entry;
|
|
531
283
|
const env = (e['env'] ?? {});
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (remoteMissingEnv.length > 0) {
|
|
538
|
-
pendingSetup.push({
|
|
539
|
-
server_name: sub.name,
|
|
284
|
+
const missingEnv = Object.entries(env)
|
|
285
|
+
.filter(([, v]) => v === '')
|
|
286
|
+
.map(([k]) => k);
|
|
287
|
+
const action = {
|
|
288
|
+
action: 'merge_mcp_json',
|
|
540
289
|
mcp_json_path: mcpJsonPath,
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
`
|
|
546
|
-
|
|
547
|
-
|
|
290
|
+
server_name: serverName,
|
|
291
|
+
entry: e,
|
|
292
|
+
...(missingEnv.length > 0 ? {
|
|
293
|
+
missing_env: missingEnv,
|
|
294
|
+
setup_hint: `Fill in the following environment variables in ${mcpJsonPath} ` +
|
|
295
|
+
`under mcpServers["${serverName}"]: ${missingEnv.join(', ')}.`,
|
|
296
|
+
} : {}),
|
|
297
|
+
};
|
|
298
|
+
localActions.push(action);
|
|
548
299
|
}
|
|
549
300
|
tally.synced++;
|
|
550
301
|
details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
|
|
551
|
-
(0, logger_1.logToolStep)('sync_resources', 'Remote-URL MCP
|
|
302
|
+
(0, logger_1.logToolStep)('sync_resources', 'Remote-URL MCP: merge_mcp_json action queued for AI', {
|
|
552
303
|
resourceId: sub.id,
|
|
553
|
-
|
|
304
|
+
serverKeys: Object.keys(entries),
|
|
554
305
|
});
|
|
555
306
|
continue;
|
|
556
307
|
}
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
catch { /* not present — fall through to write */ }
|
|
567
|
-
if (alreadyPresent) {
|
|
568
|
-
if (sub.type === 'mcp') {
|
|
569
|
-
const setupItem = await registerMcpServer(sub.name, destPath);
|
|
570
|
-
if (setupItem)
|
|
571
|
-
pendingSetup.push(setupItem);
|
|
308
|
+
// ── Rule resource ─────────────────────────────────────────────────────
|
|
309
|
+
// Return write_file actions; the AI writes the files locally.
|
|
310
|
+
if (sub.type === 'rule') {
|
|
311
|
+
const typeDir = (0, cursor_paths_1.getCursorTypeDir)(sub.type);
|
|
312
|
+
for (const file of downloadResult.files) {
|
|
313
|
+
const normalised = path.normalize(file.path);
|
|
314
|
+
if (normalised.startsWith('..')) {
|
|
315
|
+
logger_1.logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
|
|
316
|
+
continue;
|
|
572
317
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
318
|
+
localActions.push({
|
|
319
|
+
action: 'write_file',
|
|
320
|
+
path: path.join(typeDir, normalised),
|
|
321
|
+
content: file.content,
|
|
577
322
|
});
|
|
578
|
-
continue;
|
|
579
323
|
}
|
|
324
|
+
tally.synced++;
|
|
325
|
+
details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
|
|
326
|
+
(0, logger_1.logToolStep)('sync_resources', 'Rule: write_file actions queued for AI', {
|
|
327
|
+
resourceId: sub.id,
|
|
328
|
+
fileCount: downloadResult.files.length,
|
|
329
|
+
});
|
|
330
|
+
continue;
|
|
580
331
|
}
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
await fs.mkdir(typeDir, { recursive: true });
|
|
584
|
-
// Determine write strategy based on resource type:
|
|
585
|
-
// Directory-based (skill, mcp): create <typeDir>/<name>/ and write files under it.
|
|
586
|
-
// File-based (command, rule): write each file directly into <typeDir>/ — no subdir.
|
|
587
|
-
const isDirectoryType = sub.type === 'skill' || sub.type === 'mcp';
|
|
588
|
-
const writeRoot = isDirectoryType ? destPath : typeDir;
|
|
589
|
-
if (isDirectoryType) {
|
|
590
|
-
await fs.mkdir(writeRoot, { recursive: true });
|
|
591
|
-
}
|
|
592
|
-
for (const file of downloadResult.files) {
|
|
593
|
-
// Reject path traversal attempts in file.path
|
|
594
|
-
const normalised = path.normalize(file.path);
|
|
595
|
-
if (normalised.startsWith('..')) {
|
|
596
|
-
logger_1.logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
|
|
597
|
-
continue;
|
|
598
|
-
}
|
|
599
|
-
const writePath = path.join(writeRoot, normalised);
|
|
600
|
-
await fs.mkdir(path.dirname(writePath), { recursive: true });
|
|
601
|
-
await fs.writeFile(writePath, file.content, 'utf-8');
|
|
602
|
-
}
|
|
603
|
-
// After writing local MCP files, register the server in ~/.cursor/mcp.json.
|
|
332
|
+
// ── Local-executable MCP resource (Format A — has "command" field) ───
|
|
333
|
+
// Return write_file + merge_mcp_json actions; the AI performs them locally.
|
|
604
334
|
if (sub.type === 'mcp') {
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
335
|
+
const typeDir = (0, cursor_paths_1.getCursorTypeDir)(sub.type);
|
|
336
|
+
const installDir = path.join(typeDir, sub.name);
|
|
337
|
+
// Queue file writes.
|
|
338
|
+
for (const file of downloadResult.files) {
|
|
339
|
+
const normalised = path.normalize(file.path);
|
|
340
|
+
if (normalised.startsWith('..')) {
|
|
341
|
+
logger_1.logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
localActions.push({
|
|
345
|
+
action: 'write_file',
|
|
346
|
+
path: path.join(installDir, normalised),
|
|
347
|
+
content: file.content,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
// Build the mcp.json entry from the downloaded descriptor.
|
|
351
|
+
// We replicate the Format-A detection logic from registerMcpServer()
|
|
352
|
+
// but without touching the server filesystem.
|
|
353
|
+
const mcpJsonPath = path.join((0, cursor_paths_1.getCursorRootDir)(), 'mcp.json');
|
|
354
|
+
let mcpEntry = {};
|
|
355
|
+
let missingEnv = [];
|
|
356
|
+
let setupHint;
|
|
357
|
+
let setupDoc;
|
|
358
|
+
const descriptorFile = downloadResult.files.find((f) => path.basename(f.path) === 'mcp-config.json');
|
|
359
|
+
if (descriptorFile) {
|
|
360
|
+
try {
|
|
361
|
+
const cfg = JSON.parse(descriptorFile.content);
|
|
362
|
+
if (typeof cfg['command'] === 'string') {
|
|
363
|
+
// Format A: single-server descriptor
|
|
364
|
+
mcpEntry = cfg;
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
// Format B disguised as local — treat whole object as entries map
|
|
368
|
+
mcpEntry = cfg;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch { /* malformed — leave entry empty */ }
|
|
372
|
+
}
|
|
373
|
+
// Detect missing env vars.
|
|
374
|
+
const envBlock = (mcpEntry['env'] ?? {});
|
|
375
|
+
missingEnv = Object.entries(envBlock).filter(([, v]) => v === '').map(([k]) => k);
|
|
376
|
+
if (missingEnv.length > 0) {
|
|
377
|
+
setupHint =
|
|
378
|
+
`Fill in the following environment variables in ${mcpJsonPath} ` +
|
|
379
|
+
`under mcpServers["${sub.name}"]: ${missingEnv.join(', ')}.`;
|
|
380
|
+
}
|
|
381
|
+
// Check for a setup doc among downloaded files.
|
|
382
|
+
const readmeFile = downloadResult.files.find((f) => /readme/i.test(path.basename(f.path)) && f.path.endsWith('.md'));
|
|
383
|
+
if (readmeFile) {
|
|
384
|
+
setupDoc = path.join(installDir, readmeFile.path);
|
|
385
|
+
}
|
|
386
|
+
const mergeMcpAction = {
|
|
387
|
+
action: 'merge_mcp_json',
|
|
388
|
+
mcp_json_path: mcpJsonPath,
|
|
389
|
+
server_name: sub.name,
|
|
390
|
+
entry: Object.keys(mcpEntry).length > 0 ? mcpEntry : {
|
|
391
|
+
// Fallback: auto-detect entry point from file list.
|
|
392
|
+
command: (() => {
|
|
393
|
+
const jsEntry = downloadResult.files.find((f) => f.path.endsWith('.js'));
|
|
394
|
+
const pyEntry = downloadResult.files.find((f) => f.path.endsWith('.py'));
|
|
395
|
+
if (jsEntry)
|
|
396
|
+
return 'node';
|
|
397
|
+
if (pyEntry)
|
|
398
|
+
return 'python3';
|
|
399
|
+
return 'node';
|
|
400
|
+
})(),
|
|
401
|
+
args: [(() => {
|
|
402
|
+
const jsEntry = downloadResult.files.find((f) => f.path.endsWith('.js'));
|
|
403
|
+
const pyEntry = downloadResult.files.find((f) => f.path.endsWith('.py'));
|
|
404
|
+
const entryFile = jsEntry ?? pyEntry ?? downloadResult.files[0];
|
|
405
|
+
return path.join(installDir, entryFile?.path ?? '');
|
|
406
|
+
})()],
|
|
407
|
+
},
|
|
408
|
+
...(missingEnv.length > 0 ? { missing_env: missingEnv, setup_hint: setupHint } : {}),
|
|
409
|
+
...(setupDoc ? { setup_doc: setupDoc } : {}),
|
|
410
|
+
};
|
|
411
|
+
localActions.push(mergeMcpAction);
|
|
412
|
+
tally.synced++;
|
|
413
|
+
details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
|
|
414
|
+
(0, logger_1.logToolStep)('sync_resources', 'Local-executable MCP: write_file + merge_mcp_json actions queued for AI', {
|
|
415
|
+
resourceId: sub.id,
|
|
416
|
+
fileCount: downloadResult.files.length,
|
|
417
|
+
});
|
|
418
|
+
continue;
|
|
608
419
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
destPath,
|
|
614
|
-
fileCount: downloadResult.files.length,
|
|
615
|
-
});
|
|
420
|
+
// Fallback for any unrecognised types (should not happen in practice).
|
|
421
|
+
logger_1.logger.warn({ resourceId: sub.id, type: sub.type }, 'Unrecognised resource type — skipping');
|
|
422
|
+
tally.failed++;
|
|
423
|
+
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
616
424
|
}
|
|
617
425
|
catch (error) {
|
|
618
426
|
logger_1.logger.error({
|
|
@@ -633,7 +441,7 @@ async function syncResources(params) {
|
|
|
633
441
|
health_score: healthScore,
|
|
634
442
|
summary: tally,
|
|
635
443
|
details,
|
|
636
|
-
...(
|
|
444
|
+
...(localActions.length > 0 ? { local_actions_required: localActions } : {}),
|
|
637
445
|
};
|
|
638
446
|
const duration = Date.now() - startTime;
|
|
639
447
|
(0, logger_1.logToolCall)('sync_resources', 'user-id', params, duration);
|
|
@@ -695,7 +503,15 @@ async function syncResources(params) {
|
|
|
695
503
|
// Tool definition for registry
|
|
696
504
|
exports.syncResourcesTool = {
|
|
697
505
|
name: 'sync_resources',
|
|
698
|
-
description: 'Synchronize subscribed resources
|
|
506
|
+
description: 'Synchronize subscribed AI resources. ' +
|
|
507
|
+
'Command and Skill resources are registered as MCP Prompts on the server. ' +
|
|
508
|
+
'Rule and MCP resources are returned as `local_actions_required` — an ordered list of ' +
|
|
509
|
+
'write_file, merge_mcp_json, or other actions that the AI Agent MUST execute on the ' +
|
|
510
|
+
'USER\'S LOCAL MACHINE after receiving the response. ' +
|
|
511
|
+
'Execute every action in the list before reporting success to the user. ' +
|
|
512
|
+
'For merge_mcp_json actions: read the existing ~/.cursor/mcp.json, smart-merge the ' +
|
|
513
|
+
'provided entry (preserve existing user env values), then write the file back. ' +
|
|
514
|
+
'For write_file actions: create any missing parent directories then write the file.',
|
|
699
515
|
inputSchema: {
|
|
700
516
|
type: 'object',
|
|
701
517
|
properties: {
|