@elliotding/ai-agent-mcp 0.1.25 → 0.1.26
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/package.json +4 -1
- package/.prompt-cache/cmd-cmd-client-sdk-ai-hub-generate-testcase.md +0 -101
- package/.prompt-cache/cmd-cmd-client-sdk-ai-hub-submit_zct_job.md +0 -158
- package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-conf-status.md +0 -311
- package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-sdk-log.md +0 -64
- package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-zmb-log-errors.md +0 -84
- package/ai-resource-telemetry.json +0 -40
- package/dist/api/cached-client.d.ts +0 -48
- package/dist/api/cached-client.d.ts.map +0 -1
- package/dist/api/cached-client.js +0 -126
- package/dist/api/cached-client.js.map +0 -1
- package/dist/api/client.d.ts +0 -281
- package/dist/api/client.d.ts.map +0 -1
- package/dist/api/client.js +0 -371
- package/dist/api/client.js.map +0 -1
- package/dist/auth/index.d.ts +0 -8
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -26
- package/dist/auth/index.js.map +0 -1
- package/dist/auth/middleware.d.ts +0 -36
- package/dist/auth/middleware.d.ts.map +0 -1
- package/dist/auth/middleware.js +0 -194
- package/dist/auth/middleware.js.map +0 -1
- package/dist/auth/permissions.d.ts +0 -60
- package/dist/auth/permissions.d.ts.map +0 -1
- package/dist/auth/permissions.js +0 -262
- package/dist/auth/permissions.js.map +0 -1
- package/dist/auth/token-validator.d.ts +0 -52
- package/dist/auth/token-validator.d.ts.map +0 -1
- package/dist/auth/token-validator.js +0 -215
- package/dist/auth/token-validator.js.map +0 -1
- package/dist/cache/cache-manager.d.ts +0 -49
- package/dist/cache/cache-manager.d.ts.map +0 -1
- package/dist/cache/cache-manager.js +0 -191
- package/dist/cache/cache-manager.js.map +0 -1
- package/dist/cache/index.d.ts +0 -6
- package/dist/cache/index.d.ts.map +0 -1
- package/dist/cache/index.js +0 -12
- package/dist/cache/index.js.map +0 -1
- package/dist/cache/redis-client.d.ts +0 -45
- package/dist/cache/redis-client.d.ts.map +0 -1
- package/dist/cache/redis-client.js +0 -210
- package/dist/cache/redis-client.js.map +0 -1
- package/dist/config/constants.d.ts +0 -28
- package/dist/config/constants.d.ts.map +0 -1
- package/dist/config/constants.js +0 -31
- package/dist/config/constants.js.map +0 -1
- package/dist/config/index.d.ts +0 -71
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js +0 -190
- package/dist/config/index.js.map +0 -1
- package/dist/filesystem/manager.d.ts +0 -45
- package/dist/filesystem/manager.d.ts.map +0 -1
- package/dist/filesystem/manager.js +0 -246
- package/dist/filesystem/manager.js.map +0 -1
- package/dist/git/multi-source-manager.d.ts +0 -78
- package/dist/git/multi-source-manager.d.ts.map +0 -1
- package/dist/git/multi-source-manager.js +0 -577
- package/dist/git/multi-source-manager.js.map +0 -1
- package/dist/git/operations.d.ts +0 -27
- package/dist/git/operations.d.ts.map +0 -1
- package/dist/git/operations.js +0 -83
- package/dist/git/operations.js.map +0 -1
- package/dist/index.d.ts +0 -6
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -122
- package/dist/index.js.map +0 -1
- package/dist/monitoring/health.d.ts +0 -35
- package/dist/monitoring/health.d.ts.map +0 -1
- package/dist/monitoring/health.js +0 -105
- package/dist/monitoring/health.js.map +0 -1
- package/dist/prompts/cache.d.ts +0 -69
- package/dist/prompts/cache.d.ts.map +0 -1
- package/dist/prompts/cache.js +0 -163
- package/dist/prompts/cache.js.map +0 -1
- package/dist/prompts/generator.d.ts +0 -49
- package/dist/prompts/generator.d.ts.map +0 -1
- package/dist/prompts/generator.js +0 -160
- package/dist/prompts/generator.js.map +0 -1
- package/dist/prompts/index.d.ts +0 -13
- package/dist/prompts/index.d.ts.map +0 -1
- package/dist/prompts/index.js +0 -24
- package/dist/prompts/index.js.map +0 -1
- package/dist/prompts/manager.d.ts +0 -207
- package/dist/prompts/manager.d.ts.map +0 -1
- package/dist/prompts/manager.js +0 -566
- package/dist/prompts/manager.js.map +0 -1
- package/dist/resources/index.d.ts +0 -6
- package/dist/resources/index.d.ts.map +0 -1
- package/dist/resources/index.js +0 -10
- package/dist/resources/index.js.map +0 -1
- package/dist/resources/loader.d.ts +0 -88
- package/dist/resources/loader.d.ts.map +0 -1
- package/dist/resources/loader.js +0 -492
- package/dist/resources/loader.js.map +0 -1
- package/dist/server/http.d.ts +0 -57
- package/dist/server/http.d.ts.map +0 -1
- package/dist/server/http.js +0 -435
- package/dist/server/http.js.map +0 -1
- package/dist/server.d.ts +0 -13
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -201
- package/dist/server.js.map +0 -1
- package/dist/session/manager.d.ts +0 -91
- package/dist/session/manager.d.ts.map +0 -1
- package/dist/session/manager.js +0 -251
- package/dist/session/manager.js.map +0 -1
- package/dist/telemetry/index.d.ts +0 -3
- package/dist/telemetry/index.d.ts.map +0 -1
- package/dist/telemetry/index.js +0 -7
- package/dist/telemetry/index.js.map +0 -1
- package/dist/telemetry/manager.d.ts +0 -151
- package/dist/telemetry/manager.d.ts.map +0 -1
- package/dist/telemetry/manager.js +0 -367
- package/dist/telemetry/manager.js.map +0 -1
- package/dist/tools/index.d.ts +0 -13
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js +0 -29
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/manage-subscription.d.ts +0 -47
- package/dist/tools/manage-subscription.d.ts.map +0 -1
- package/dist/tools/manage-subscription.js +0 -317
- package/dist/tools/manage-subscription.js.map +0 -1
- package/dist/tools/registry.d.ts +0 -40
- package/dist/tools/registry.d.ts.map +0 -1
- package/dist/tools/registry.js +0 -85
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/resolve-prompt-content.d.ts +0 -35
- package/dist/tools/resolve-prompt-content.d.ts.map +0 -1
- package/dist/tools/resolve-prompt-content.js +0 -99
- package/dist/tools/resolve-prompt-content.js.map +0 -1
- package/dist/tools/search-resources.d.ts +0 -35
- package/dist/tools/search-resources.d.ts.map +0 -1
- package/dist/tools/search-resources.js +0 -159
- package/dist/tools/search-resources.js.map +0 -1
- package/dist/tools/sync-resources.d.ts +0 -54
- package/dist/tools/sync-resources.d.ts.map +0 -1
- package/dist/tools/sync-resources.js +0 -735
- package/dist/tools/sync-resources.js.map +0 -1
- package/dist/tools/track-usage.d.ts +0 -63
- package/dist/tools/track-usage.d.ts.map +0 -1
- package/dist/tools/track-usage.js +0 -90
- package/dist/tools/track-usage.js.map +0 -1
- package/dist/tools/uninstall-resource.d.ts +0 -30
- package/dist/tools/uninstall-resource.d.ts.map +0 -1
- package/dist/tools/uninstall-resource.js +0 -174
- package/dist/tools/uninstall-resource.js.map +0 -1
- package/dist/tools/upload-resource.d.ts +0 -81
- package/dist/tools/upload-resource.d.ts.map +0 -1
- package/dist/tools/upload-resource.js +0 -393
- package/dist/tools/upload-resource.js.map +0 -1
- package/dist/transport/sse.d.ts +0 -29
- package/dist/transport/sse.d.ts.map +0 -1
- package/dist/transport/sse.js +0 -271
- package/dist/transport/sse.js.map +0 -1
- package/dist/types/errors.d.ts +0 -60
- package/dist/types/errors.d.ts.map +0 -1
- package/dist/types/errors.js +0 -112
- package/dist/types/errors.js.map +0 -1
- package/dist/types/index.d.ts +0 -7
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -23
- package/dist/types/index.js.map +0 -1
- package/dist/types/mcp.d.ts +0 -50
- package/dist/types/mcp.d.ts.map +0 -1
- package/dist/types/mcp.js +0 -6
- package/dist/types/mcp.js.map +0 -1
- package/dist/types/resources.d.ts +0 -109
- package/dist/types/resources.d.ts.map +0 -1
- package/dist/types/resources.js +0 -7
- package/dist/types/resources.js.map +0 -1
- package/dist/types/tools.d.ts +0 -253
- package/dist/types/tools.d.ts.map +0 -1
- package/dist/types/tools.js +0 -6
- package/dist/types/tools.js.map +0 -1
- package/dist/utils/cursor-paths.d.ts +0 -84
- package/dist/utils/cursor-paths.d.ts.map +0 -1
- package/dist/utils/cursor-paths.js +0 -166
- package/dist/utils/cursor-paths.js.map +0 -1
- package/dist/utils/log-cleaner.d.ts +0 -18
- package/dist/utils/log-cleaner.d.ts.map +0 -1
- package/dist/utils/log-cleaner.js +0 -112
- package/dist/utils/log-cleaner.js.map +0 -1
- package/dist/utils/logger.d.ts +0 -59
- package/dist/utils/logger.d.ts.map +0 -1
- package/dist/utils/logger.js +0 -292
- package/dist/utils/logger.js.map +0 -1
- package/dist/utils/validation.d.ts +0 -58
- package/dist/utils/validation.d.ts.map +0 -1
- package/dist/utils/validation.js +0 -214
- package/dist/utils/validation.js.map +0 -1
- package/src/api/cached-client.ts +0 -144
- package/src/api/client.ts +0 -697
- package/src/auth/index.ts +0 -11
- package/src/auth/middleware.ts +0 -244
- package/src/auth/permissions.ts +0 -323
- package/src/auth/token-validator.ts +0 -292
- package/src/cache/cache-manager.ts +0 -243
- package/src/cache/index.ts +0 -6
- package/src/cache/redis-client.ts +0 -249
- package/src/config/constants.ts +0 -33
- package/src/config/index.ts +0 -269
- package/src/filesystem/manager.ts +0 -235
- package/src/git/multi-source-manager.ts +0 -654
- package/src/git/operations.ts +0 -93
- package/src/index.ts +0 -157
- package/src/monitoring/health.ts +0 -132
- package/src/prompts/cache.ts +0 -140
- package/src/prompts/generator.ts +0 -143
- package/src/prompts/index.ts +0 -20
- package/src/prompts/manager.ts +0 -718
- package/src/resources/index.ts +0 -13
- package/src/resources/loader.ts +0 -563
- package/src/server/http.ts +0 -549
- package/src/server.ts +0 -206
- package/src/session/manager.ts +0 -296
- package/src/telemetry/index.ts +0 -10
- package/src/telemetry/manager.ts +0 -419
- package/src/tools/index.ts +0 -13
- package/src/tools/manage-subscription.ts +0 -388
- package/src/tools/registry.ts +0 -97
- package/src/tools/resolve-prompt-content.ts +0 -113
- package/src/tools/search-resources.ts +0 -185
- package/src/tools/sync-resources.ts +0 -829
- package/src/tools/track-usage.ts +0 -113
- package/src/tools/uninstall-resource.ts +0 -199
- package/src/tools/upload-resource.ts +0 -431
- package/src/transport/sse.ts +0 -308
- package/src/types/errors.ts +0 -146
- package/src/types/index.ts +0 -7
- package/src/types/mcp.ts +0 -61
- package/src/types/resources.ts +0 -141
- package/src/types/tools.ts +0 -305
- package/src/utils/cursor-paths.ts +0 -135
- package/src/utils/log-cleaner.ts +0 -92
- package/src/utils/logger.ts +0 -333
- package/src/utils/validation.ts +0 -262
|
@@ -1,829 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* sync_resources Tool
|
|
3
|
-
*
|
|
4
|
-
* Synchronises the user's subscribed AI resources.
|
|
5
|
-
*
|
|
6
|
-
* Resource delivery strategy (v1.5):
|
|
7
|
-
* - Command / Skill : registered as MCP Prompts (NOT written to local filesystem).
|
|
8
|
-
* Content is generated into .prompt-cache/ and registered via PromptManager.
|
|
9
|
-
* - Rule : downloaded to ~/.cursor/rules/ (Cursor engine requires local files).
|
|
10
|
-
* - MCP : downloaded to ~/.cursor/mcp-servers/ and registered in mcp.json.
|
|
11
|
-
*
|
|
12
|
-
* Flow:
|
|
13
|
-
* 1. Fetch subscription list from CSP server (REST API).
|
|
14
|
-
* 2. (non-check) Trigger Git sync on server side via multiSourceGitManager.
|
|
15
|
-
* 3. For each subscription: handle per type as above.
|
|
16
|
-
* 4. Update telemetry: subscribed_rules + configured_mcps lists.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import * as fs from 'fs/promises';
|
|
20
|
-
import * as path from 'path';
|
|
21
|
-
import { logger, logToolCall, logToolStep, logToolResult } from '../utils/logger';
|
|
22
|
-
import { apiClient } from '../api/client';
|
|
23
|
-
import { multiSourceGitManager } from '../git/multi-source-manager';
|
|
24
|
-
import {
|
|
25
|
-
getCursorResourcePath,
|
|
26
|
-
getCursorTypeDirForClient,
|
|
27
|
-
getCursorRootDirForClient,
|
|
28
|
-
} from '../utils/cursor-paths';
|
|
29
|
-
import { MCPServerError } from '../types/errors';
|
|
30
|
-
import type {
|
|
31
|
-
SyncResourcesParams,
|
|
32
|
-
SyncResourcesResult,
|
|
33
|
-
LocalAction,
|
|
34
|
-
ToolResult,
|
|
35
|
-
} from '../types/tools';
|
|
36
|
-
import { telemetry } from '../telemetry/index.js';
|
|
37
|
-
import { promptManager } from '../prompts/index.js';
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Server-side in-memory download cache.
|
|
41
|
-
*
|
|
42
|
-
* Purpose: avoid redundant API download calls for resources whose content has
|
|
43
|
-
* not changed between syncs IN THE SAME SERVER SESSION.
|
|
44
|
-
*
|
|
45
|
-
* IMPORTANT — this cache ONLY skips the network download; it NEVER skips
|
|
46
|
-
* generating LocalAction instructions. Whether the user's local files are
|
|
47
|
-
* already up-to-date is determined client-side by direct content comparison
|
|
48
|
-
* (string equality check) in write_file actions and `skip_if_exists` checks
|
|
49
|
-
* on merge_mcp_json actions. This ensures a manual sync always re-delivers
|
|
50
|
-
* actions so the user can recover deleted local files, even when the resource
|
|
51
|
-
* 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
|
-
/**
|
|
69
|
-
* Extract the `description` field from YAML frontmatter in a Markdown file.
|
|
70
|
-
* Frontmatter is delimited by leading `---` and closing `---` lines.
|
|
71
|
-
* Returns undefined if no frontmatter or no description key is found.
|
|
72
|
-
*/
|
|
73
|
-
function extractFrontmatterDescription(content: string): string | undefined {
|
|
74
|
-
if (!content.startsWith('---')) return undefined;
|
|
75
|
-
const end = content.indexOf('\n---', 3);
|
|
76
|
-
if (end === -1) return undefined;
|
|
77
|
-
const frontmatter = content.slice(3, end);
|
|
78
|
-
for (const line of frontmatter.split('\n')) {
|
|
79
|
-
const match = /^description:\s*(.+)$/.exec(line.trim());
|
|
80
|
-
if (match) return match[1]!.trim().replace(/^['"]|['"]$/g, '');
|
|
81
|
-
}
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
export async function syncResources(params: unknown): Promise<ToolResult<SyncResourcesResult>> {
|
|
87
|
-
const startTime = Date.now();
|
|
88
|
-
|
|
89
|
-
const typedParams = params as SyncResourcesParams;
|
|
90
|
-
|
|
91
|
-
logger.info({
|
|
92
|
-
tool: 'sync_resources',
|
|
93
|
-
params: typedParams,
|
|
94
|
-
timestamp: new Date().toISOString()
|
|
95
|
-
}, 'sync_resources tool invoked');
|
|
96
|
-
|
|
97
|
-
logToolStep('sync_resources', 'Tool invocation started', { params: typedParams });
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
const mode = typedParams.mode || 'incremental';
|
|
101
|
-
const scope = typedParams.scope || 'global';
|
|
102
|
-
const types = typedParams.types;
|
|
103
|
-
const userToken = typedParams.user_token;
|
|
104
|
-
const configuredMcpServers = new Set(typedParams.configured_mcp_servers || []);
|
|
105
|
-
|
|
106
|
-
logToolStep('sync_resources', 'Parameters validated', {
|
|
107
|
-
mode,
|
|
108
|
-
scope,
|
|
109
|
-
types,
|
|
110
|
-
configuredMcpCount: configuredMcpServers.size,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ── Step 1: Fetch subscription list ────────────────────────────────────
|
|
114
|
-
logToolStep('sync_resources', 'Step 1: Fetching subscriptions from API', { scope, types });
|
|
115
|
-
const t1 = Date.now();
|
|
116
|
-
|
|
117
|
-
const subscriptions = await apiClient.getSubscriptions({ types }, userToken);
|
|
118
|
-
|
|
119
|
-
logToolStep('sync_resources', 'Subscriptions fetched', {
|
|
120
|
-
total: subscriptions.total,
|
|
121
|
-
duration: Date.now() - t1,
|
|
122
|
-
ids: subscriptions.subscriptions.map(s => s.id),
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// ── Step 2: Server-side Git sync (skip in check mode) ──────────────────
|
|
126
|
-
logToolStep('sync_resources', 'Step 2: Server-side Git sync');
|
|
127
|
-
|
|
128
|
-
if (mode === 'check') {
|
|
129
|
-
const statuses = await multiSourceGitManager.checkAllSources();
|
|
130
|
-
logToolStep('sync_resources', 'Repository status check completed', {
|
|
131
|
-
sources: statuses.map(s => ({ name: s.source, exists: s.exists, hasRemote: s.hasRemote })),
|
|
132
|
-
});
|
|
133
|
-
} else {
|
|
134
|
-
const t2 = Date.now();
|
|
135
|
-
const gitResults = await multiSourceGitManager.syncAllSources();
|
|
136
|
-
logToolStep('sync_resources', 'Server-side Git sync completed', {
|
|
137
|
-
duration: Date.now() - t2,
|
|
138
|
-
summary: {
|
|
139
|
-
cloned: gitResults.filter(r => r.action === 'cloned').length,
|
|
140
|
-
pulled: gitResults.filter(r => r.action === 'pulled').length,
|
|
141
|
-
upToDate: gitResults.filter(r => r.action === 'up-to-date').length,
|
|
142
|
-
skipped: gitResults.filter(r => r.action === 'skipped').length,
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ── Step 3: Download each subscribed resource ──────────────────────────
|
|
148
|
-
// Command / Skill → registered as MCP Prompts on the server (no local I/O)
|
|
149
|
-
// Rule / MCP → file content is returned as LocalAction instructions
|
|
150
|
-
// so that the AI Agent executes the writes on the user's
|
|
151
|
-
// LOCAL machine (not on this potentially remote server).
|
|
152
|
-
logToolStep('sync_resources', 'Step 3: Processing subscribed resources', {
|
|
153
|
-
count: subscriptions.total,
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
const tally = { total: subscriptions.total, synced: 0, cached: 0, failed: 0 };
|
|
157
|
-
|
|
158
|
-
const details: Array<{
|
|
159
|
-
id: string;
|
|
160
|
-
name: string;
|
|
161
|
-
action: 'synced' | 'cached' | 'failed';
|
|
162
|
-
version: string;
|
|
163
|
-
}> = [];
|
|
164
|
-
|
|
165
|
-
// Accumulated local file-system actions the AI must perform on the user's machine.
|
|
166
|
-
const localActions: LocalAction[] = [];
|
|
167
|
-
|
|
168
|
-
// Track which prompt names are expected from the current subscription list.
|
|
169
|
-
// After the loop, any prompt registered in PromptManager but NOT in this set
|
|
170
|
-
// is stale (from a previous connection / subscription change) and will be pruned.
|
|
171
|
-
const expectedPromptNames = new Set<string>();
|
|
172
|
-
|
|
173
|
-
for (let i = 0; i < subscriptions.subscriptions.length; i++) {
|
|
174
|
-
const sub = subscriptions.subscriptions[i];
|
|
175
|
-
if (!sub) continue;
|
|
176
|
-
|
|
177
|
-
// Safe access — `resource` metadata is only present when detail=true was requested.
|
|
178
|
-
const resourceVersion = sub.resource?.version ?? 'unknown';
|
|
179
|
-
|
|
180
|
-
logToolStep('sync_resources', `Processing ${i + 1}/${tally.total}`, {
|
|
181
|
-
resourceId: sub.id,
|
|
182
|
-
resourceName: sub.name,
|
|
183
|
-
resourceType: sub.type,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
try {
|
|
187
|
-
// Resolve the destination path inside the Cursor directory.
|
|
188
|
-
// getCursorResourcePath throws for unrecognised types, caught below.
|
|
189
|
-
const destPath = getCursorResourcePath(sub.type, sub.name);
|
|
190
|
-
|
|
191
|
-
// In check mode: report whether the resource is already available.
|
|
192
|
-
// Command/Skill: check the in-memory Prompt registry (no local files).
|
|
193
|
-
// Rule/MCP: check whether the local file / mcp.json entry exists.
|
|
194
|
-
if (mode === 'check') {
|
|
195
|
-
if (sub.type === 'command' || sub.type === 'skill') {
|
|
196
|
-
const meta = {
|
|
197
|
-
resource_id: sub.id,
|
|
198
|
-
resource_type: sub.type as 'command' | 'skill',
|
|
199
|
-
resource_name: sub.name,
|
|
200
|
-
team: (sub as any).team ?? 'general',
|
|
201
|
-
};
|
|
202
|
-
const isRegistered = promptManager.has(promptManager.buildPromptName(meta), userToken ?? '');
|
|
203
|
-
if (isRegistered) {
|
|
204
|
-
tally.cached++;
|
|
205
|
-
details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
|
|
206
|
-
} else {
|
|
207
|
-
tally.failed++;
|
|
208
|
-
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
209
|
-
}
|
|
210
|
-
} else {
|
|
211
|
-
try {
|
|
212
|
-
await fs.access(destPath);
|
|
213
|
-
tally.cached++;
|
|
214
|
-
details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
|
|
215
|
-
logToolStep('sync_resources', 'Resource already present (check mode)', {
|
|
216
|
-
resourceId: sub.id, destPath,
|
|
217
|
-
});
|
|
218
|
-
} catch {
|
|
219
|
-
tally.failed++;
|
|
220
|
-
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
221
|
-
logToolStep('sync_resources', 'Resource missing (check mode)', {
|
|
222
|
-
resourceId: sub.id, destPath,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ── Command / Skill: MCP Prompt mode (no local file write) ──────────
|
|
230
|
-
// Download content → generate intermediate cache → register as MCP Prompt.
|
|
231
|
-
if (sub.type === 'command' || sub.type === 'skill') {
|
|
232
|
-
logToolStep('sync_resources', `Registering ${sub.type} as MCP Prompt`, {
|
|
233
|
-
resourceId: sub.id,
|
|
234
|
-
resourceName: sub.name,
|
|
235
|
-
});
|
|
236
|
-
try {
|
|
237
|
-
const tDl = Date.now();
|
|
238
|
-
const downloadResult = await apiClient.downloadResource(sub.id, userToken);
|
|
239
|
-
logToolStep('sync_resources', 'Download complete (Prompt mode)', {
|
|
240
|
-
resourceId: sub.id,
|
|
241
|
-
fileCount: downloadResult.files.length,
|
|
242
|
-
duration: Date.now() - tDl,
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// When the API returns no files (expected for Command/Skill in MCP Prompt
|
|
246
|
-
// mode — content lives in the server-side git repo, not the API), fall back
|
|
247
|
-
// to reading the files directly from the local git checkout.
|
|
248
|
-
let sourceFiles = downloadResult.files;
|
|
249
|
-
if (sourceFiles.length === 0) {
|
|
250
|
-
sourceFiles = await multiSourceGitManager.readResourceFiles(
|
|
251
|
-
sub.name,
|
|
252
|
-
sub.type as 'command' | 'skill',
|
|
253
|
-
);
|
|
254
|
-
if (sourceFiles.length > 0) {
|
|
255
|
-
logToolStep('sync_resources', 'Loaded resource files from local git checkout', {
|
|
256
|
-
resourceId: sub.id,
|
|
257
|
-
fileCount: sourceFiles.length,
|
|
258
|
-
});
|
|
259
|
-
} else {
|
|
260
|
-
logger.warn(
|
|
261
|
-
{ resourceId: sub.id, resourceName: sub.name },
|
|
262
|
-
'No files found via API or local git — prompt will have empty content',
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Primary Markdown content selection:
|
|
268
|
-
// - skill: prefer SKILL.md (canonical entrypoint for all skill content)
|
|
269
|
-
// - command: prefer the file whose name matches the resource name
|
|
270
|
-
// - fallback: first .md file, then first file of any type
|
|
271
|
-
const isSkill = sub.type === 'skill';
|
|
272
|
-
const primaryFile = isSkill
|
|
273
|
-
? (sourceFiles.find((f) => path.basename(f.path) === 'SKILL.md') ??
|
|
274
|
-
sourceFiles.find((f) => f.path.endsWith('.md')) ??
|
|
275
|
-
sourceFiles[0])
|
|
276
|
-
: (sourceFiles.find((f) => path.basename(f.path).replace(/\.md$/, '') === sub.name) ??
|
|
277
|
-
sourceFiles.find((f) => f.path.endsWith('.md')) ??
|
|
278
|
-
sourceFiles[0]);
|
|
279
|
-
|
|
280
|
-
const rawContent = primaryFile?.content ?? '';
|
|
281
|
-
|
|
282
|
-
// Extract description from frontmatter (---\ndescription: ...\n---)
|
|
283
|
-
// falling back to the subscription's description field or resource name.
|
|
284
|
-
const frontmatterDesc = extractFrontmatterDescription(rawContent);
|
|
285
|
-
const description =
|
|
286
|
-
frontmatterDesc ??
|
|
287
|
-
(sub as any).description ??
|
|
288
|
-
sub.name;
|
|
289
|
-
|
|
290
|
-
const meta = {
|
|
291
|
-
resource_id: sub.id,
|
|
292
|
-
resource_type: sub.type as 'command' | 'skill',
|
|
293
|
-
resource_name: sub.name,
|
|
294
|
-
team: (sub as any).team ?? 'general',
|
|
295
|
-
description,
|
|
296
|
-
rawContent,
|
|
297
|
-
};
|
|
298
|
-
// userToken is required so the prompt is scoped to this user's namespace.
|
|
299
|
-
const effectiveToken = userToken ?? '';
|
|
300
|
-
await promptManager.registerPrompt(meta, effectiveToken);
|
|
301
|
-
|
|
302
|
-
// Track this prompt name so stale prompts can be pruned after the loop.
|
|
303
|
-
expectedPromptNames.add(promptManager.buildPromptName(meta));
|
|
304
|
-
|
|
305
|
-
// Clean up any legacy local files that may have been written by an
|
|
306
|
-
// older version of sync_resources. Command/Skill resources are now
|
|
307
|
-
// served exclusively as MCP Prompts; stale local files would cause
|
|
308
|
-
// the AI to read outdated content (without the track_usage header).
|
|
309
|
-
try {
|
|
310
|
-
const legacyPath = getCursorResourcePath(sub.type, `${sub.name}.md`);
|
|
311
|
-
await fs.unlink(legacyPath);
|
|
312
|
-
logger.info(
|
|
313
|
-
{ resourceId: sub.id, legacyPath },
|
|
314
|
-
'Removed legacy local file for Command/Skill resource',
|
|
315
|
-
);
|
|
316
|
-
} catch {
|
|
317
|
-
// File didn't exist — nothing to clean up.
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
tally.synced++;
|
|
321
|
-
details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
|
|
322
|
-
logToolStep('sync_resources', `${sub.type} registered as MCP Prompt`, {
|
|
323
|
-
resourceId: sub.id,
|
|
324
|
-
promptCount: promptManager.sizeFor(userToken ?? ''),
|
|
325
|
-
});
|
|
326
|
-
} catch (promptErr) {
|
|
327
|
-
logger.error(
|
|
328
|
-
{ resourceId: sub.id, error: (promptErr as Error).message },
|
|
329
|
-
'Failed to register Command/Skill as MCP Prompt',
|
|
330
|
-
);
|
|
331
|
-
tally.failed++;
|
|
332
|
-
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
333
|
-
}
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// ── Download (with server-session cache) ─────────────────────────────
|
|
338
|
-
// The download cache avoids redundant API calls when the same resource
|
|
339
|
-
// is synced multiple times within one server session without any content
|
|
340
|
-
// change. It ONLY caches the network response; LocalAction generation
|
|
341
|
-
// always proceeds so that users can recover deleted local files by
|
|
342
|
-
// re-running sync — even when the resource content is unchanged.
|
|
343
|
-
const cacheKey = syncCacheKey(userToken ?? '', sub.id);
|
|
344
|
-
let downloadResult: { hash: string; files: Array<{ path: string; content: string }> };
|
|
345
|
-
|
|
346
|
-
const cached = downloadCache.get(cacheKey);
|
|
347
|
-
if (mode === 'incremental' && cached) {
|
|
348
|
-
// Reuse the previously downloaded content without hitting the API.
|
|
349
|
-
// full mode always bypasses this branch to guarantee a fresh download.
|
|
350
|
-
downloadResult = cached;
|
|
351
|
-
logToolStep('sync_resources', 'Using cached download (no API call)', {
|
|
352
|
-
resourceId: sub.id,
|
|
353
|
-
cachedHash: cached.hash,
|
|
354
|
-
});
|
|
355
|
-
} else {
|
|
356
|
-
logToolStep('sync_resources', 'Downloading resource', {
|
|
357
|
-
resourceId: sub.id,
|
|
358
|
-
resourceType: sub.type,
|
|
359
|
-
});
|
|
360
|
-
const tDl = Date.now();
|
|
361
|
-
const apiResult = await apiClient.downloadResource(sub.id, userToken);
|
|
362
|
-
logToolStep('sync_resources', 'Download complete', {
|
|
363
|
-
resourceId: sub.id,
|
|
364
|
-
fileCount: apiResult.files.length,
|
|
365
|
-
duration: Date.now() - tDl,
|
|
366
|
-
});
|
|
367
|
-
downloadResult = { hash: apiResult.hash, files: apiResult.files };
|
|
368
|
-
// Refresh cache with the latest download.
|
|
369
|
-
downloadCache.set(cacheKey, downloadResult);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// When the API returns no files (expected when the MCP server is deployed
|
|
373
|
-
// remotely and content lives in the server-side git repo), fall back to
|
|
374
|
-
// reading the files directly from the local git checkout.
|
|
375
|
-
let resourceFiles = downloadResult.files;
|
|
376
|
-
if (resourceFiles.length === 0) {
|
|
377
|
-
logger.info(
|
|
378
|
-
{ resourceId: sub.id, resourceName: sub.name, type: sub.type },
|
|
379
|
-
'sync_resources: API returned no files — triggering git-checkout fallback',
|
|
380
|
-
);
|
|
381
|
-
const gitType = sub.type as 'command' | 'skill' | 'rule' | 'mcp';
|
|
382
|
-
const gitFiles = await multiSourceGitManager.readResourceFiles(sub.name, gitType);
|
|
383
|
-
if (gitFiles.length > 0) {
|
|
384
|
-
resourceFiles = gitFiles;
|
|
385
|
-
logger.info(
|
|
386
|
-
{
|
|
387
|
-
resourceId: sub.id,
|
|
388
|
-
resourceName: sub.name,
|
|
389
|
-
type: sub.type,
|
|
390
|
-
fileCount: resourceFiles.length,
|
|
391
|
-
files: resourceFiles.map((f) => f.path),
|
|
392
|
-
},
|
|
393
|
-
'sync_resources: git-checkout fallback succeeded',
|
|
394
|
-
);
|
|
395
|
-
logToolStep('sync_resources', 'Loaded resource files from local git checkout', {
|
|
396
|
-
resourceId: sub.id,
|
|
397
|
-
fileCount: resourceFiles.length,
|
|
398
|
-
});
|
|
399
|
-
} else {
|
|
400
|
-
logger.warn(
|
|
401
|
-
{ resourceId: sub.id, resourceName: sub.name, type: sub.type },
|
|
402
|
-
'sync_resources: git-checkout fallback found no files — marking resource failed',
|
|
403
|
-
);
|
|
404
|
-
tally.failed++;
|
|
405
|
-
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// ── MCP resource ──────────────────────────────────────────────────────
|
|
411
|
-
// Read mcp-config.json to determine Format A (local executable, has
|
|
412
|
-
// "command" field) vs Format B (remote URL map, no "command" field).
|
|
413
|
-
//
|
|
414
|
-
// IMPORTANT: all paths in LocalAction instructions must use the CLIENT-side
|
|
415
|
-
// helper (tilde-based) so they resolve correctly on the user's machine,
|
|
416
|
-
// not on this (possibly remote Linux) server.
|
|
417
|
-
if (sub.type === 'mcp') {
|
|
418
|
-
const mcpConfigFile = resourceFiles.find(
|
|
419
|
-
(f) => path.basename(f.path) === 'mcp-config.json',
|
|
420
|
-
);
|
|
421
|
-
// ~/.cursor/mcp.json on the user's machine
|
|
422
|
-
const mcpJsonPath = `${getCursorRootDirForClient()}/mcp.json`;
|
|
423
|
-
|
|
424
|
-
// ── Optimization: skip if already configured (incremental mode only) ────
|
|
425
|
-
// In incremental mode, if the AI Agent reports this MCP server is already
|
|
426
|
-
// in ~/.cursor/mcp.json, skip downloading and generating write_file actions.
|
|
427
|
-
// This reduces API calls, network traffic, and AI Agent execution overhead.
|
|
428
|
-
// In full mode, always proceed to allow file recovery.
|
|
429
|
-
if (mode === 'incremental' && mcpConfigFile) {
|
|
430
|
-
let cfg: Record<string, unknown> = {};
|
|
431
|
-
try { cfg = JSON.parse(mcpConfigFile.content) as Record<string, unknown>; }
|
|
432
|
-
catch { /* ignore parse errors, proceed to download */ }
|
|
433
|
-
|
|
434
|
-
// Format A: check if the single server is configured
|
|
435
|
-
if (typeof cfg['command'] === 'string') {
|
|
436
|
-
const serverName = (cfg['name'] as string | undefined) ?? sub.name;
|
|
437
|
-
if (configuredMcpServers.has(serverName)) {
|
|
438
|
-
logger.info(
|
|
439
|
-
{ resourceId: sub.id, resourceName: sub.name, serverName },
|
|
440
|
-
'sync_resources: MCP server already configured — skipping download',
|
|
441
|
-
);
|
|
442
|
-
tally.cached++;
|
|
443
|
-
details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
// Format B: check if all servers in the map are configured
|
|
448
|
-
else if (Object.keys(cfg).length > 0) {
|
|
449
|
-
const allConfigured = Object.keys(cfg).every((k) => configuredMcpServers.has(k));
|
|
450
|
-
if (allConfigured) {
|
|
451
|
-
logger.info(
|
|
452
|
-
{ resourceId: sub.id, resourceName: sub.name, serverKeys: Object.keys(cfg) },
|
|
453
|
-
'sync_resources: All MCP servers already configured — skipping download',
|
|
454
|
-
);
|
|
455
|
-
tally.cached++;
|
|
456
|
-
details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
logger.info(
|
|
463
|
-
{
|
|
464
|
-
resourceId: sub.id,
|
|
465
|
-
resourceName: sub.name,
|
|
466
|
-
mcpJsonPath,
|
|
467
|
-
hasMcpConfigFile: !!mcpConfigFile,
|
|
468
|
-
availableFiles: resourceFiles.map((f) => f.path),
|
|
469
|
-
},
|
|
470
|
-
'sync_resources: processing MCP resource',
|
|
471
|
-
);
|
|
472
|
-
|
|
473
|
-
if (mcpConfigFile) {
|
|
474
|
-
let cfg: Record<string, unknown> = {};
|
|
475
|
-
try { cfg = JSON.parse(mcpConfigFile.content) as Record<string, unknown>; }
|
|
476
|
-
catch {
|
|
477
|
-
logger.warn(
|
|
478
|
-
{ resourceId: sub.id, resourceName: sub.name },
|
|
479
|
-
'sync_resources: failed to parse mcp-config.json — treating as empty config',
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
if (typeof cfg['command'] === 'string') {
|
|
484
|
-
// ── Format A: local executable ──────────────────────────────────
|
|
485
|
-
const installDir = `${getCursorTypeDirForClient('mcp')}/${sub.name}`;
|
|
486
|
-
const writeActions: string[] = [];
|
|
487
|
-
for (const file of resourceFiles) {
|
|
488
|
-
const normalised = path.normalize(file.path);
|
|
489
|
-
if (normalised.startsWith('..')) continue;
|
|
490
|
-
const fileDest = `${installDir}/${normalised}`;
|
|
491
|
-
localActions.push({
|
|
492
|
-
action: 'write_file',
|
|
493
|
-
path: fileDest,
|
|
494
|
-
content: file.content,
|
|
495
|
-
});
|
|
496
|
-
writeActions.push(fileDest);
|
|
497
|
-
}
|
|
498
|
-
const env = (cfg['env'] ?? {}) as Record<string, string>;
|
|
499
|
-
const missingEnv = Object.entries(env).filter(([, v]) => v === '').map(([k]) => k);
|
|
500
|
-
const looksLikePath = (a: string) =>
|
|
501
|
-
a.startsWith('./') || a.startsWith('../') || a.includes('/') || /\.\w+$/.test(a);
|
|
502
|
-
const args = ((cfg['args'] ?? []) as string[]).map((a) =>
|
|
503
|
-
path.isAbsolute(a) || !looksLikePath(a) ? a : `${installDir}/${a.replace(/^\.\//, '')}`,
|
|
504
|
-
);
|
|
505
|
-
const serverName = (cfg['name'] as string | undefined) ?? sub.name;
|
|
506
|
-
localActions.push({
|
|
507
|
-
action: 'merge_mcp_json',
|
|
508
|
-
mcp_json_path: mcpJsonPath,
|
|
509
|
-
server_name: serverName,
|
|
510
|
-
entry: { ...cfg, args },
|
|
511
|
-
// skip_if_exists: preserve user-edited env values; the entry
|
|
512
|
-
// is already configured if the key exists in mcp.json.
|
|
513
|
-
skip_if_exists: true,
|
|
514
|
-
...(missingEnv.length > 0 ? {
|
|
515
|
-
missing_env: missingEnv,
|
|
516
|
-
setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${sub.name}"]: ${missingEnv.join(', ')}.`,
|
|
517
|
-
} : {}),
|
|
518
|
-
});
|
|
519
|
-
logger.info(
|
|
520
|
-
{
|
|
521
|
-
resourceId: sub.id,
|
|
522
|
-
resourceName: sub.name,
|
|
523
|
-
format: 'A',
|
|
524
|
-
installDir,
|
|
525
|
-
mcpJsonPath,
|
|
526
|
-
serverName,
|
|
527
|
-
writeFiles: writeActions,
|
|
528
|
-
missingEnv,
|
|
529
|
-
},
|
|
530
|
-
'sync_resources: MCP Format A — write_file + merge_mcp_json actions queued',
|
|
531
|
-
);
|
|
532
|
-
logToolStep('sync_resources', 'Local-executable MCP: write_file + merge_mcp_json queued', { resourceId: sub.id });
|
|
533
|
-
} else {
|
|
534
|
-
// ── Format B: remote URL map ────────────────────────────────────
|
|
535
|
-
const queuedServers: string[] = [];
|
|
536
|
-
for (const [serverName, entry] of Object.entries(cfg)) {
|
|
537
|
-
const e = entry as Record<string, unknown>;
|
|
538
|
-
const env = (e['env'] ?? {}) as Record<string, string>;
|
|
539
|
-
const missingEnv = Object.entries(env).filter(([, v]) => v === '').map(([k]) => k);
|
|
540
|
-
localActions.push({
|
|
541
|
-
action: 'merge_mcp_json',
|
|
542
|
-
mcp_json_path: mcpJsonPath,
|
|
543
|
-
server_name: serverName,
|
|
544
|
-
entry: e,
|
|
545
|
-
// skip_if_exists: user may have customised env values; do
|
|
546
|
-
// not overwrite an existing entry on every incremental sync.
|
|
547
|
-
skip_if_exists: true,
|
|
548
|
-
...(missingEnv.length > 0 ? {
|
|
549
|
-
missing_env: missingEnv,
|
|
550
|
-
setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${serverName}"]: ${missingEnv.join(', ')}.`,
|
|
551
|
-
} : {}),
|
|
552
|
-
});
|
|
553
|
-
queuedServers.push(serverName);
|
|
554
|
-
}
|
|
555
|
-
logger.info(
|
|
556
|
-
{
|
|
557
|
-
resourceId: sub.id,
|
|
558
|
-
resourceName: sub.name,
|
|
559
|
-
format: 'B',
|
|
560
|
-
mcpJsonPath,
|
|
561
|
-
serverKeys: queuedServers,
|
|
562
|
-
},
|
|
563
|
-
'sync_resources: MCP Format B — merge_mcp_json actions queued',
|
|
564
|
-
);
|
|
565
|
-
logToolStep('sync_resources', 'Remote-URL MCP: merge_mcp_json queued', {
|
|
566
|
-
resourceId: sub.id, serverKeys: Object.keys(cfg),
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
} else {
|
|
570
|
-
// No mcp-config.json: heuristic fallback
|
|
571
|
-
const installDir = `${getCursorTypeDirForClient('mcp')}/${sub.name}`;
|
|
572
|
-
const writeActions: string[] = [];
|
|
573
|
-
for (const file of resourceFiles) {
|
|
574
|
-
const normalised = path.normalize(file.path);
|
|
575
|
-
if (normalised.startsWith('..')) continue;
|
|
576
|
-
const fileDest = `${installDir}/${normalised}`;
|
|
577
|
-
localActions.push({
|
|
578
|
-
action: 'write_file',
|
|
579
|
-
path: fileDest,
|
|
580
|
-
content: file.content,
|
|
581
|
-
});
|
|
582
|
-
writeActions.push(fileDest);
|
|
583
|
-
}
|
|
584
|
-
const jsEntry = resourceFiles.find((f) => f.path.endsWith('.js'));
|
|
585
|
-
const pyEntry = resourceFiles.find((f) => f.path.endsWith('.py'));
|
|
586
|
-
const entryFile = jsEntry ?? pyEntry ?? resourceFiles[0];
|
|
587
|
-
const cmd = jsEntry ? 'node' : 'python3';
|
|
588
|
-
const entryPath = `${installDir}/${entryFile?.path ?? ''}`;
|
|
589
|
-
localActions.push({
|
|
590
|
-
action: 'merge_mcp_json',
|
|
591
|
-
mcp_json_path: mcpJsonPath,
|
|
592
|
-
server_name: sub.name,
|
|
593
|
-
entry: { command: cmd, args: [entryPath] },
|
|
594
|
-
skip_if_exists: true,
|
|
595
|
-
});
|
|
596
|
-
logger.info(
|
|
597
|
-
{
|
|
598
|
-
resourceId: sub.id,
|
|
599
|
-
resourceName: sub.name,
|
|
600
|
-
format: 'heuristic',
|
|
601
|
-
installDir,
|
|
602
|
-
mcpJsonPath,
|
|
603
|
-
cmd,
|
|
604
|
-
entryPath,
|
|
605
|
-
writeFiles: writeActions,
|
|
606
|
-
},
|
|
607
|
-
'sync_resources: MCP heuristic fallback — write_file + merge_mcp_json actions queued',
|
|
608
|
-
);
|
|
609
|
-
logToolStep('sync_resources', 'MCP heuristic fallback: write_file + merge_mcp_json queued', { resourceId: sub.id });
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
tally.synced++;
|
|
613
|
-
details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
|
|
614
|
-
continue;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// ── Rule resource ─────────────────────────────────────────────────────
|
|
618
|
-
// Return write_file actions; the AI Agent executes them on the user's
|
|
619
|
-
// LOCAL machine. The AI compares file content directly (string equality)
|
|
620
|
-
// against the existing local file and skips the write when content is
|
|
621
|
-
// identical — avoiding unnecessary disk I/O. If the local file is missing
|
|
622
|
-
// or has different content, the AI writes it unconditionally, which also
|
|
623
|
-
// recovers files that were accidentally deleted by the user.
|
|
624
|
-
if (sub.type === 'rule') {
|
|
625
|
-
const typeDir = getCursorTypeDirForClient(sub.type);
|
|
626
|
-
const writeActions: Array<{ destPath: string; contentLength: number }> = [];
|
|
627
|
-
|
|
628
|
-
for (const file of resourceFiles) {
|
|
629
|
-
const normalised = path.normalize(file.path);
|
|
630
|
-
if (normalised.startsWith('..')) {
|
|
631
|
-
logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
|
|
632
|
-
continue;
|
|
633
|
-
}
|
|
634
|
-
const destPath = `${typeDir}/${normalised}`;
|
|
635
|
-
localActions.push({
|
|
636
|
-
action: 'write_file',
|
|
637
|
-
path: destPath,
|
|
638
|
-
content: file.content,
|
|
639
|
-
});
|
|
640
|
-
writeActions.push({ destPath, contentLength: file.content.length });
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
logger.info(
|
|
644
|
-
{
|
|
645
|
-
resourceId: sub.id,
|
|
646
|
-
resourceName: sub.name,
|
|
647
|
-
typeDir,
|
|
648
|
-
fileCount: writeActions.length,
|
|
649
|
-
files: writeActions,
|
|
650
|
-
clientSideNote: 'AI will compare file content directly; write is skipped if content is identical',
|
|
651
|
-
},
|
|
652
|
-
'sync_resources: Rule — write_file actions queued for AI (client-side content comparison)',
|
|
653
|
-
);
|
|
654
|
-
|
|
655
|
-
tally.synced++;
|
|
656
|
-
details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
|
|
657
|
-
logToolStep('sync_resources', 'Rule: write_file actions queued for AI', {
|
|
658
|
-
resourceId: sub.id,
|
|
659
|
-
fileCount: resourceFiles.length,
|
|
660
|
-
});
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Fallback for any unrecognised types (should not happen in practice).
|
|
665
|
-
logger.warn({ resourceId: sub.id, type: sub.type }, 'Unrecognised resource type — skipping');
|
|
666
|
-
tally.failed++;
|
|
667
|
-
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
668
|
-
|
|
669
|
-
} catch (error) {
|
|
670
|
-
logger.error({
|
|
671
|
-
resourceId: sub.id,
|
|
672
|
-
resourceName: sub.name,
|
|
673
|
-
error: error instanceof Error ? error.message : String(error),
|
|
674
|
-
}, 'Failed to sync resource');
|
|
675
|
-
|
|
676
|
-
tally.failed++;
|
|
677
|
-
details.push({ id: sub.id, name: sub.name, action: 'failed', version: sub.resource?.version ?? 'unknown' });
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// ── Step 4: Prune stale prompts ────────────────────────────────────────
|
|
682
|
-
// Remove any prompt registered in a previous session that is no longer in
|
|
683
|
-
// the current subscription list. This prevents prompt count from growing
|
|
684
|
-
// unboundedly across reconnections.
|
|
685
|
-
// In 'check' mode we skip pruning — we never registered any prompts above.
|
|
686
|
-
if (mode !== 'check') {
|
|
687
|
-
promptManager.pruneStalePrompts(expectedPromptNames, userToken ?? '');
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// ── Step 5: Health score ───────────────────────────────────────────────
|
|
691
|
-
const healthScore = tally.total > 0
|
|
692
|
-
? Math.round(((tally.synced + tally.cached) / tally.total) * 100)
|
|
693
|
-
: 100;
|
|
694
|
-
|
|
695
|
-
const result: SyncResourcesResult = {
|
|
696
|
-
mode,
|
|
697
|
-
health_score: healthScore,
|
|
698
|
-
summary: tally,
|
|
699
|
-
details,
|
|
700
|
-
...(localActions.length > 0 ? { local_actions_required: localActions } : {}),
|
|
701
|
-
};
|
|
702
|
-
|
|
703
|
-
const duration = Date.now() - startTime;
|
|
704
|
-
logToolCall('sync_resources', 'user-id', params as Record<string, unknown>, duration);
|
|
705
|
-
logToolResult('sync_resources', true, result);
|
|
706
|
-
|
|
707
|
-
logger.info({
|
|
708
|
-
tool: 'sync_resources',
|
|
709
|
-
mode,
|
|
710
|
-
total: tally.total,
|
|
711
|
-
synced: tally.synced,
|
|
712
|
-
cached: tally.cached,
|
|
713
|
-
failed: tally.failed,
|
|
714
|
-
healthScore,
|
|
715
|
-
duration,
|
|
716
|
-
timestamp: new Date().toISOString()
|
|
717
|
-
}, 'sync_resources completed successfully');
|
|
718
|
-
|
|
719
|
-
// Update telemetry snapshot lists (fire-and-forget).
|
|
720
|
-
// Rules: cannot track individual invocations; report subscription list only.
|
|
721
|
-
const subscribedRules = subscriptions.subscriptions
|
|
722
|
-
.filter((s) => s.type === 'rule')
|
|
723
|
-
.map((s) => ({
|
|
724
|
-
resource_id: s.id,
|
|
725
|
-
resource_name: s.name,
|
|
726
|
-
subscribed_at: (s as any).subscribed_at ?? new Date().toISOString(),
|
|
727
|
-
}));
|
|
728
|
-
if (userToken) telemetry.updateSubscribedRules(subscribedRules, userToken).catch(() => {});
|
|
729
|
-
|
|
730
|
-
// MCPs: individual invocation tracking is each MCP server's own responsibility.
|
|
731
|
-
const configuredMcps = subscriptions.subscriptions
|
|
732
|
-
.filter((s) => s.type === 'mcp')
|
|
733
|
-
.map((s) => ({
|
|
734
|
-
resource_id: s.id,
|
|
735
|
-
resource_name: s.name,
|
|
736
|
-
configured_at: (s as any).subscribed_at ?? new Date().toISOString(),
|
|
737
|
-
}));
|
|
738
|
-
if (userToken) telemetry.updateConfiguredMcps(configuredMcps, userToken).catch(() => {});
|
|
739
|
-
|
|
740
|
-
return { success: true, data: result };
|
|
741
|
-
|
|
742
|
-
} catch (error) {
|
|
743
|
-
const duration = Date.now() - startTime;
|
|
744
|
-
|
|
745
|
-
logger.error({
|
|
746
|
-
tool: 'sync_resources',
|
|
747
|
-
error: error instanceof Error
|
|
748
|
-
? { message: error.message, stack: error.stack, name: error.name }
|
|
749
|
-
: String(error),
|
|
750
|
-
duration,
|
|
751
|
-
timestamp: new Date().toISOString()
|
|
752
|
-
}, 'sync_resources failed with error');
|
|
753
|
-
|
|
754
|
-
logToolResult('sync_resources', false, undefined, error instanceof Error ? error : new Error(String(error)));
|
|
755
|
-
|
|
756
|
-
return {
|
|
757
|
-
success: false,
|
|
758
|
-
error: {
|
|
759
|
-
code: error instanceof MCPServerError ? error.code : 'UNKNOWN_ERROR',
|
|
760
|
-
message: error instanceof Error ? error.message : String(error),
|
|
761
|
-
},
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// Tool definition for registry
|
|
767
|
-
export const syncResourcesTool = {
|
|
768
|
-
name: 'sync_resources',
|
|
769
|
-
description:
|
|
770
|
-
'Synchronize subscribed AI resources. ' +
|
|
771
|
-
'Command and Skill resources are registered as MCP Prompts on the server. ' +
|
|
772
|
-
'If the user subscribed to a NEW Command or Skill in THIS conversation and you need to execute it immediately, do NOT wait for native prompts/get. ' +
|
|
773
|
-
'After this tool completes, call `resolve_prompt_content` with the new prompt_name or resource_id, then execute the returned content. ' +
|
|
774
|
-
'Rule and MCP resources are returned as `local_actions_required` — an ordered list of ' +
|
|
775
|
-
'write_file, merge_mcp_json, or other actions that the AI Agent MUST execute on the ' +
|
|
776
|
-
'USER\'S LOCAL MACHINE after receiving the response. ' +
|
|
777
|
-
'Execute every action in the list in order before reporting success to the user. ' +
|
|
778
|
-
'OPTIMIZATION: Before calling this tool in incremental mode, read ~/.cursor/mcp.json ' +
|
|
779
|
-
'and pass Object.keys(mcpServers || {}) as `configured_mcp_servers` parameter. ' +
|
|
780
|
-
'This allows the server to skip downloading MCP resources that are already configured, ' +
|
|
781
|
-
'significantly reducing API calls, network traffic, and local action overhead. ' +
|
|
782
|
-
'For write_file actions: ' +
|
|
783
|
-
' (1) Read the existing file at `path` (if it exists) using fs.readFile() or equivalent. ' +
|
|
784
|
-
' (2) Compare the file content directly (string equality) against the `content` field. ' +
|
|
785
|
-
' (3) SKIP the write if they are EXACTLY equal — the file is already up-to-date. ' +
|
|
786
|
-
' (4) Otherwise, create any missing parent directories and write the file. ' +
|
|
787
|
-
'For merge_mcp_json actions: ' +
|
|
788
|
-
' (1) If the action has `skip_if_exists: true` AND `mcpServers[server_name]` already exists ' +
|
|
789
|
-
' in ~/.cursor/mcp.json, SKIP this action entirely — the server is already configured ' +
|
|
790
|
-
' and any user-edited env values must not be overwritten. ' +
|
|
791
|
-
' (2) Otherwise read the existing ~/.cursor/mcp.json, smart-merge the provided entry ' +
|
|
792
|
-
' (preserve existing user env values), then write the file back.',
|
|
793
|
-
inputSchema: {
|
|
794
|
-
type: 'object' as const,
|
|
795
|
-
properties: {
|
|
796
|
-
mode: {
|
|
797
|
-
type: 'string',
|
|
798
|
-
description: 'Sync mode: check (status only), incremental (updates only), full (all)',
|
|
799
|
-
enum: ['check', 'incremental', 'full'],
|
|
800
|
-
default: 'incremental',
|
|
801
|
-
},
|
|
802
|
-
scope: {
|
|
803
|
-
type: 'string',
|
|
804
|
-
description: 'Installation scope: global (~/.cursor/), workspace (.cursor/), or all',
|
|
805
|
-
enum: ['global', 'workspace', 'all'],
|
|
806
|
-
default: 'global',
|
|
807
|
-
},
|
|
808
|
-
types: {
|
|
809
|
-
type: 'array',
|
|
810
|
-
description: 'Filter by resource types (empty = all types)',
|
|
811
|
-
},
|
|
812
|
-
user_token: {
|
|
813
|
-
type: 'string',
|
|
814
|
-
description:
|
|
815
|
-
'DO NOT set this field — it is automatically injected by the MCP server from ' +
|
|
816
|
-
'the authenticated SSE connection. The server always provides the correct token.',
|
|
817
|
-
},
|
|
818
|
-
configured_mcp_servers: {
|
|
819
|
-
type: 'array',
|
|
820
|
-
description:
|
|
821
|
-
'List of MCP server names already configured in ~/.cursor/mcp.json. ' +
|
|
822
|
-
'In incremental mode, the server skips downloading these MCP resources to reduce overhead. ' +
|
|
823
|
-
'To populate this: read ~/.cursor/mcp.json and extract Object.keys(mcpServers || {}). ' +
|
|
824
|
-
'Example: ["github", "gitlab", "postgres"]. Ignored in full mode (always downloads).',
|
|
825
|
-
},
|
|
826
|
-
},
|
|
827
|
-
},
|
|
828
|
-
handler: syncResources,
|
|
829
|
-
};
|