@dypai-ai/mcp 1.0.8 → 1.0.10
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 +1 -1
- package/src/auto-update.js +198 -0
- package/src/index.js +8 -0
- package/src/tools/deploy.js +21 -68
package/package.json
CHANGED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update for @dypai-ai/mcp.
|
|
3
|
+
*
|
|
4
|
+
* On startup, checks the npm registry for a newer version. If found, performs
|
|
5
|
+
* the appropriate update (clear npx cache or `npm install -g`) and exits with
|
|
6
|
+
* code 0 so the host (Cursor / Claude / Trae / VSCode) re-spawns the process
|
|
7
|
+
* with the freshly installed version.
|
|
8
|
+
*
|
|
9
|
+
* Throttled to one check every 6h to avoid hammering the registry.
|
|
10
|
+
*
|
|
11
|
+
* Disable with: DYPAI_NO_AUTOUPDATE=1
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { homedir, tmpdir } from "node:os";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const PKG_PATH = join(__dirname, "..", "package.json");
|
|
22
|
+
const PKG_NAME = "@dypai-ai/mcp";
|
|
23
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PKG_NAME}/latest`;
|
|
24
|
+
const CHECK_TIMEOUT_MS = 2000;
|
|
25
|
+
const THROTTLE_HOURS = 6;
|
|
26
|
+
const STATE_FILE = join(tmpdir(), "dypai-mcp-update-state.json");
|
|
27
|
+
|
|
28
|
+
function log(msg) {
|
|
29
|
+
// stderr — stdout is reserved for the MCP protocol
|
|
30
|
+
console.error(`[dypai-mcp:auto-update] ${msg}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readState() {
|
|
34
|
+
try { return JSON.parse(readFileSync(STATE_FILE, "utf-8")); } catch { return {}; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeState(state) {
|
|
38
|
+
try { writeFileSync(STATE_FILE, JSON.stringify(state)); } catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getCurrentVersion() {
|
|
42
|
+
try { return JSON.parse(readFileSync(PKG_PATH, "utf-8")).version; }
|
|
43
|
+
catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** semver compare: returns 1 if a > b, -1 if a < b, 0 if equal. */
|
|
47
|
+
function compareVersions(a, b) {
|
|
48
|
+
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
49
|
+
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
50
|
+
for (let i = 0; i < 3; i += 1) {
|
|
51
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
52
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
53
|
+
}
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchLatestManifest() {
|
|
58
|
+
const ctrl = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => ctrl.abort(), CHECK_TIMEOUT_MS);
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(REGISTRY_URL, { signal: ctrl.signal });
|
|
62
|
+
if (!res.ok) return null;
|
|
63
|
+
return await res.json();
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
} finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* After npm publish there's a 30s–2min window where the registry knows the
|
|
73
|
+
* version but the tarball is not yet replicated across the CDN. If we clear
|
|
74
|
+
* the cache and exit during that window, the next spawn fails to download
|
|
75
|
+
* and the MCP becomes unusable.
|
|
76
|
+
*
|
|
77
|
+
* This does a HEAD on the tarball URL with a small timeout. Returns true only
|
|
78
|
+
* if the artifact is actually retrievable.
|
|
79
|
+
*/
|
|
80
|
+
async function isTarballReady(tarballUrl) {
|
|
81
|
+
if (!tarballUrl) return false;
|
|
82
|
+
const ctrl = new AbortController();
|
|
83
|
+
const timer = setTimeout(() => ctrl.abort(), CHECK_TIMEOUT_MS);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(tarballUrl, { method: "HEAD", signal: ctrl.signal });
|
|
86
|
+
return res.ok;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Detect how this process is running so we know how to update. */
|
|
95
|
+
function detectInstallMethod() {
|
|
96
|
+
const scriptPath = process.argv[1] || "";
|
|
97
|
+
if (scriptPath.includes("/_npx/")) return "npx";
|
|
98
|
+
if (scriptPath.includes("/node_modules/") && scriptPath.includes("/.bin/")) return "global";
|
|
99
|
+
// global installs typically resolve to /usr/local/lib/... on mac/linux or %APPDATA%/npm on windows
|
|
100
|
+
if (scriptPath.includes("/lib/node_modules/")) return "global";
|
|
101
|
+
if (scriptPath.match(/[\\/]npm[\\/]node_modules[\\/]/)) return "global";
|
|
102
|
+
return "unknown";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Wipe ONLY this package's cache from the user's npx cache directory. */
|
|
106
|
+
function clearNpxCacheForPackage() {
|
|
107
|
+
const npxRoot = join(homedir(), ".npm", "_npx");
|
|
108
|
+
if (!existsSync(npxRoot)) return false;
|
|
109
|
+
|
|
110
|
+
let cleared = 0;
|
|
111
|
+
for (const dir of readdirSync(npxRoot)) {
|
|
112
|
+
const fullPath = join(npxRoot, dir);
|
|
113
|
+
try {
|
|
114
|
+
if (!statSync(fullPath).isDirectory()) continue;
|
|
115
|
+
const pkgManifest = join(fullPath, "node_modules", PKG_NAME, "package.json");
|
|
116
|
+
if (existsSync(pkgManifest)) {
|
|
117
|
+
rmSync(fullPath, { recursive: true, force: true });
|
|
118
|
+
cleared += 1;
|
|
119
|
+
}
|
|
120
|
+
} catch { /* ignore */ }
|
|
121
|
+
}
|
|
122
|
+
return cleared > 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function installGlobalLatest() {
|
|
126
|
+
try {
|
|
127
|
+
execSync(`npm install -g ${PKG_NAME}@latest`, {
|
|
128
|
+
stdio: "pipe",
|
|
129
|
+
timeout: 60_000,
|
|
130
|
+
});
|
|
131
|
+
return true;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
log(`global install failed: ${e.message?.slice(0, 200)}`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Main entry point — call once at startup.
|
|
140
|
+
* Returns quickly. Exits the process if an update was applied.
|
|
141
|
+
*/
|
|
142
|
+
export async function checkForUpdates({ force = false } = {}) {
|
|
143
|
+
if (process.env.DYPAI_NO_AUTOUPDATE === "1") return { skipped: "disabled" };
|
|
144
|
+
|
|
145
|
+
const current = getCurrentVersion();
|
|
146
|
+
if (!current) return { skipped: "no current version" };
|
|
147
|
+
|
|
148
|
+
// Throttle
|
|
149
|
+
if (!force) {
|
|
150
|
+
const state = readState();
|
|
151
|
+
if (state.lastCheckAt) {
|
|
152
|
+
const hoursAgo = (Date.now() - state.lastCheckAt) / 3_600_000;
|
|
153
|
+
if (hoursAgo < THROTTLE_HOURS) return { skipped: "throttled" };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const manifest = await fetchLatestManifest();
|
|
158
|
+
const latest = manifest?.version || null;
|
|
159
|
+
const tarballUrl = manifest?.dist?.tarball || null;
|
|
160
|
+
writeState({ lastCheckAt: Date.now(), lastSeenLatest: latest, current });
|
|
161
|
+
|
|
162
|
+
if (!latest) return { skipped: "registry unreachable" };
|
|
163
|
+
if (compareVersions(latest, current) <= 0) return { uptodate: true, current };
|
|
164
|
+
|
|
165
|
+
log(`update available: ${current} → ${latest}`);
|
|
166
|
+
|
|
167
|
+
// Critical safety: don't clear the cache until the new tarball is actually
|
|
168
|
+
// downloadable. Right after `npm publish` the version is visible in the
|
|
169
|
+
// registry metadata up to ~2 min before the tarball replicates to all CDNs.
|
|
170
|
+
// If we clear the cache + exit during that window, next spawn fails.
|
|
171
|
+
const ready = await isTarballReady(tarballUrl);
|
|
172
|
+
if (!ready) {
|
|
173
|
+
log(`new version ${latest} not yet downloadable from CDN; will retry later`);
|
|
174
|
+
return { skipped: "tarball not ready" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const method = detectInstallMethod();
|
|
178
|
+
let updated = false;
|
|
179
|
+
|
|
180
|
+
if (method === "npx") {
|
|
181
|
+
log(`tarball verified — clearing npx cache so the next spawn pulls ${PKG_NAME}@${latest}`);
|
|
182
|
+
updated = clearNpxCacheForPackage();
|
|
183
|
+
} else if (method === "global") {
|
|
184
|
+
log(`tarball verified — running: npm install -g ${PKG_NAME}@latest`);
|
|
185
|
+
updated = installGlobalLatest();
|
|
186
|
+
} else {
|
|
187
|
+
log(`unknown install method (script=${process.argv[1] || "?"}); skipping self-update`);
|
|
188
|
+
return { skipped: "unknown install method" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!updated) {
|
|
192
|
+
log("update step did not succeed; continuing with current version");
|
|
193
|
+
return { skipped: "update failed" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
log(`updated to ${latest}. Exiting so the IDE re-spawns the MCP with the new version.`);
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { createInterface } from "readline"
|
|
23
|
+
import { checkForUpdates } from "./auto-update.js"
|
|
23
24
|
import { deployTool } from "./tools/deploy.js"
|
|
24
25
|
import { scaffoldTool } from "./tools/scaffold.js"
|
|
25
26
|
import { addDomainTool, listDomainsTool, removeDomainTool } from "./tools/domains.js"
|
|
@@ -27,6 +28,13 @@ import { frontendStatusTool, buildStatusTool, listDeploymentsTool, getDeployment
|
|
|
27
28
|
import { bulkUpsertTool } from "./tools/bulk-upsert.js"
|
|
28
29
|
import { proxyToolCall } from "./tools/proxy.js"
|
|
29
30
|
|
|
31
|
+
// ── Self-update ─────────────────────────────────────────────────────────────
|
|
32
|
+
// Throttled (6h) check against the npm registry. If a newer version is
|
|
33
|
+
// available, the update is performed and the process exits cleanly so the
|
|
34
|
+
// IDE re-spawns it with the latest. Disable with DYPAI_NO_AUTOUPDATE=1.
|
|
35
|
+
// Network failures are silently ignored — never blocks startup more than ~2s.
|
|
36
|
+
await checkForUpdates().catch(() => {})
|
|
37
|
+
|
|
30
38
|
// ── Local tools (filesystem access) ─────────────────────────────────────────
|
|
31
39
|
|
|
32
40
|
const LOCAL_TOOLS = [
|
package/src/tools/deploy.js
CHANGED
|
@@ -180,85 +180,38 @@ After deploying, the site is live at https://{slug}.dypai.app within ~30s.`,
|
|
|
180
180
|
|
|
181
181
|
const label = framework?.label ?? "Project"
|
|
182
182
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
189
|
-
await new Promise(r => setTimeout(r, pollInterval))
|
|
190
|
-
try {
|
|
191
|
-
const status = await api.get(`/api/engine/${project_id}/frontend/build-status`)
|
|
192
|
-
const s = status?.status
|
|
193
|
-
if (s === "success") {
|
|
194
|
-
buildResult = { status: "success", url: result.url, duration: status.duration_seconds }
|
|
195
|
-
break
|
|
196
|
-
}
|
|
197
|
-
if (s === "failure") {
|
|
198
|
-
// Fetch build logs for error context
|
|
199
|
-
let logs = status.build_error || null
|
|
200
|
-
if (!logs) {
|
|
201
|
-
try {
|
|
202
|
-
const deployments = await api.get(`/api/engine/${project_id}/frontend/deployments?limit=1`)
|
|
203
|
-
const depId = deployments?.deployments?.[0]?.id
|
|
204
|
-
if (depId) {
|
|
205
|
-
const logRes = await api.get(`/api/engine/${project_id}/frontend/deployments/${depId}/logs`)
|
|
206
|
-
if (logRes?.logs) {
|
|
207
|
-
const lines = logRes.logs.split("\n")
|
|
208
|
-
logs = lines.slice(-30).join("\n")
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
} catch {}
|
|
212
|
-
}
|
|
213
|
-
buildResult = { status: "failure", error: logs || "Build failed — check logs with get_deployment_logs" }
|
|
214
|
-
break
|
|
215
|
-
}
|
|
216
|
-
// still queued/building — keep polling
|
|
217
|
-
} catch {
|
|
218
|
-
// build-status not available yet, keep polling
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (!buildResult) {
|
|
223
|
-
// Timed out — build still in progress
|
|
224
|
-
return {
|
|
225
|
-
success: true,
|
|
226
|
-
url: result.url,
|
|
227
|
-
files_pushed: files.length,
|
|
228
|
-
size_bytes: total,
|
|
229
|
-
framework: label,
|
|
230
|
-
build_status: "building",
|
|
231
|
-
message: `Deployed ${files.length} files (${Math.round(total / 1024)} KB). ${label} build still in progress at ${result.url} — use get_build_status to check progress.`,
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (buildResult.status === "failure") {
|
|
236
|
-
return {
|
|
237
|
-
success: false,
|
|
238
|
-
url: result.url,
|
|
239
|
-
files_pushed: files.length,
|
|
240
|
-
framework: label,
|
|
241
|
-
build_status: "failure",
|
|
242
|
-
build_error: buildResult.error,
|
|
243
|
-
message: `Deploy pushed ${files.length} files but the ${label} build FAILED. Build error:\n${buildResult.error}`,
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
183
|
+
// Fire-and-forget: return immediately with "queued" status. The agent
|
|
184
|
+
// is expected to call `get_build_status` to poll progress until the
|
|
185
|
+
// terminal "success" / "failure" is reached. Blocking here would stall
|
|
186
|
+
// the agent for up to 2 minutes and prevent it from doing anything
|
|
187
|
+
// else in parallel.
|
|
247
188
|
const skippedMsg = skipped.length > 0
|
|
248
189
|
? `\n\nSkipped ${skipped.length} file(s):\n${skipped.map(s => ` - ${s.path}: ${s.reason}`).join("\n")}`
|
|
249
190
|
: ""
|
|
250
191
|
|
|
251
192
|
return {
|
|
252
193
|
success: true,
|
|
253
|
-
|
|
194
|
+
deployed: false, // explicit: not live yet, still building
|
|
195
|
+
url: result.url, // final URL once success
|
|
254
196
|
files_pushed: files.length,
|
|
255
197
|
files_skipped: skipped.length,
|
|
256
198
|
skipped_files: skipped.length > 0 ? skipped : undefined,
|
|
257
199
|
size_bytes: total,
|
|
258
200
|
framework: label,
|
|
259
|
-
build_status: "
|
|
260
|
-
|
|
261
|
-
|
|
201
|
+
build_status: "queued",
|
|
202
|
+
next_step: {
|
|
203
|
+
action: "poll_build_status",
|
|
204
|
+
tool: "get_build_status",
|
|
205
|
+
arg: { project_id },
|
|
206
|
+
interval_seconds: 5,
|
|
207
|
+
expected_total_seconds: 60,
|
|
208
|
+
terminal_statuses: ["success", "failure"],
|
|
209
|
+
},
|
|
210
|
+
message:
|
|
211
|
+
`Deploy accepted — ${files.length} files (${Math.round(total / 1024)} KB) queued. ` +
|
|
212
|
+
`The build is running in the cloud (${label}, typically 20–60s). ` +
|
|
213
|
+
`Call get_build_status with project_id="${project_id}" every ~5s until status is "success" or "failure" ` +
|
|
214
|
+
`before reporting completion to the user. The site is NOT live yet.${skippedMsg}`,
|
|
262
215
|
}
|
|
263
216
|
} catch (e) {
|
|
264
217
|
return { error: `Deploy failed: ${e.message}` }
|