@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dypai-ai/mcp",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "DYPAI MCP Server — AI agent toolkit for building and deploying full-stack apps",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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 = [
@@ -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
- // ── Wait for CF Pages build (poll up to ~2 min) ──────────────────────
184
- let buildResult = null
185
- const maxAttempts = 12 // 12 × 10s = 2 min max
186
- const pollInterval = 10_000 // 10 seconds
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
- url: result.url,
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: "success",
260
- build_duration: buildResult.duration,
261
- message: `Deployed ${files.length} files (${Math.round(total / 1024)} KB). ${label} build succeeded${buildResult.duration ? ` in ${buildResult.duration}s` : ""}. Live at ${result.url}${skippedMsg}`,
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}` }