@elliotding/ai-agent-mcp 0.1.0
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/dist/api/cached-client.d.ts +48 -0
- package/dist/api/cached-client.d.ts.map +1 -0
- package/dist/api/cached-client.js +126 -0
- package/dist/api/cached-client.js.map +1 -0
- package/dist/api/client.d.ts +213 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +326 -0
- package/dist/api/client.js.map +1 -0
- package/dist/auth/index.d.ts +8 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +26 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/middleware.d.ts +36 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +194 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/permissions.d.ts +60 -0
- package/dist/auth/permissions.d.ts.map +1 -0
- package/dist/auth/permissions.js +256 -0
- package/dist/auth/permissions.js.map +1 -0
- package/dist/auth/token-validator.d.ts +52 -0
- package/dist/auth/token-validator.d.ts.map +1 -0
- package/dist/auth/token-validator.js +217 -0
- package/dist/auth/token-validator.js.map +1 -0
- package/dist/cache/cache-manager.d.ts +49 -0
- package/dist/cache/cache-manager.d.ts.map +1 -0
- package/dist/cache/cache-manager.js +191 -0
- package/dist/cache/cache-manager.js.map +1 -0
- package/dist/cache/index.d.ts +6 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +12 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/redis-client.d.ts +45 -0
- package/dist/cache/redis-client.d.ts.map +1 -0
- package/dist/cache/redis-client.js +210 -0
- package/dist/cache/redis-client.js.map +1 -0
- package/dist/config/constants.d.ts +28 -0
- package/dist/config/constants.d.ts.map +1 -0
- package/dist/config/constants.js +31 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +168 -0
- package/dist/config/index.js.map +1 -0
- package/dist/filesystem/manager.d.ts +45 -0
- package/dist/filesystem/manager.d.ts.map +1 -0
- package/dist/filesystem/manager.js +246 -0
- package/dist/filesystem/manager.js.map +1 -0
- package/dist/git/multi-source-manager.d.ts +62 -0
- package/dist/git/multi-source-manager.d.ts.map +1 -0
- package/dist/git/multi-source-manager.js +293 -0
- package/dist/git/multi-source-manager.js.map +1 -0
- package/dist/git/operations.d.ts +27 -0
- package/dist/git/operations.d.ts.map +1 -0
- package/dist/git/operations.js +83 -0
- package/dist/git/operations.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/index.js.map +1 -0
- package/dist/monitoring/health.d.ts +35 -0
- package/dist/monitoring/health.d.ts.map +1 -0
- package/dist/monitoring/health.js +105 -0
- package/dist/monitoring/health.js.map +1 -0
- package/dist/resources/index.d.ts +6 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +10 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/loader.d.ts +87 -0
- package/dist/resources/loader.d.ts.map +1 -0
- package/dist/resources/loader.js +452 -0
- package/dist/resources/loader.js.map +1 -0
- package/dist/server/http.d.ts +57 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +336 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +157 -0
- package/dist/server.js.map +1 -0
- package/dist/session/manager.d.ts +91 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +251 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/tools/index.d.ts +11 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/manage-subscription.d.ts +43 -0
- package/dist/tools/manage-subscription.d.ts.map +1 -0
- package/dist/tools/manage-subscription.js +268 -0
- package/dist/tools/manage-subscription.js.map +1 -0
- package/dist/tools/registry.d.ts +40 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +85 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/search-resources.d.ts +31 -0
- package/dist/tools/search-resources.d.ts.map +1 -0
- package/dist/tools/search-resources.js +154 -0
- package/dist/tools/search-resources.js.map +1 -0
- package/dist/tools/sync-resources.d.ts +41 -0
- package/dist/tools/sync-resources.d.ts.map +1 -0
- package/dist/tools/sync-resources.js +606 -0
- package/dist/tools/sync-resources.js.map +1 -0
- package/dist/tools/uninstall-resource.d.ts +30 -0
- package/dist/tools/uninstall-resource.d.ts.map +1 -0
- package/dist/tools/uninstall-resource.js +259 -0
- package/dist/tools/uninstall-resource.js.map +1 -0
- package/dist/tools/upload-resource.d.ts +77 -0
- package/dist/tools/upload-resource.d.ts.map +1 -0
- package/dist/tools/upload-resource.js +252 -0
- package/dist/tools/upload-resource.js.map +1 -0
- package/dist/transport/sse.d.ts +29 -0
- package/dist/transport/sse.d.ts.map +1 -0
- package/dist/transport/sse.js +271 -0
- package/dist/transport/sse.js.map +1 -0
- package/dist/types/errors.d.ts +60 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/errors.js +112 -0
- package/dist/types/errors.js.map +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +23 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/mcp.d.ts +50 -0
- package/dist/types/mcp.d.ts.map +1 -0
- package/dist/types/mcp.js +6 -0
- package/dist/types/mcp.js.map +1 -0
- package/dist/types/resources.d.ts +109 -0
- package/dist/types/resources.d.ts.map +1 -0
- package/dist/types/resources.js +7 -0
- package/dist/types/resources.js.map +1 -0
- package/dist/types/tools.d.ts +147 -0
- package/dist/types/tools.d.ts.map +1 -0
- package/dist/types/tools.js +6 -0
- package/dist/types/tools.js.map +1 -0
- package/dist/utils/cursor-paths.d.ts +49 -0
- package/dist/utils/cursor-paths.d.ts.map +1 -0
- package/dist/utils/cursor-paths.js +116 -0
- package/dist/utils/cursor-paths.js.map +1 -0
- package/dist/utils/log-cleaner.d.ts +18 -0
- package/dist/utils/log-cleaner.d.ts.map +1 -0
- package/dist/utils/log-cleaner.js +112 -0
- package/dist/utils/log-cleaner.js.map +1 -0
- package/dist/utils/logger.d.ts +59 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +292 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/validation.d.ts +58 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +214 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +58 -0
- package/src/api/cached-client.ts +144 -0
- package/src/api/client.ts +578 -0
- package/src/auth/index.ts +11 -0
- package/src/auth/middleware.ts +244 -0
- package/src/auth/permissions.ts +317 -0
- package/src/auth/token-validator.ts +294 -0
- package/src/cache/cache-manager.ts +243 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/redis-client.ts +249 -0
- package/src/config/constants.ts +33 -0
- package/src/config/index.ts +228 -0
- package/src/filesystem/manager.ts +235 -0
- package/src/git/multi-source-manager.ts +333 -0
- package/src/git/operations.ts +93 -0
- package/src/index.ts +139 -0
- package/src/monitoring/health.ts +132 -0
- package/src/resources/index.ts +13 -0
- package/src/resources/loader.ts +530 -0
- package/src/server/http.ts +427 -0
- package/src/server.ts +191 -0
- package/src/session/manager.ts +296 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/manage-subscription.ts +332 -0
- package/src/tools/registry.ts +97 -0
- package/src/tools/search-resources.ts +177 -0
- package/src/tools/sync-resources.ts +662 -0
- package/src/tools/uninstall-resource.ts +248 -0
- package/src/tools/upload-resource.ts +258 -0
- package/src/transport/sse.ts +308 -0
- package/src/types/errors.ts +146 -0
- package/src/types/index.ts +7 -0
- package/src/types/mcp.ts +61 -0
- package/src/types/resources.ts +141 -0
- package/src/types/tools.ts +175 -0
- package/src/utils/cursor-paths.ts +83 -0
- package/src/utils/log-cleaner.ts +92 -0
- package/src/utils/logger.ts +333 -0
- package/src/utils/validation.ts +262 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sync_resources Tool
|
|
3
|
+
*
|
|
4
|
+
* Synchronises the user's subscribed AI resources to the local Cursor directories:
|
|
5
|
+
* macOS/Linux : ~/.cursor/skills/ ~/.cursor/commands/ ~/.cursor/rules/ ~/.cursor/mcp-servers/
|
|
6
|
+
* Windows : %APPDATA%\Cursor\User\<type>\
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Fetch subscription list from CSP server (REST API).
|
|
10
|
+
* 2. (non-check) Trigger Git sync on server side via multiSourceGitManager.
|
|
11
|
+
* 3. For each subscription: download content via REST API → write to Cursor directory.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs/promises';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { logger, logToolCall, logToolStep, logToolResult } from '../utils/logger';
|
|
17
|
+
import { apiClient } from '../api/client';
|
|
18
|
+
import { multiSourceGitManager } from '../git/multi-source-manager';
|
|
19
|
+
import { getCursorResourcePath, getCursorTypeDir, getCursorRootDir } from '../utils/cursor-paths';
|
|
20
|
+
import { MCPServerError } from '../types/errors';
|
|
21
|
+
import type { SyncResourcesParams, SyncResourcesResult, McpSetupItem, ToolResult } from '../types/tools';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Two supported mcp-config.json formats:
|
|
25
|
+
*
|
|
26
|
+
* Format A — Local executable (e.g. jenkins):
|
|
27
|
+
* Has a top-level "command" field.
|
|
28
|
+
* { "name": "jenkins", "command": "python3", "args": ["server.py"], "env": {...} }
|
|
29
|
+
* → One entry written to mcpServers using resolved absolute args.
|
|
30
|
+
*
|
|
31
|
+
* Format B — Remote URL entries (e.g. acm):
|
|
32
|
+
* No "command" field; the object IS the mcpServers map (one or more entries).
|
|
33
|
+
* { "acm-dev": { "url": "...", "transport": "sse" }, "acm": { "url": "..." } }
|
|
34
|
+
* → Each key merged directly into mcpServers as-is (no path resolution needed).
|
|
35
|
+
*
|
|
36
|
+
* Detection: if parsed JSON has a "command" key at the top level → Format A, else Format B.
|
|
37
|
+
*/
|
|
38
|
+
interface LocalMcpDescriptor {
|
|
39
|
+
name?: string;
|
|
40
|
+
command: string;
|
|
41
|
+
args?: string[];
|
|
42
|
+
env?: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
type RemoteMcpEntries = Record<string, unknown>; // mcpServers-compatible map
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register a downloaded MCP resource into ~/.cursor/mcp.json.
|
|
48
|
+
*
|
|
49
|
+
* Supports:
|
|
50
|
+
* - Format A (local executable): resolves relative args to absolute paths, writes one entry.
|
|
51
|
+
* - Format B (remote URL map): merges all entries directly into mcpServers.
|
|
52
|
+
* - No mcp-config.json: heuristic fallback (scans for .py/.js entry point, logs WARN).
|
|
53
|
+
*
|
|
54
|
+
* The write is idempotent — re-running after a re-download updates existing entries.
|
|
55
|
+
*
|
|
56
|
+
* Returns a McpSetupItem when the registered server needs manual configuration
|
|
57
|
+
* (empty env vars, or a command that might differ across platforms), or null
|
|
58
|
+
* when no action is required from the user.
|
|
59
|
+
*/
|
|
60
|
+
async function registerMcpServer(serverName: string, installDir: string): Promise<McpSetupItem | null> {
|
|
61
|
+
// ── 1. Load mcp-config.json ────────────────────────────────────────────
|
|
62
|
+
const configFilePath = path.join(installDir, 'mcp-config.json');
|
|
63
|
+
let rawConfig: unknown = null;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const raw = await fs.readFile(configFilePath, 'utf-8');
|
|
67
|
+
rawConfig = JSON.parse(raw);
|
|
68
|
+
logger.debug({ serverName, configFilePath }, 'registerMcpServer: loaded mcp-config.json');
|
|
69
|
+
} catch {
|
|
70
|
+
logger.warn(
|
|
71
|
+
{ serverName, configFilePath },
|
|
72
|
+
'registerMcpServer: mcp-config.json not found — falling back to heuristic detection. ' +
|
|
73
|
+
'Add an mcp-config.json to this resource for reliable registration.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 2. Determine what to merge into mcp.json ──────────────────────────
|
|
78
|
+
// entriesToMerge: map of serverKey → entry object (may have multiple keys for Format B)
|
|
79
|
+
let entriesToMerge: Record<string, unknown> = {};
|
|
80
|
+
|
|
81
|
+
if (rawConfig !== null && typeof rawConfig === 'object') {
|
|
82
|
+
const cfg = rawConfig as Record<string, unknown>;
|
|
83
|
+
|
|
84
|
+
if (typeof cfg['command'] === 'string') {
|
|
85
|
+
// ── Format A: local executable ───────────────────────────────────
|
|
86
|
+
const descriptor = cfg as unknown as LocalMcpDescriptor;
|
|
87
|
+
const key = descriptor.name ?? serverName;
|
|
88
|
+
// Only resolve args that look like relative file paths (contain a dot or
|
|
89
|
+
// path separator). Plain words like "mcp", "start", "--port" are kept as-is.
|
|
90
|
+
const looksLikePath = (a: string) =>
|
|
91
|
+
a.startsWith('./') || a.startsWith('../') || a.includes(path.sep) || /\.\w+$/.test(a);
|
|
92
|
+
const resolvedArgs = (descriptor.args ?? []).map(a =>
|
|
93
|
+
path.isAbsolute(a) || !looksLikePath(a) ? a : path.join(installDir, a)
|
|
94
|
+
);
|
|
95
|
+
entriesToMerge[key] = {
|
|
96
|
+
command: descriptor.command,
|
|
97
|
+
args: resolvedArgs,
|
|
98
|
+
...(descriptor.env && Object.keys(descriptor.env).length > 0
|
|
99
|
+
? { env: descriptor.env }
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
logger.info(
|
|
103
|
+
{ serverName, key, command: descriptor.command },
|
|
104
|
+
'registerMcpServer: Format A (local executable)'
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
// ── Format B: remote URL entries map ─────────────────────────────
|
|
108
|
+
// The entire object is a ready-to-merge mcpServers map.
|
|
109
|
+
entriesToMerge = cfg as RemoteMcpEntries;
|
|
110
|
+
logger.info(
|
|
111
|
+
{ serverName, keys: Object.keys(entriesToMerge) },
|
|
112
|
+
'registerMcpServer: Format B (remote URL entries)'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
// ── Heuristic fallback (no mcp-config.json) ───────────────────────
|
|
117
|
+
let entryFile: string | null = null;
|
|
118
|
+
let command = 'python3';
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const entries = await fs.readdir(installDir);
|
|
122
|
+
if (entries.includes(`${serverName}.py`)) {
|
|
123
|
+
entryFile = path.join(installDir, `${serverName}.py`); command = 'python3';
|
|
124
|
+
} else if (entries.includes(`${serverName}.js`)) {
|
|
125
|
+
entryFile = path.join(installDir, `${serverName}.js`); command = 'node';
|
|
126
|
+
}
|
|
127
|
+
if (!entryFile) {
|
|
128
|
+
const py = entries.find(f => f.endsWith('.py') && f !== '__init__.py');
|
|
129
|
+
if (py) { entryFile = path.join(installDir, py); command = 'python3'; }
|
|
130
|
+
}
|
|
131
|
+
if (!entryFile) {
|
|
132
|
+
const js = entries.find(f => f.endsWith('.js') || f.endsWith('.mjs'));
|
|
133
|
+
if (js) { entryFile = path.join(installDir, js); command = 'node'; }
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
logger.warn({ serverName, installDir, err }, 'registerMcpServer: could not read install directory');
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!entryFile) {
|
|
141
|
+
logger.warn(
|
|
142
|
+
{ serverName, installDir },
|
|
143
|
+
'registerMcpServer: no entry point found and no mcp-config.json — skipping registration'
|
|
144
|
+
);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
entriesToMerge[serverName] = { command, args: [entryFile] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── 3. Read / create ~/.cursor/mcp.json ───────────────────────────────
|
|
151
|
+
const mcpJsonPath = path.join(getCursorRootDir(), 'mcp.json');
|
|
152
|
+
let mcpConfig: { mcpServers: Record<string, unknown> } = { mcpServers: {} };
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const raw = await fs.readFile(mcpJsonPath, 'utf-8');
|
|
156
|
+
const parsed = JSON.parse(raw);
|
|
157
|
+
if (parsed && typeof parsed === 'object' && 'mcpServers' in parsed) {
|
|
158
|
+
mcpConfig = parsed as typeof mcpConfig;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// File doesn't exist or is corrupt — start fresh
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Smart-merge each entry into mcpServers:
|
|
165
|
+
// - Structural fields (command, args, url, transport, …): always take the
|
|
166
|
+
// value from mcp-config.json (server is authoritative for structure).
|
|
167
|
+
// - env field: preserve user-filled non-empty values; only add keys that
|
|
168
|
+
// are new or were previously empty (avoids wiping tokens / URLs the user
|
|
169
|
+
// has already configured).
|
|
170
|
+
for (const [key, incoming] of Object.entries(entriesToMerge)) {
|
|
171
|
+
const existing = mcpConfig.mcpServers[key];
|
|
172
|
+
|
|
173
|
+
if (!existing || typeof existing !== 'object') {
|
|
174
|
+
// No prior entry — write as-is.
|
|
175
|
+
mcpConfig.mcpServers[key] = incoming;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const incomingEntry = incoming as Record<string, unknown>;
|
|
180
|
+
const existingEntry = existing as Record<string, unknown>;
|
|
181
|
+
|
|
182
|
+
// Merge env: keep user values that are non-empty strings; fill in the rest
|
|
183
|
+
// from the incoming template (which uses empty strings as placeholders).
|
|
184
|
+
const mergedEnv: Record<string, string> = {};
|
|
185
|
+
const incomingEnv = (incomingEntry['env'] ?? {}) as Record<string, string>;
|
|
186
|
+
const existingEnv = (existingEntry['env'] ?? {}) as Record<string, string>;
|
|
187
|
+
|
|
188
|
+
for (const envKey of Object.keys(incomingEnv)) {
|
|
189
|
+
const userVal = existingEnv[envKey];
|
|
190
|
+
// Preserve whatever the user typed; fall back to the template placeholder.
|
|
191
|
+
mergedEnv[envKey] = (typeof userVal === 'string' && userVal !== '')
|
|
192
|
+
? userVal
|
|
193
|
+
: (incomingEnv[envKey] ?? '');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Structural fields from server override local, env is smart-merged.
|
|
197
|
+
mcpConfig.mcpServers[key] = {
|
|
198
|
+
...incomingEntry,
|
|
199
|
+
...(Object.keys(mergedEnv).length > 0 ? { env: mergedEnv } : {}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── 4. Atomic write ────────────────────────────────────────────────────
|
|
204
|
+
const tmpPath = `${mcpJsonPath}.tmp-${process.pid}`;
|
|
205
|
+
try {
|
|
206
|
+
await fs.writeFile(tmpPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
|
|
207
|
+
await fs.rename(tmpPath, mcpJsonPath);
|
|
208
|
+
logger.info(
|
|
209
|
+
{ serverName, mergedKeys: Object.keys(entriesToMerge), mcpJsonPath },
|
|
210
|
+
'MCP server(s) registered in mcp.json'
|
|
211
|
+
);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
await fs.unlink(tmpPath).catch(() => undefined);
|
|
214
|
+
logger.error({ serverName, err }, 'registerMcpServer: failed to write mcp.json');
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── 5. Detect setup requirements ──────────────────────────────────────
|
|
219
|
+
// Collect env keys that are still empty (user must fill in) and flag
|
|
220
|
+
// commands that may differ across platforms (python vs python3, etc.).
|
|
221
|
+
const AMBIGUOUS_COMMANDS = new Set(['python', 'python3', 'node', 'npx', 'uvx']);
|
|
222
|
+
const missingEnvKeys: string[] = [];
|
|
223
|
+
let commandNeedsVerification = false;
|
|
224
|
+
let registeredCommand = '';
|
|
225
|
+
|
|
226
|
+
for (const entry of Object.values(entriesToMerge)) {
|
|
227
|
+
const e = entry as Record<string, unknown>;
|
|
228
|
+
const env = (e['env'] ?? {}) as Record<string, string>;
|
|
229
|
+
for (const [k, v] of Object.entries(env)) {
|
|
230
|
+
if (v === '') missingEnvKeys.push(k);
|
|
231
|
+
}
|
|
232
|
+
if (typeof e['command'] === 'string') {
|
|
233
|
+
registeredCommand = e['command'];
|
|
234
|
+
if (AMBIGUOUS_COMMANDS.has(registeredCommand)) {
|
|
235
|
+
commandNeedsVerification = true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (missingEnvKeys.length === 0 && !commandNeedsVerification) {
|
|
241
|
+
return null; // No user action needed
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Locate the best available setup/readme doc in the install directory so the
|
|
245
|
+
// user can be pointed to it. Priority: SETUP.md > README.md > README*.md > *.md
|
|
246
|
+
let setupDocPath: string | null = null;
|
|
247
|
+
try {
|
|
248
|
+
const entries = await fs.readdir(installDir);
|
|
249
|
+
const mdFiles = entries.filter(f => /\.md$/i.test(f));
|
|
250
|
+
const pick = (name: string) => mdFiles.find(f => f.toLowerCase() === name.toLowerCase());
|
|
251
|
+
const found =
|
|
252
|
+
pick('SETUP.md') ??
|
|
253
|
+
pick('README.md') ??
|
|
254
|
+
mdFiles.find(f => f.toLowerCase().startsWith('readme')) ??
|
|
255
|
+
mdFiles[0];
|
|
256
|
+
if (found) {
|
|
257
|
+
setupDocPath = path.join(installDir, found);
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// installDir might not exist yet for remote-URL MCPs — ignore
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const hints: string[] = [];
|
|
264
|
+
if (commandNeedsVerification) {
|
|
265
|
+
hints.push(
|
|
266
|
+
`The command "${registeredCommand}" may differ on your machine ` +
|
|
267
|
+
`(e.g. "python" vs "python3"). ` +
|
|
268
|
+
`Please verify the command in ${mcpJsonPath} under mcpServers["${serverName}"].`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (missingEnvKeys.length > 0) {
|
|
272
|
+
hints.push(
|
|
273
|
+
`Fill in the following environment variables in ${mcpJsonPath} ` +
|
|
274
|
+
`under mcpServers["${serverName}"].env: ${missingEnvKeys.join(', ')}.`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (setupDocPath) {
|
|
278
|
+
hints.push(`Refer to the setup guide for details: ${setupDocPath}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
server_name: serverName,
|
|
283
|
+
mcp_json_path: mcpJsonPath,
|
|
284
|
+
missing_env: missingEnvKeys,
|
|
285
|
+
command_needs_verification: commandNeedsVerification,
|
|
286
|
+
command: registeredCommand,
|
|
287
|
+
setup_hint: hints.join(' '),
|
|
288
|
+
...(setupDocPath ? { setup_doc: setupDocPath } : {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function syncResources(params: unknown): Promise<ToolResult<SyncResourcesResult>> {
|
|
293
|
+
const startTime = Date.now();
|
|
294
|
+
|
|
295
|
+
const typedParams = params as SyncResourcesParams;
|
|
296
|
+
|
|
297
|
+
logger.info({
|
|
298
|
+
tool: 'sync_resources',
|
|
299
|
+
params: typedParams,
|
|
300
|
+
timestamp: new Date().toISOString()
|
|
301
|
+
}, 'sync_resources tool invoked');
|
|
302
|
+
|
|
303
|
+
logToolStep('sync_resources', 'Tool invocation started', { params: typedParams });
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const mode = typedParams.mode || 'incremental';
|
|
307
|
+
const scope = typedParams.scope || 'global';
|
|
308
|
+
const types = typedParams.types;
|
|
309
|
+
|
|
310
|
+
logToolStep('sync_resources', 'Parameters validated', { mode, scope, types });
|
|
311
|
+
|
|
312
|
+
// ── Step 1: Fetch subscription list ────────────────────────────────────
|
|
313
|
+
logToolStep('sync_resources', 'Step 1: Fetching subscriptions from API', { scope, types });
|
|
314
|
+
const t1 = Date.now();
|
|
315
|
+
|
|
316
|
+
const subscriptions = await apiClient.getSubscriptions({ types });
|
|
317
|
+
|
|
318
|
+
logToolStep('sync_resources', 'Subscriptions fetched', {
|
|
319
|
+
total: subscriptions.total,
|
|
320
|
+
duration: Date.now() - t1,
|
|
321
|
+
ids: subscriptions.subscriptions.map(s => s.id),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// ── Step 2: Server-side Git sync (skip in check mode) ──────────────────
|
|
325
|
+
logToolStep('sync_resources', 'Step 2: Server-side Git sync');
|
|
326
|
+
|
|
327
|
+
if (mode === 'check') {
|
|
328
|
+
const statuses = await multiSourceGitManager.checkAllSources();
|
|
329
|
+
logToolStep('sync_resources', 'Repository status check completed', {
|
|
330
|
+
sources: statuses.map(s => ({ name: s.source, exists: s.exists, hasRemote: s.hasRemote })),
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
const t2 = Date.now();
|
|
334
|
+
const gitResults = await multiSourceGitManager.syncAllSources();
|
|
335
|
+
logToolStep('sync_resources', 'Server-side Git sync completed', {
|
|
336
|
+
duration: Date.now() - t2,
|
|
337
|
+
summary: {
|
|
338
|
+
cloned: gitResults.filter(r => r.action === 'cloned').length,
|
|
339
|
+
pulled: gitResults.filter(r => r.action === 'pulled').length,
|
|
340
|
+
upToDate: gitResults.filter(r => r.action === 'up-to-date').length,
|
|
341
|
+
skipped: gitResults.filter(r => r.action === 'skipped').length,
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Step 3: Download each subscribed resource to the local Cursor dir ──
|
|
347
|
+
logToolStep('sync_resources', 'Step 3: Downloading resources to Cursor directories', {
|
|
348
|
+
count: subscriptions.total,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const tally = { total: subscriptions.total, synced: 0, cached: 0, failed: 0 };
|
|
352
|
+
|
|
353
|
+
const details: Array<{
|
|
354
|
+
id: string;
|
|
355
|
+
name: string;
|
|
356
|
+
action: 'synced' | 'cached' | 'failed';
|
|
357
|
+
version: string;
|
|
358
|
+
}> = [];
|
|
359
|
+
|
|
360
|
+
const pendingSetup: McpSetupItem[] = [];
|
|
361
|
+
|
|
362
|
+
for (let i = 0; i < subscriptions.subscriptions.length; i++) {
|
|
363
|
+
const sub = subscriptions.subscriptions[i];
|
|
364
|
+
if (!sub) continue;
|
|
365
|
+
|
|
366
|
+
// Safe access — `resource` metadata is only present when detail=true was requested.
|
|
367
|
+
const resourceVersion = sub.resource?.version ?? 'unknown';
|
|
368
|
+
|
|
369
|
+
logToolStep('sync_resources', `Processing ${i + 1}/${tally.total}`, {
|
|
370
|
+
resourceId: sub.id,
|
|
371
|
+
resourceName: sub.name,
|
|
372
|
+
resourceType: sub.type,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
// Resolve the destination path inside the Cursor directory.
|
|
377
|
+
// getCursorResourcePath throws for unrecognised types, caught below.
|
|
378
|
+
const destPath = getCursorResourcePath(sub.type, sub.name);
|
|
379
|
+
|
|
380
|
+
// In check mode: just report whether the resource already exists locally.
|
|
381
|
+
if (mode === 'check') {
|
|
382
|
+
try {
|
|
383
|
+
await fs.access(destPath);
|
|
384
|
+
tally.cached++;
|
|
385
|
+
details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
|
|
386
|
+
logToolStep('sync_resources', 'Resource already present (check mode)', {
|
|
387
|
+
resourceId: sub.id, destPath,
|
|
388
|
+
});
|
|
389
|
+
} catch {
|
|
390
|
+
tally.failed++;
|
|
391
|
+
details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
|
|
392
|
+
logToolStep('sync_resources', 'Resource missing (check mode)', {
|
|
393
|
+
resourceId: sub.id, destPath,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Download all files for this resource from the CSP server.
|
|
400
|
+
// We always download first so we can inspect the payload and determine
|
|
401
|
+
// whether this is a remote-URL-only MCP (Format B: config-only, no
|
|
402
|
+
// local files needed) before deciding what to write locally.
|
|
403
|
+
logToolStep('sync_resources', 'Downloading resource', {
|
|
404
|
+
resourceId: sub.id,
|
|
405
|
+
resourceType: sub.type,
|
|
406
|
+
});
|
|
407
|
+
const tDl = Date.now();
|
|
408
|
+
const downloadResult = await apiClient.downloadResource(sub.id);
|
|
409
|
+
logToolStep('sync_resources', 'Download complete', {
|
|
410
|
+
resourceId: sub.id,
|
|
411
|
+
fileCount: downloadResult.files.length,
|
|
412
|
+
duration: Date.now() - tDl,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Detect remote-URL-only MCP: the payload contains exactly one file
|
|
416
|
+
// named mcp-config.json whose JSON has no "command" field (Format B).
|
|
417
|
+
// These servers are deployed remotely — no local files are needed.
|
|
418
|
+
// We only need to update the user's ~/.cursor/mcp.json.
|
|
419
|
+
let isRemoteUrlMcp = false;
|
|
420
|
+
const firstFile = downloadResult.files[0];
|
|
421
|
+
if (sub.type === 'mcp' && downloadResult.files.length === 1
|
|
422
|
+
&& firstFile !== undefined
|
|
423
|
+
&& path.basename(firstFile.path) === 'mcp-config.json') {
|
|
424
|
+
try {
|
|
425
|
+
const parsed = JSON.parse(firstFile.content) as Record<string, unknown>;
|
|
426
|
+
if (typeof parsed['command'] !== 'string') {
|
|
427
|
+
isRemoteUrlMcp = true;
|
|
428
|
+
}
|
|
429
|
+
} catch { /* malformed JSON — treat as normal MCP */ }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (isRemoteUrlMcp) {
|
|
433
|
+
// Remote-URL MCP: no local files to write; just register in mcp.json.
|
|
434
|
+
// Parse and merge the entries directly from the downloaded content.
|
|
435
|
+
const configContent = firstFile!.content;
|
|
436
|
+
const mcpJsonPath = path.join(getCursorRootDir(), 'mcp.json');
|
|
437
|
+
let mcpConfig: { mcpServers: Record<string, unknown> } = { mcpServers: {} };
|
|
438
|
+
try {
|
|
439
|
+
const raw = await fs.readFile(mcpJsonPath, 'utf-8');
|
|
440
|
+
const p = JSON.parse(raw);
|
|
441
|
+
if (p && typeof p === 'object' && 'mcpServers' in p) {
|
|
442
|
+
mcpConfig = p as typeof mcpConfig;
|
|
443
|
+
}
|
|
444
|
+
} catch { /* file missing or corrupt — start fresh */ }
|
|
445
|
+
|
|
446
|
+
const entries = JSON.parse(configContent) as Record<string, unknown>;
|
|
447
|
+
// Smart-merge: structural fields from server; preserve user env values.
|
|
448
|
+
for (const [key, incoming] of Object.entries(entries)) {
|
|
449
|
+
const existing = mcpConfig.mcpServers[key];
|
|
450
|
+
if (!existing || typeof existing !== 'object') {
|
|
451
|
+
mcpConfig.mcpServers[key] = incoming;
|
|
452
|
+
} else {
|
|
453
|
+
const inc = incoming as Record<string, unknown>;
|
|
454
|
+
const ext = existing as Record<string, unknown>;
|
|
455
|
+
const inEnv = (inc['env'] ?? {}) as Record<string, string>;
|
|
456
|
+
const exEnv = (ext['env'] ?? {}) as Record<string, string>;
|
|
457
|
+
const mergedEnv: Record<string, string> = {};
|
|
458
|
+
for (const k of Object.keys(inEnv)) {
|
|
459
|
+
const userVal = exEnv[k];
|
|
460
|
+
mergedEnv[k] = (typeof userVal === 'string' && userVal !== '') ? userVal : (inEnv[k] ?? '');
|
|
461
|
+
}
|
|
462
|
+
mcpConfig.mcpServers[key] = {
|
|
463
|
+
...inc,
|
|
464
|
+
...(Object.keys(mergedEnv).length > 0 ? { env: mergedEnv } : {}),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const tmpPath = `${mcpJsonPath}.tmp-${process.pid}`;
|
|
470
|
+
await fs.writeFile(tmpPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
|
|
471
|
+
await fs.rename(tmpPath, mcpJsonPath);
|
|
472
|
+
|
|
473
|
+
// Detect missing env vars in remote-URL entries (no local command to check).
|
|
474
|
+
const remoteMissingEnv: string[] = [];
|
|
475
|
+
for (const entry of Object.values(entries)) {
|
|
476
|
+
const e = entry as Record<string, unknown>;
|
|
477
|
+
const env = (e['env'] ?? {}) as Record<string, string>;
|
|
478
|
+
for (const [k, v] of Object.entries(env)) {
|
|
479
|
+
if (v === '') remoteMissingEnv.push(k);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (remoteMissingEnv.length > 0) {
|
|
483
|
+
pendingSetup.push({
|
|
484
|
+
server_name: sub.name,
|
|
485
|
+
mcp_json_path: mcpJsonPath,
|
|
486
|
+
missing_env: remoteMissingEnv,
|
|
487
|
+
command_needs_verification: false,
|
|
488
|
+
command: '',
|
|
489
|
+
setup_hint:
|
|
490
|
+
`Fill in the following environment variables in ${mcpJsonPath} ` +
|
|
491
|
+
`under the relevant mcpServers entries for "${sub.name}": ` +
|
|
492
|
+
`${remoteMissingEnv.join(', ')}.`,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
tally.synced++;
|
|
497
|
+
details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
|
|
498
|
+
logToolStep('sync_resources', 'Remote-URL MCP registered in mcp.json (no local files)', {
|
|
499
|
+
resourceId: sub.id,
|
|
500
|
+
mergedKeys: Object.keys(entries),
|
|
501
|
+
});
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Incremental mode: skip file write if local directory already exists.
|
|
506
|
+
// MCP resources (local-executable type) still call registerMcpServer
|
|
507
|
+
// to keep mcp.json in sync even when files have not changed.
|
|
508
|
+
if (mode === 'incremental') {
|
|
509
|
+
let alreadyPresent = false;
|
|
510
|
+
try {
|
|
511
|
+
await fs.access(destPath);
|
|
512
|
+
alreadyPresent = true;
|
|
513
|
+
} catch { /* not present — fall through to write */ }
|
|
514
|
+
|
|
515
|
+
if (alreadyPresent) {
|
|
516
|
+
if (sub.type === 'mcp') {
|
|
517
|
+
const setupItem = await registerMcpServer(sub.name, destPath);
|
|
518
|
+
if (setupItem) pendingSetup.push(setupItem);
|
|
519
|
+
}
|
|
520
|
+
tally.cached++;
|
|
521
|
+
details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
|
|
522
|
+
logToolStep('sync_resources', 'Resource already present (incremental — skipping file write)', {
|
|
523
|
+
resourceId: sub.id, destPath,
|
|
524
|
+
});
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Ensure the Cursor type directory exists (e.g. ~/.cursor/skills/).
|
|
530
|
+
const typeDir = getCursorTypeDir(sub.type);
|
|
531
|
+
await fs.mkdir(typeDir, { recursive: true });
|
|
532
|
+
|
|
533
|
+
// Determine write strategy based on resource type:
|
|
534
|
+
// Directory-based (skill, mcp): create <typeDir>/<name>/ and write files under it.
|
|
535
|
+
// File-based (command, rule): write each file directly into <typeDir>/ — no subdir.
|
|
536
|
+
const isDirectoryType = sub.type === 'skill' || sub.type === 'mcp';
|
|
537
|
+
const writeRoot = isDirectoryType ? destPath : typeDir;
|
|
538
|
+
|
|
539
|
+
if (isDirectoryType) {
|
|
540
|
+
await fs.mkdir(writeRoot, { recursive: true });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
for (const file of downloadResult.files) {
|
|
544
|
+
// Reject path traversal attempts in file.path
|
|
545
|
+
const normalised = path.normalize(file.path);
|
|
546
|
+
if (normalised.startsWith('..')) {
|
|
547
|
+
logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
const writePath = path.join(writeRoot, normalised);
|
|
551
|
+
await fs.mkdir(path.dirname(writePath), { recursive: true });
|
|
552
|
+
await fs.writeFile(writePath, file.content, 'utf-8');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// After writing local MCP files, register the server in ~/.cursor/mcp.json.
|
|
556
|
+
if (sub.type === 'mcp') {
|
|
557
|
+
const setupItem = await registerMcpServer(sub.name, destPath);
|
|
558
|
+
if (setupItem) pendingSetup.push(setupItem);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
tally.synced++;
|
|
562
|
+
details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
|
|
563
|
+
logToolStep('sync_resources', 'Resource written to Cursor directory', {
|
|
564
|
+
resourceId: sub.id,
|
|
565
|
+
destPath,
|
|
566
|
+
fileCount: downloadResult.files.length,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
} catch (error) {
|
|
570
|
+
logger.error({
|
|
571
|
+
resourceId: sub.id,
|
|
572
|
+
resourceName: sub.name,
|
|
573
|
+
error: error instanceof Error ? error.message : String(error),
|
|
574
|
+
}, 'Failed to sync resource');
|
|
575
|
+
|
|
576
|
+
tally.failed++;
|
|
577
|
+
details.push({ id: sub.id, name: sub.name, action: 'failed', version: sub.resource?.version ?? 'unknown' });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Step 4: Health score ───────────────────────────────────────────────
|
|
582
|
+
const healthScore = tally.total > 0
|
|
583
|
+
? Math.round(((tally.synced + tally.cached) / tally.total) * 100)
|
|
584
|
+
: 100;
|
|
585
|
+
|
|
586
|
+
const result: SyncResourcesResult = {
|
|
587
|
+
mode,
|
|
588
|
+
health_score: healthScore,
|
|
589
|
+
summary: tally,
|
|
590
|
+
details,
|
|
591
|
+
...(pendingSetup.length > 0 ? { pending_setup: pendingSetup } : {}),
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const duration = Date.now() - startTime;
|
|
595
|
+
logToolCall('sync_resources', 'user-id', params as Record<string, unknown>, duration);
|
|
596
|
+
logToolResult('sync_resources', true, result);
|
|
597
|
+
|
|
598
|
+
logger.info({
|
|
599
|
+
tool: 'sync_resources',
|
|
600
|
+
mode,
|
|
601
|
+
total: tally.total,
|
|
602
|
+
synced: tally.synced,
|
|
603
|
+
cached: tally.cached,
|
|
604
|
+
failed: tally.failed,
|
|
605
|
+
healthScore,
|
|
606
|
+
duration,
|
|
607
|
+
timestamp: new Date().toISOString()
|
|
608
|
+
}, 'sync_resources completed successfully');
|
|
609
|
+
|
|
610
|
+
return { success: true, data: result };
|
|
611
|
+
|
|
612
|
+
} catch (error) {
|
|
613
|
+
const duration = Date.now() - startTime;
|
|
614
|
+
|
|
615
|
+
logger.error({
|
|
616
|
+
tool: 'sync_resources',
|
|
617
|
+
error: error instanceof Error
|
|
618
|
+
? { message: error.message, stack: error.stack, name: error.name }
|
|
619
|
+
: String(error),
|
|
620
|
+
duration,
|
|
621
|
+
timestamp: new Date().toISOString()
|
|
622
|
+
}, 'sync_resources failed with error');
|
|
623
|
+
|
|
624
|
+
logToolResult('sync_resources', false, undefined, error instanceof Error ? error : new Error(String(error)));
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
success: false,
|
|
628
|
+
error: {
|
|
629
|
+
code: error instanceof MCPServerError ? error.code : 'UNKNOWN_ERROR',
|
|
630
|
+
message: error instanceof Error ? error.message : String(error),
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Tool definition for registry
|
|
637
|
+
export const syncResourcesTool = {
|
|
638
|
+
name: 'sync_resources',
|
|
639
|
+
description: 'Synchronize subscribed resources to local filesystem',
|
|
640
|
+
inputSchema: {
|
|
641
|
+
type: 'object' as const,
|
|
642
|
+
properties: {
|
|
643
|
+
mode: {
|
|
644
|
+
type: 'string',
|
|
645
|
+
description: 'Sync mode: check (status only), incremental (updates only), full (all)',
|
|
646
|
+
enum: ['check', 'incremental', 'full'],
|
|
647
|
+
default: 'incremental',
|
|
648
|
+
},
|
|
649
|
+
scope: {
|
|
650
|
+
type: 'string',
|
|
651
|
+
description: 'Installation scope: global (~/.cursor/), workspace (.cursor/), or all',
|
|
652
|
+
enum: ['global', 'workspace', 'all'],
|
|
653
|
+
default: 'global',
|
|
654
|
+
},
|
|
655
|
+
types: {
|
|
656
|
+
type: 'array',
|
|
657
|
+
description: 'Filter by resource types (empty = all types)',
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
handler: syncResources,
|
|
662
|
+
};
|