@elliotding/ai-agent-mcp 0.1.15 → 0.1.18
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 +1 -1
- package/dist/prompts/manager.d.ts +8 -0
- package/dist/prompts/manager.d.ts.map +1 -1
- package/dist/prompts/manager.js +4 -0
- package/dist/prompts/manager.js.map +1 -1
- package/dist/resources/loader.d.ts +1 -0
- package/dist/resources/loader.d.ts.map +1 -1
- package/dist/resources/loader.js +51 -11
- package/dist/resources/loader.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +25 -4
- package/dist/server/http.js.map +1 -1
- package/dist/tools/manage-subscription.d.ts.map +1 -1
- package/dist/tools/manage-subscription.js +26 -9
- package/dist/tools/manage-subscription.js.map +1 -1
- package/dist/tools/sync-resources.d.ts.map +1 -1
- package/dist/tools/sync-resources.js +113 -33
- 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 +23 -18
- package/dist/tools/uninstall-resource.js.map +1 -1
- package/dist/types/tools.d.ts +16 -0
- package/dist/types/tools.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/prompts/manager.ts +5 -0
- package/src/resources/loader.ts +44 -11
- package/src/server/http.ts +26 -4
- package/src/tools/manage-subscription.ts +29 -10
- package/src/tools/sync-resources.ts +136 -33
- package/src/tools/uninstall-resource.ts +24 -18
- package/src/types/tools.ts +16 -0
package/src/server/http.ts
CHANGED
|
@@ -307,11 +307,33 @@ export class HTTPServer {
|
|
|
307
307
|
// Build the absolute message URL for the SSE endpoint event.
|
|
308
308
|
// Cursor (and other MCP clients) resolve the endpoint event data as a URL;
|
|
309
309
|
// using a relative path causes some clients to misinterpret it as a redirect.
|
|
310
|
-
//
|
|
311
|
-
//
|
|
310
|
+
//
|
|
311
|
+
// Priority order for determining the public base URL:
|
|
312
|
+
// 1. PUBLIC_URL env var — explicit override for reverse-proxy / Docker deployments
|
|
313
|
+
// where the Host header reflects the internal address (e.g. 0.0.0.0:5080)
|
|
314
|
+
// rather than the externally reachable hostname.
|
|
315
|
+
// Set this to the full external origin, e.g. https://mcp.example.com
|
|
316
|
+
// 2. X-Forwarded-Host / X-Forwarded-Proto headers — set by well-configured proxies
|
|
317
|
+
// 3. Host header from the incoming request (may be the internal address behind nginx)
|
|
318
|
+
// 4. Fallback to HTTP_HOST:HTTP_PORT from config
|
|
312
319
|
const basePath = config.http?.basePath ?? '';
|
|
313
|
-
|
|
314
|
-
|
|
320
|
+
let messageUrl: string;
|
|
321
|
+
|
|
322
|
+
const publicUrl = process.env['PUBLIC_URL'];
|
|
323
|
+
if (publicUrl) {
|
|
324
|
+
// Strip trailing slash, then append basePath + /message
|
|
325
|
+
messageUrl = `${publicUrl.replace(/\/$/, '')}${basePath}/message`;
|
|
326
|
+
} else {
|
|
327
|
+
const forwardedProto = request.headers['x-forwarded-proto'];
|
|
328
|
+
const forwardedHost = request.headers['x-forwarded-host'];
|
|
329
|
+
const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : (forwardedProto ?? 'http');
|
|
330
|
+
const host = Array.isArray(forwardedHost)
|
|
331
|
+
? forwardedHost[0]
|
|
332
|
+
: (forwardedHost ?? request.headers.host ?? `${config.http?.host ?? '127.0.0.1'}:${config.http?.port ?? 3000}`);
|
|
333
|
+
messageUrl = `${proto}://${host}${basePath}/message`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
logger.info({ messageUrl }, 'Message endpoint constructed for SSE client');
|
|
315
337
|
const transport = new SSEServerTransport(messageUrl, reply.raw);
|
|
316
338
|
const sdkSessionId = transport.sessionId;
|
|
317
339
|
this.sseTransports.set(sdkSessionId, transport);
|
|
@@ -103,6 +103,18 @@ export async function manageSubscription(params: unknown): Promise<ToolResult<Ma
|
|
|
103
103
|
|
|
104
104
|
logger.debug({ resourceIds: typedParams.resource_ids }, 'Unsubscribing from resources...');
|
|
105
105
|
|
|
106
|
+
// Build a resource_id → type map from the current subscription list so
|
|
107
|
+
// uninstall actions can be scoped precisely to rule vs mcp resources.
|
|
108
|
+
let idToType: Map<string, string> = new Map();
|
|
109
|
+
try {
|
|
110
|
+
const currentSubs = await apiClient.getSubscriptions({}, typedParams.user_token);
|
|
111
|
+
for (const s of currentSubs.subscriptions) {
|
|
112
|
+
idToType.set(s.id, s.type);
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
logger.warn({ error: (e as Error).message }, 'Could not fetch subscriptions for type resolution — uninstall will emit both rule+mcp actions as fallback');
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
// Cancel server-side subscription
|
|
107
119
|
await apiClient.unsubscribe(typedParams.resource_ids, typedParams.user_token);
|
|
108
120
|
logger.info({ count: typedParams.resource_ids.length }, 'Server-side subscriptions removed');
|
|
@@ -112,17 +124,22 @@ export async function manageSubscription(params: unknown): Promise<ToolResult<Ma
|
|
|
112
124
|
const uninstallResults: Array<{ id: string; removed: boolean; detail: string }> = [];
|
|
113
125
|
for (const resourceId of typedParams.resource_ids) {
|
|
114
126
|
// Determine if this is a Command or Skill by checking the prompt registry.
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
// API resource IDs are UUIDs (e.g. "0ccd800f..."), NOT prefixed with "cmd-"/"skill-".
|
|
128
|
+
// Check whether any registered prompt for this user matches the resource_id.
|
|
129
|
+
const matchedPromptName = promptManager.promptNames(typedParams.user_token ?? '').find(
|
|
130
|
+
(name) => {
|
|
131
|
+
// Prompt names are "<type>/<resource_name>"; check by looking up the registered meta.
|
|
132
|
+
const registered = promptManager.getByPromptName(name, typedParams.user_token ?? '');
|
|
133
|
+
return registered?.meta?.resource_id === resourceId;
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
if (matchedPromptName) {
|
|
137
|
+
const parts = matchedPromptName.split('/');
|
|
138
|
+
const resourceType = (parts[0] ?? 'command') as 'command' | 'skill';
|
|
139
|
+
const resourceName = parts.slice(1).join('/') || matchedPromptName;
|
|
140
|
+
promptManager.unregisterPrompt(resourceId, resourceType, resourceName, typedParams.user_token ?? '');
|
|
124
141
|
uninstallResults.push({ id: resourceId, removed: true, detail: `Unregistered MCP Prompt for "${resourceName}"` });
|
|
125
|
-
logger.info({ resourceId, resourceType }, 'MCP Prompt unregistered on unsubscribe');
|
|
142
|
+
logger.info({ resourceId, resourceType, matchedPromptName }, 'MCP Prompt unregistered on unsubscribe');
|
|
126
143
|
continue;
|
|
127
144
|
}
|
|
128
145
|
// Use the last segment of the resource ID as the search pattern
|
|
@@ -139,11 +156,13 @@ export async function manageSubscription(params: unknown): Promise<ToolResult<Ma
|
|
|
139
156
|
namePart,
|
|
140
157
|
]));
|
|
141
158
|
|
|
159
|
+
const resolvedType = idToType.get(resourceId) as 'rule' | 'mcp' | undefined;
|
|
142
160
|
let uninstalled = false;
|
|
143
161
|
for (const pattern of patternsToTry) {
|
|
144
162
|
const uninstallResult = await uninstallResource({
|
|
145
163
|
resource_id_or_name: pattern,
|
|
146
164
|
remove_from_account: false, // already unsubscribed above
|
|
165
|
+
...(resolvedType ? { resource_type: resolvedType } : {}),
|
|
147
166
|
});
|
|
148
167
|
if (uninstallResult.success && uninstallResult.data && uninstallResult.data.removed_resources.length > 0) {
|
|
149
168
|
uninstallResults.push({ id: resourceId, removed: true, detail: `Removed local files for "${pattern}"` });
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import * as fs from 'fs/promises';
|
|
20
20
|
import * as path from 'path';
|
|
21
|
+
import { createHash } from 'crypto';
|
|
21
22
|
import { logger, logToolCall, logToolStep, logToolResult } from '../utils/logger';
|
|
22
23
|
import { apiClient } from '../api/client';
|
|
23
24
|
import { multiSourceGitManager } from '../git/multi-source-manager';
|
|
@@ -36,6 +37,39 @@ import type {
|
|
|
36
37
|
import { telemetry } from '../telemetry/index.js';
|
|
37
38
|
import { promptManager } from '../prompts/index.js';
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Server-side in-memory download cache.
|
|
42
|
+
*
|
|
43
|
+
* Purpose: avoid redundant API download calls for resources whose content has
|
|
44
|
+
* not changed between syncs IN THE SAME SERVER SESSION.
|
|
45
|
+
*
|
|
46
|
+
* IMPORTANT — this cache ONLY skips the network download; it NEVER skips
|
|
47
|
+
* generating LocalAction instructions. Whether the user's local files are
|
|
48
|
+
* already up-to-date is determined client-side via the `content_hash` field
|
|
49
|
+
* on write_file actions and `skip_if_exists` on merge_mcp_json actions.
|
|
50
|
+
* This ensures a manual sync always re-delivers actions so the user can
|
|
51
|
+
* recover deleted local files, even when the resource content is unchanged.
|
|
52
|
+
*
|
|
53
|
+
* Key format: `${userToken}::${resourceId}`
|
|
54
|
+
* Value: the last downloadResource() response (hash + files).
|
|
55
|
+
*
|
|
56
|
+
* The cache is process-scoped and cleared on server restart.
|
|
57
|
+
*/
|
|
58
|
+
interface CachedDownload {
|
|
59
|
+
hash: string;
|
|
60
|
+
files: Array<{ path: string; content: string }>;
|
|
61
|
+
}
|
|
62
|
+
const downloadCache = new Map<string, CachedDownload>();
|
|
63
|
+
|
|
64
|
+
function syncCacheKey(userToken: string, resourceId: string): string {
|
|
65
|
+
return `${userToken}::${resourceId}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Compute SHA-256 hex digest of a UTF-8 string. */
|
|
69
|
+
function sha256(content: string): string {
|
|
70
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
39
73
|
/**
|
|
40
74
|
* Extract the `description` field from YAML frontmatter in a Markdown file.
|
|
41
75
|
* Frontmatter is delimited by leading `---` and closing `---` lines.
|
|
@@ -153,21 +187,40 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
153
187
|
// getCursorResourcePath throws for unrecognised types, caught below.
|
|
154
188
|
const destPath = getCursorResourcePath(sub.type, sub.name);
|
|
155
189
|
|
|
156
|
-
// In check mode:
|
|
190
|
+
// In check mode: report whether the resource is already available.
|
|
191
|
+
// Command/Skill: check the in-memory Prompt registry (no local files).
|
|
192
|
+
// Rule/MCP: check whether the local file / mcp.json entry exists.
|
|
157
193
|
if (mode === 'check') {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
194
|
+
if (sub.type === 'command' || sub.type === 'skill') {
|
|
195
|
+
const meta = {
|
|
196
|
+
resource_id: sub.id,
|
|
197
|
+
resource_type: sub.type as 'command' | 'skill',
|
|
198
|
+
resource_name: sub.name,
|
|
199
|
+
team: (sub as any).team ?? 'general',
|
|
200
|
+
};
|
|
201
|
+
const isRegistered = promptManager.has(promptManager.buildPromptName(meta), userToken ?? '');
|
|
202
|
+
if (isRegistered) {
|
|
203
|
+
tally.cached++;
|
|
204
|
+
details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
|
|
205
|
+
} else {
|
|
206
|
+
tally.failed++;
|
|
207
|
+
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
try {
|
|
211
|
+
await fs.access(destPath);
|
|
212
|
+
tally.cached++;
|
|
213
|
+
details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
|
|
214
|
+
logToolStep('sync_resources', 'Resource already present (check mode)', {
|
|
215
|
+
resourceId: sub.id, destPath,
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
tally.failed++;
|
|
219
|
+
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
220
|
+
logToolStep('sync_resources', 'Resource missing (check mode)', {
|
|
221
|
+
resourceId: sub.id, destPath,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
171
224
|
}
|
|
172
225
|
continue;
|
|
173
226
|
}
|
|
@@ -280,18 +333,40 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
280
333
|
continue;
|
|
281
334
|
}
|
|
282
335
|
|
|
283
|
-
// Download
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
336
|
+
// ── Download (with server-session cache) ─────────────────────────────
|
|
337
|
+
// The download cache avoids redundant API calls when the same resource
|
|
338
|
+
// is synced multiple times within one server session without any content
|
|
339
|
+
// change. It ONLY caches the network response; LocalAction generation
|
|
340
|
+
// always proceeds so that users can recover deleted local files by
|
|
341
|
+
// re-running sync — even when the resource content is unchanged.
|
|
342
|
+
const cacheKey = syncCacheKey(userToken ?? '', sub.id);
|
|
343
|
+
let downloadResult: { hash: string; files: Array<{ path: string; content: string }> };
|
|
344
|
+
|
|
345
|
+
const cached = downloadCache.get(cacheKey);
|
|
346
|
+
if (mode === 'incremental' && cached) {
|
|
347
|
+
// Reuse the previously downloaded content without hitting the API.
|
|
348
|
+
// full mode always bypasses this branch to guarantee a fresh download.
|
|
349
|
+
downloadResult = cached;
|
|
350
|
+
logToolStep('sync_resources', 'Using cached download (no API call)', {
|
|
351
|
+
resourceId: sub.id,
|
|
352
|
+
cachedHash: cached.hash,
|
|
353
|
+
});
|
|
354
|
+
} else {
|
|
355
|
+
logToolStep('sync_resources', 'Downloading resource', {
|
|
356
|
+
resourceId: sub.id,
|
|
357
|
+
resourceType: sub.type,
|
|
358
|
+
});
|
|
359
|
+
const tDl = Date.now();
|
|
360
|
+
const apiResult = await apiClient.downloadResource(sub.id, userToken);
|
|
361
|
+
logToolStep('sync_resources', 'Download complete', {
|
|
362
|
+
resourceId: sub.id,
|
|
363
|
+
fileCount: apiResult.files.length,
|
|
364
|
+
duration: Date.now() - tDl,
|
|
365
|
+
});
|
|
366
|
+
downloadResult = { hash: apiResult.hash, files: apiResult.files };
|
|
367
|
+
// Refresh cache with the latest download.
|
|
368
|
+
downloadCache.set(cacheKey, downloadResult);
|
|
369
|
+
}
|
|
295
370
|
|
|
296
371
|
// When the API returns no files (expected when the MCP server is deployed
|
|
297
372
|
// remotely and content lives in the server-side git repo), fall back to
|
|
@@ -374,7 +449,14 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
374
449
|
const normalised = path.normalize(file.path);
|
|
375
450
|
if (normalised.startsWith('..')) continue;
|
|
376
451
|
const fileDest = `${installDir}/${normalised}`;
|
|
377
|
-
|
|
452
|
+
// Carry content_hash so the AI can skip the write when the
|
|
453
|
+
// local file already has identical content.
|
|
454
|
+
localActions.push({
|
|
455
|
+
action: 'write_file',
|
|
456
|
+
path: fileDest,
|
|
457
|
+
content: file.content,
|
|
458
|
+
content_hash: sha256(file.content),
|
|
459
|
+
});
|
|
378
460
|
writeActions.push(fileDest);
|
|
379
461
|
}
|
|
380
462
|
const env = (cfg['env'] ?? {}) as Record<string, string>;
|
|
@@ -390,6 +472,9 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
390
472
|
mcp_json_path: mcpJsonPath,
|
|
391
473
|
server_name: serverName,
|
|
392
474
|
entry: { ...cfg, args },
|
|
475
|
+
// skip_if_exists: preserve user-edited env values; the entry
|
|
476
|
+
// is already configured if the key exists in mcp.json.
|
|
477
|
+
skip_if_exists: true,
|
|
393
478
|
...(missingEnv.length > 0 ? {
|
|
394
479
|
missing_env: missingEnv,
|
|
395
480
|
setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${sub.name}"]: ${missingEnv.join(', ')}.`,
|
|
@@ -421,6 +506,9 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
421
506
|
mcp_json_path: mcpJsonPath,
|
|
422
507
|
server_name: serverName,
|
|
423
508
|
entry: e,
|
|
509
|
+
// skip_if_exists: user may have customised env values; do
|
|
510
|
+
// not overwrite an existing entry on every incremental sync.
|
|
511
|
+
skip_if_exists: true,
|
|
424
512
|
...(missingEnv.length > 0 ? {
|
|
425
513
|
missing_env: missingEnv,
|
|
426
514
|
setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${serverName}"]: ${missingEnv.join(', ')}.`,
|
|
@@ -450,7 +538,12 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
450
538
|
const normalised = path.normalize(file.path);
|
|
451
539
|
if (normalised.startsWith('..')) continue;
|
|
452
540
|
const fileDest = `${installDir}/${normalised}`;
|
|
453
|
-
localActions.push({
|
|
541
|
+
localActions.push({
|
|
542
|
+
action: 'write_file',
|
|
543
|
+
path: fileDest,
|
|
544
|
+
content: file.content,
|
|
545
|
+
content_hash: sha256(file.content),
|
|
546
|
+
});
|
|
454
547
|
writeActions.push(fileDest);
|
|
455
548
|
}
|
|
456
549
|
const jsEntry = resourceFiles.find((f) => f.path.endsWith('.js'));
|
|
@@ -463,6 +556,7 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
463
556
|
mcp_json_path: mcpJsonPath,
|
|
464
557
|
server_name: sub.name,
|
|
465
558
|
entry: { command: cmd, args: [entryPath] },
|
|
559
|
+
skip_if_exists: true,
|
|
466
560
|
});
|
|
467
561
|
logger.info(
|
|
468
562
|
{
|
|
@@ -487,7 +581,8 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
487
581
|
|
|
488
582
|
// ── Rule resource ─────────────────────────────────────────────────────
|
|
489
583
|
// Return write_file actions; the AI writes the files locally.
|
|
490
|
-
//
|
|
584
|
+
// Each action carries content_hash so the AI can skip the write when
|
|
585
|
+
// the local file already has identical content.
|
|
491
586
|
if (sub.type === 'rule') {
|
|
492
587
|
const typeDir = getCursorTypeDirForClient(sub.type);
|
|
493
588
|
const writeActions: string[] = [];
|
|
@@ -503,6 +598,7 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
|
|
|
503
598
|
action: 'write_file',
|
|
504
599
|
path: destPath,
|
|
505
600
|
content: file.content,
|
|
601
|
+
content_hash: sha256(file.content),
|
|
506
602
|
});
|
|
507
603
|
writeActions.push(destPath);
|
|
508
604
|
}
|
|
@@ -638,10 +734,17 @@ export const syncResourcesTool = {
|
|
|
638
734
|
'Rule and MCP resources are returned as `local_actions_required` — an ordered list of ' +
|
|
639
735
|
'write_file, merge_mcp_json, or other actions that the AI Agent MUST execute on the ' +
|
|
640
736
|
'USER\'S LOCAL MACHINE after receiving the response. ' +
|
|
641
|
-
'Execute every action in the list before reporting success to the user. ' +
|
|
642
|
-
'For
|
|
643
|
-
'
|
|
644
|
-
'
|
|
737
|
+
'Execute every action in the list in order before reporting success to the user. ' +
|
|
738
|
+
'For write_file actions: ' +
|
|
739
|
+
' (1) If the action has a `content_hash` field, read the existing file (if any), compute its SHA-256, ' +
|
|
740
|
+
' and SKIP the write if the digests match — the file is already up-to-date. ' +
|
|
741
|
+
' (2) Otherwise create any missing parent directories then write the file. ' +
|
|
742
|
+
'For merge_mcp_json actions: ' +
|
|
743
|
+
' (1) If the action has `skip_if_exists: true` AND `mcpServers[server_name]` already exists ' +
|
|
744
|
+
' in ~/.cursor/mcp.json, SKIP this action entirely — the server is already configured ' +
|
|
745
|
+
' and any user-edited env values must not be overwritten. ' +
|
|
746
|
+
' (2) Otherwise read the existing ~/.cursor/mcp.json, smart-merge the provided entry ' +
|
|
747
|
+
' (preserve existing user env values), then write the file back.',
|
|
645
748
|
inputSchema: {
|
|
646
749
|
type: 'object' as const,
|
|
647
750
|
properties: {
|
|
@@ -87,32 +87,38 @@ export async function uninstallResource(params: unknown): Promise<ToolResult<Uni
|
|
|
87
87
|
// The MCP server may be running remotely; we must NOT touch the server's
|
|
88
88
|
// own filesystem. Instead we return delete/remove instructions so the AI
|
|
89
89
|
// Agent performs them on the user's LOCAL machine.
|
|
90
|
-
logger.debug({ pattern }, 'Building local uninstall actions for Rule/MCP resource...');
|
|
90
|
+
logger.debug({ pattern, resourceType: typedParams.resource_type }, 'Building local uninstall actions for Rule/MCP resource...');
|
|
91
91
|
|
|
92
92
|
const localActions: LocalAction[] = [];
|
|
93
93
|
// Use client-side tilde-based paths; the MCP server may be running remotely
|
|
94
94
|
// and its os.homedir() would resolve to the server's home, not the user's.
|
|
95
95
|
const mcpJsonPath = `${getCursorRootDirForClient()}/mcp.json`;
|
|
96
96
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
97
|
+
// When resource_type is provided, only emit the relevant actions.
|
|
98
|
+
// When unknown, emit both (AI skips missing files gracefully).
|
|
99
|
+
const knownType = typedParams.resource_type;
|
|
100
|
+
const isRule = !knownType || knownType === 'rule';
|
|
101
|
+
const isMcp = !knownType || knownType === 'mcp';
|
|
102
|
+
|
|
103
|
+
if (isRule) {
|
|
104
|
+
// Rule: delete ~/.cursor/rules/<pattern>.mdc and .md variants.
|
|
105
|
+
const rulesDir = getCursorTypeDirForClient('rule');
|
|
106
|
+
for (const ext of ['.mdc', '.md']) {
|
|
107
|
+
const filePath = `${rulesDir}/${pattern}${ext}`;
|
|
108
|
+
localActions.push({ action: 'delete_file', path: filePath });
|
|
109
|
+
removedResources.push({ id: pattern, name: pattern, path: filePath });
|
|
110
|
+
}
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
if (isMcp) {
|
|
114
|
+
// MCP: delete install directory (Format A — may not exist for remote-URL MCPs)
|
|
115
|
+
// and remove the mcpServers entry from mcp.json.
|
|
116
|
+
const mcpDir = getCursorTypeDirForClient('mcp');
|
|
117
|
+
const mcpInstallDir = `${mcpDir}/${pattern}`;
|
|
118
|
+
localActions.push({ action: 'delete_file', path: mcpInstallDir, recursive: true });
|
|
119
|
+
localActions.push({ action: 'remove_mcp_json_entry', mcp_json_path: mcpJsonPath, server_name: pattern });
|
|
120
|
+
removedResources.push({ id: pattern, name: pattern, path: mcpInstallDir });
|
|
121
|
+
}
|
|
116
122
|
|
|
117
123
|
if (removedResources.length === 0 && localActions.length === 0) {
|
|
118
124
|
throw createValidationError(
|
package/src/types/tools.ts
CHANGED
|
@@ -22,6 +22,13 @@ export interface WriteFileAction {
|
|
|
22
22
|
path: string;
|
|
23
23
|
/** UTF-8 file content to write. */
|
|
24
24
|
content: string;
|
|
25
|
+
/**
|
|
26
|
+
* SHA-256 hex digest of `content` (without leading "sha256:").
|
|
27
|
+
* When present, the AI MUST read the existing file at `path` (if any),
|
|
28
|
+
* compute its SHA-256, and SKIP the write if the digests match.
|
|
29
|
+
* This prevents redundant writes on incremental syncs.
|
|
30
|
+
*/
|
|
31
|
+
content_hash?: string;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export interface DeleteFileAction {
|
|
@@ -46,6 +53,13 @@ export interface MergeMcpJsonAction {
|
|
|
46
53
|
setup_hint?: string;
|
|
47
54
|
/** Path to a local setup/readme doc if one exists in the install dir. */
|
|
48
55
|
setup_doc?: string;
|
|
56
|
+
/**
|
|
57
|
+
* When true, the AI MUST skip this action if `mcpServers[server_name]`
|
|
58
|
+
* already exists in mcp.json (regardless of content).
|
|
59
|
+
* Use this for idempotent installs where re-writing would clobber
|
|
60
|
+
* user-customised values (e.g. env vars the user has already filled in).
|
|
61
|
+
*/
|
|
62
|
+
skip_if_exists?: boolean;
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
export interface RemoveMcpJsonEntryAction {
|
|
@@ -242,6 +256,8 @@ export interface UploadResourceResult {
|
|
|
242
256
|
export interface UninstallResourceParams {
|
|
243
257
|
resource_id_or_name: string;
|
|
244
258
|
remove_from_account?: boolean;
|
|
259
|
+
/** When known, the resource type — narrows which local_actions are emitted. */
|
|
260
|
+
resource_type?: 'command' | 'skill' | 'rule' | 'mcp';
|
|
245
261
|
/** CSP API token from the user's mcp.json env configuration. */
|
|
246
262
|
user_token?: string;
|
|
247
263
|
}
|