@dypai-ai/mcp 1.3.0 → 1.4.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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * manage_storage — local extension adding an `upload_file` operation.
3
+ *
4
+ * The remote `manage_storage` tool already covers list / create / delete buckets
5
+ * and list_objects / delete_object / get_signed_download_url. What it CAN'T do
6
+ * is upload: the binary would have to travel through the MCP stdio transport
7
+ * which is (a) slow and (b) saturates the remote pod on 100 MB files.
8
+ *
9
+ * `upload_file` solves this with the same pattern `dypai.api.upload` uses in
10
+ * the frontend SDK:
11
+ *
12
+ * 1. Ask the remote MCP → API for a signed PUT URL (op: sign_upload).
13
+ * 2. PUT the file binary DIRECTLY to R2 from the local machine (no infra hop).
14
+ * 3. Ask the remote MCP → API to register the object (op: verify_upload).
15
+ *
16
+ * Net effect: the agent just calls `manage_storage(operation:"upload_file",
17
+ * local_path:"...", bucket:"public")` and gets back a URL. Zero credentials on
18
+ * the agent side — the signed URL IS the temporary credential (15 min, one key,
19
+ * PUT only).
20
+ *
21
+ * NOTE: this file exports `uploadFile` plus the pieces needed to extend the
22
+ * `manage_storage` tool entry in index.js. The catalog-level tool definition
23
+ * still lives in index.js because manage_storage is a REMOTE tool with a local
24
+ * operation grafted on top — the dispatcher logic lives there too.
25
+ */
26
+
27
+ import { statSync, readFileSync, existsSync } from "fs"
28
+ import { basename } from "path"
29
+ import { proxyToolCall } from "./proxy.js"
30
+ import { updateMediaManifest } from "./deploy.js"
31
+
32
+ // 100 MB — enforced by the API (MAX_UPLOAD_SIZE_BYTES). We check client-side too
33
+ // so the user sees a clean error before we waste a round-trip.
34
+ const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
35
+
36
+ // Extensions we know how to MIME-type without shelling out. Everything else
37
+ // falls back to application/octet-stream, which R2 accepts fine — the browser
38
+ // just won't preview it inline.
39
+ const MIME_BY_EXT = {
40
+ // Images
41
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
42
+ webp: "image/webp", avif: "image/avif", svg: "image/svg+xml", ico: "image/x-icon",
43
+ bmp: "image/bmp", tiff: "image/tiff",
44
+ // Video
45
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", mkv: "video/x-matroska",
46
+ // Audio
47
+ mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4",
48
+ flac: "audio/flac",
49
+ // Documents
50
+ pdf: "application/pdf",
51
+ doc: "application/msword",
52
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
53
+ xls: "application/vnd.ms-excel",
54
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
55
+ ppt: "application/vnd.ms-powerpoint",
56
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
57
+ // Text / data
58
+ txt: "text/plain", csv: "text/csv", json: "application/json",
59
+ xml: "application/xml", html: "text/html", md: "text/markdown",
60
+ yaml: "application/yaml", yml: "application/yaml",
61
+ // Archives
62
+ zip: "application/zip", gz: "application/gzip", tar: "application/x-tar",
63
+ // Fonts
64
+ woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf",
65
+ }
66
+
67
+ function mimeFor(filename) {
68
+ const ext = filename.includes(".") ? filename.split(".").pop().toLowerCase() : ""
69
+ return MIME_BY_EXT[ext] || "application/octet-stream"
70
+ }
71
+
72
+ function formatBytes(n) {
73
+ if (n < 1024) return `${n} B`
74
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
75
+ return `${(n / 1024 / 1024).toFixed(1)} MB`
76
+ }
77
+
78
+ /**
79
+ * Core upload. Returns { success, bucket, name, size, content_type,
80
+ * signed_url, public_url, object_id, ... } or { success: false, error }.
81
+ *
82
+ * Flow:
83
+ * 1. stat → size + mime
84
+ * 2. ensure_bucket (optional) → list or create bucket if missing
85
+ * 3. sign_upload → presigned PUT URL
86
+ * 4. fetch(PUT, body=fileBuffer)
87
+ * 5. verify_upload → row in storage.objects
88
+ * 6. sign a short-lived download URL so the agent has something clickable
89
+ */
90
+ export async function uploadFile({
91
+ local_path,
92
+ bucket,
93
+ object_name,
94
+ prefix,
95
+ content_type,
96
+ ensure_bucket = false,
97
+ bucket_public = false,
98
+ project_id,
99
+ // Optional: if provided, the manifest at <source_directory>/dypai/.dypai/media-manifest.json
100
+ // is updated after a successful upload so future deploys know this file is in the bucket.
101
+ source_directory,
102
+ }) {
103
+ // ── Validation ──────────────────────────────────────────────────────────
104
+ if (!local_path) return { success: false, error: "`local_path` is required." }
105
+ if (!bucket) return { success: false, error: "`bucket` is required (e.g. 'public')." }
106
+ if (!existsSync(local_path)) {
107
+ return { success: false, error: `File not found: ${local_path}` }
108
+ }
109
+
110
+ const stat = statSync(local_path)
111
+ if (!stat.isFile()) {
112
+ return { success: false, error: `Not a file: ${local_path}` }
113
+ }
114
+ if (stat.size === 0) {
115
+ return { success: false, error: `File is empty: ${local_path}` }
116
+ }
117
+ if (stat.size > MAX_UPLOAD_BYTES) {
118
+ return {
119
+ success: false,
120
+ error: `File too large: ${formatBytes(stat.size)} (max ${formatBytes(MAX_UPLOAD_BYTES)}). Split the file or host it externally.`,
121
+ }
122
+ }
123
+
124
+ const filename = object_name || basename(local_path)
125
+ const resolvedContentType = content_type || mimeFor(filename)
126
+
127
+ // ── Ensure bucket (optional) ────────────────────────────────────────────
128
+ // If the agent passed ensure_bucket:true and the bucket doesn't exist yet,
129
+ // create it. Saves a round-trip of "bucket not found → go create → retry".
130
+ if (ensure_bucket) {
131
+ try {
132
+ const buckets = await proxyToolCall("manage_storage", {
133
+ operation: "list",
134
+ project_id,
135
+ })
136
+ const list = Array.isArray(buckets) ? buckets : (buckets?.data || [])
137
+ const exists = list.some(b => (b.name || b) === bucket)
138
+ if (!exists) {
139
+ await proxyToolCall("manage_storage", {
140
+ operation: "create",
141
+ project_id,
142
+ name: bucket,
143
+ public: bool(bucket_public),
144
+ })
145
+ }
146
+ } catch (e) {
147
+ return { success: false, error: `ensure_bucket failed: ${e.message}` }
148
+ }
149
+ }
150
+
151
+ // ── Step 1: sign_upload ─────────────────────────────────────────────────
152
+ let signed
153
+ try {
154
+ signed = await proxyToolCall("manage_storage", {
155
+ operation: "sign_upload",
156
+ project_id,
157
+ bucket,
158
+ filename,
159
+ content_type: resolvedContentType,
160
+ size_bytes: stat.size,
161
+ prefix,
162
+ })
163
+ } catch (e) {
164
+ return { success: false, error: `sign_upload failed: ${e.message}`, hint: bucketHint(e.message, bucket) }
165
+ }
166
+
167
+ const { upload_url, method = "PUT", headers = {}, file_path } = signed || {}
168
+ if (!upload_url || !file_path) {
169
+ return { success: false, error: "sign_upload returned an invalid payload (no upload_url or file_path)." }
170
+ }
171
+
172
+ // ── Step 2: direct PUT to R2 ────────────────────────────────────────────
173
+ let buf
174
+ try {
175
+ buf = readFileSync(local_path)
176
+ } catch (e) {
177
+ return { success: false, error: `Failed to read file: ${e.message}` }
178
+ }
179
+
180
+ try {
181
+ const res = await fetch(upload_url, {
182
+ method,
183
+ headers: {
184
+ "Content-Type": resolvedContentType,
185
+ ...headers,
186
+ },
187
+ body: buf,
188
+ })
189
+ if (!res.ok) {
190
+ const detail = await safeReadBody(res)
191
+ return {
192
+ success: false,
193
+ error: `R2 PUT failed with status ${res.status}. ${detail ? `Detail: ${detail.slice(0, 300)}` : ""}`.trim(),
194
+ }
195
+ }
196
+ } catch (e) {
197
+ return { success: false, error: `Direct upload to R2 failed: ${e.message}` }
198
+ }
199
+
200
+ // ── Step 3: verify_upload ───────────────────────────────────────────────
201
+ let verified
202
+ try {
203
+ verified = await proxyToolCall("manage_storage", {
204
+ operation: "verify_upload",
205
+ project_id,
206
+ bucket,
207
+ file_path,
208
+ original_filename: filename,
209
+ content_type: resolvedContentType,
210
+ size_bytes: stat.size,
211
+ prefix,
212
+ })
213
+ } catch (e) {
214
+ return {
215
+ success: false,
216
+ error: `verify_upload failed (the file WAS uploaded to R2 but not registered): ${e.message}. You can retry the upload — verify_upload is idempotent on (bucket, name).`,
217
+ }
218
+ }
219
+
220
+ // ── Step 4: fetch a preview signed download URL ─────────────────────────
221
+ // Best-effort: if it fails we just skip it, the upload succeeded regardless.
222
+ let signedDownloadUrl = null
223
+ try {
224
+ const dl = await proxyToolCall("manage_storage", {
225
+ operation: "get_signed_download_url",
226
+ project_id,
227
+ bucket,
228
+ name: verified?.name || filename,
229
+ expires_minutes: 15,
230
+ download: false,
231
+ })
232
+ signedDownloadUrl = dl?.signed_url || null
233
+ } catch {
234
+ // non-fatal
235
+ }
236
+
237
+ // ── Update manifest (if source_directory provided) ──────────────────────
238
+ // Tracks this upload so future deploy calls know the file is already in the
239
+ // bucket and don't re-recommend uploading it.
240
+ if (source_directory) {
241
+ try {
242
+ updateMediaManifest(source_directory, local_path, {
243
+ size: stat.size,
244
+ bucket,
245
+ object_name: verified?.name || filename,
246
+ content_type: resolvedContentType,
247
+ uploaded_at: new Date().toISOString(),
248
+ })
249
+ } catch {
250
+ // non-fatal — manifest update failure shouldn't break the upload response
251
+ }
252
+ }
253
+
254
+ return {
255
+ success: true,
256
+ bucket,
257
+ name: verified?.name || filename,
258
+ object_id: verified?.id || null,
259
+ size_bytes: stat.size,
260
+ size_human: formatBytes(stat.size),
261
+ content_type: resolvedContentType,
262
+ signed_url: signedDownloadUrl,
263
+ signed_url_expires_minutes: signedDownloadUrl ? 15 : null,
264
+ public_url: null,
265
+ message: `✓ Uploaded '${filename}' (${formatBytes(stat.size)}) to bucket '${bucket}'. Manifest updated — future deploys will skip this file.`,
266
+ }
267
+ }
268
+
269
+ function bool(v) {
270
+ if (typeof v === "boolean") return v
271
+ if (v === "true" || v === 1 || v === "1") return true
272
+ return false
273
+ }
274
+
275
+ function bucketHint(errMsg, bucket) {
276
+ if (!errMsg) return null
277
+ const lower = errMsg.toLowerCase()
278
+ if (lower.includes("not found") || lower.includes("no encontrado")) {
279
+ return `Bucket '${bucket}' may not exist. Either retry with ensure_bucket:true, or create it first: manage_storage({operation:"create", name:"${bucket}", public:true}).`
280
+ }
281
+ if (lower.includes("quota") || lower.includes("exceeded")) {
282
+ return "Storage quota exceeded for this project. Delete unused objects or upgrade the plan."
283
+ }
284
+ return null
285
+ }
286
+
287
+ async function safeReadBody(res) {
288
+ try { return await res.text() } catch { return null }
289
+ }
@@ -12,7 +12,10 @@
12
12
  import { proxyToolCall } from "../proxy.js"
13
13
 
14
14
  async function execSql(projectId, sql) {
15
- const args = projectId ? { project_id: projectId, sql } : { sql }
15
+ // Bypass remote execute_sql auto-LIMIT 20 so describe counts match reality.
16
+ const hasLimit = /\bLIMIT\b/i.test(sql)
17
+ const finalSql = hasLimit ? sql : `${sql.replace(/;?\s*$/, "")} LIMIT 100000`
18
+ const args = projectId ? { project_id: projectId, sql: finalSql } : { sql: finalSql }
16
19
  const result = await proxyToolCall("execute_sql", args)
17
20
  if (result?.error) return null
18
21
  if (!result?.rows) return null
@@ -54,7 +57,6 @@ export const dypaiDescribeTool = {
54
57
  jsonb_array_length(e.workflow_code->'nodes') AS node_count
55
58
  FROM system.endpoints e
56
59
  LEFT JOIN system.endpoints_group g ON g.id = e.group_id
57
- WHERE e.is_active = true
58
60
  ORDER BY e.name
59
61
  `),
60
62
  execSql(project_id, "SELECT name, type FROM system.credentials ORDER BY name"),
@@ -17,7 +17,13 @@ import { deserializeEndpoint, serializeEndpoint } from "./codec.js"
17
17
  // ─── Remote ────────────────────────────────────────────────────────────────
18
18
 
19
19
  async function execSql(projectId, sql) {
20
- const args = projectId ? { project_id: projectId, sql } : { sql }
20
+ // Bypass the remote execute_sql safety that injects `LIMIT 20` when no LIMIT
21
+ // is present. Planner needs the FULL endpoint/credential/group lists to
22
+ // compute an accurate diff — truncation here would mean invisible deletes
23
+ // (local thinks "remote has only X" when it actually has X+N).
24
+ const hasLimit = /\bLIMIT\b/i.test(sql)
25
+ const finalSql = hasLimit ? sql : `${sql.replace(/;?\s*$/, "")} LIMIT 100000`
26
+ const args = projectId ? { project_id: projectId, sql: finalSql } : { sql: finalSql }
21
27
  const result = await proxyToolCall("execute_sql", args)
22
28
  if (result?.error) throw new Error(`SQL error: ${result.error}`)
23
29
  if (!result?.rows) throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
@@ -116,9 +122,8 @@ export async function fetchRemoteState(projectId) {
116
122
  const [endpoints, credentials, groups] = await Promise.all([
117
123
  execSql(projectId, `
118
124
  SELECT id, name, method, description, workflow_code, input, output,
119
- allowed_roles, is_tool, tool_description, group_id, updated_at
125
+ allowed_roles, is_tool, tool_description, group_id, is_active, updated_at
120
126
  FROM system.endpoints
121
- WHERE is_active = true
122
127
  ORDER BY name
123
128
  `),
124
129
  execSql(projectId, "SELECT id, name, type FROM system.credentials"),
@@ -441,7 +441,15 @@ workflow:
441
441
  `
442
442
 
443
443
  async function execSql(projectId, sql) {
444
- const args = projectId ? { project_id: projectId, sql } : { sql }
444
+ // The remote `execute_sql` silently injects `LIMIT 20` whenever a query has no
445
+ // LIMIT of its own — intended as a guard against ad-hoc queries returning
446
+ // gigantic result sets. For pull we want the COMPLETE lists of endpoints,
447
+ // credentials, groups, realtime policies, etc. Without an explicit LIMIT
448
+ // here, a project with >20 of any resource would silently lose rows.
449
+ // We append a generous cap (100k) only when the caller didn't specify one.
450
+ const hasLimit = /\bLIMIT\b/i.test(sql)
451
+ const finalSql = hasLimit ? sql : `${sql.replace(/;?\s*$/, "")} LIMIT 100000`
452
+ const args = projectId ? { project_id: projectId, sql: finalSql } : { sql: finalSql }
445
453
  const result = await proxyToolCall("execute_sql", args)
446
454
  if (result?.error) throw new Error(`SQL error: ${result.error}`)
447
455
  if (!result?.rows) {
@@ -553,9 +561,8 @@ export const dypaiPullTool = {
553
561
  const [endpoints, credentials, groups, schemaSql, nodeCatalogResult, realtimePolicies] = await Promise.all([
554
562
  execSql(project_id, `
555
563
  SELECT id, name, method, description, workflow_code, input, output,
556
- allowed_roles, is_tool, tool_description, group_id, updated_at
564
+ allowed_roles, is_tool, tool_description, group_id, is_active, updated_at
557
565
  FROM system.endpoints
558
- WHERE is_active = true
559
566
  ORDER BY name
560
567
  `),
561
568
  execSql(project_id, "SELECT id, name, type FROM system.credentials"),
@@ -650,7 +657,22 @@ export const dypaiPullTool = {
650
657
  const relPath = groupName
651
658
  ? `endpoints/${groupName}/${row.name}.yaml`
652
659
  : `endpoints/${row.name}.yaml`
653
- await writeFileEnsured(join(outDir, relPath), renderYaml(doc))
660
+
661
+ // Inactive endpoints ARE pulled (user needs to see them), but flagged
662
+ // with a visible header so the agent/user knows the endpoint won't
663
+ // execute until re-enabled in the dashboard. `is_active` isn't
664
+ // serialized into the YAML itself — push only updates content, never
665
+ // the active flag, so editing + pushing an inactive endpoint keeps it
666
+ // inactive (safe default).
667
+ const yamlBody = renderYaml(doc)
668
+ const content = row.is_active === false
669
+ ? "# ⚠️ INACTIVE on the engine — this endpoint will NOT execute until re-enabled in the dashboard.\n" +
670
+ "# Edits applied via `dypai_push` are still saved, but the endpoint stays paused.\n" +
671
+ "# To reactivate: go to the project dashboard → Endpoints → toggle this one back on.\n\n" +
672
+ yamlBody
673
+ : yamlBody
674
+
675
+ await writeFileEnsured(join(outDir, relPath), content)
654
676
  filesWritten.push(relPath)
655
677
  } catch (e) {
656
678
  errors.push({ endpoint: row.name, error: e.message })
@@ -730,6 +752,9 @@ export const dypaiPullTool = {
730
752
  const toolEndpoints = (endpoints || [])
731
753
  .filter(e => e.is_tool)
732
754
  .map(e => ({ name: e.name, description: e.tool_description || null }))
755
+ const inactiveEndpoints = (endpoints || [])
756
+ .filter(e => e.is_active === false)
757
+ .map(e => e.name)
733
758
 
734
759
  const overview = {
735
760
  project: projectInfo ? {
@@ -739,9 +764,12 @@ export const dypaiPullTool = {
739
764
  } : { id: resolvedProjectId || "(from token)" },
740
765
  endpoints: {
741
766
  total: (endpoints || []).length,
767
+ active: (endpoints || []).filter(e => e.is_active !== false).length,
768
+ inactive: inactiveEndpoints.length,
742
769
  groups: Object.keys(byGroup).filter(g => g !== "(no group)").sort(),
743
770
  by_group: byGroup,
744
771
  tool_endpoints: toolEndpoints,
772
+ inactive_endpoints: inactiveEndpoints.length > 0 ? inactiveEndpoints : undefined,
745
773
  },
746
774
  credentials: (credentials || []).map(c => ({ name: c.name, type: c.type })),
747
775
  realtime_policies: (realtimePolicies || []).length,
@@ -7,7 +7,13 @@
7
7
  import { proxyToolCall } from "../proxy.js"
8
8
 
9
9
  async function execSql(projectId, sql) {
10
- const args = projectId ? { project_id: projectId, sql } : { sql }
10
+ // Bypass remote execute_sql auto-LIMIT 20 so the schema dump is complete.
11
+ // Without this, a project with >20 columns across public.* (very normal)
12
+ // would produce a truncated schema.sql silently — breaks the validator and
13
+ // any agent that reads schema.sql before writing SQL.
14
+ const hasLimit = /\bLIMIT\b/i.test(sql)
15
+ const finalSql = hasLimit ? sql : `${sql.replace(/;?\s*$/, "")} LIMIT 100000`
16
+ const args = projectId ? { project_id: projectId, sql: finalSql } : { sql: finalSql }
11
17
  const result = await proxyToolCall("execute_sql", args)
12
18
  if (result?.error) throw new Error(`SQL error: ${result.error}`)
13
19
  if (!result?.rows) throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
package/src/tools/sync.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { writeFileSync, mkdirSync, existsSync } from "fs"
16
16
  import { join, dirname } from "path"
17
+ import { createHash } from "crypto"
17
18
  import { api } from "../api.js"
18
19
 
19
20
  export async function syncFromRemote({ project_id, targetDirectory, overwrite = false }) {
@@ -60,6 +61,7 @@ export async function syncFromRemote({ project_id, targetDirectory, overwrite =
60
61
 
61
62
  let written = 0
62
63
  const failures = []
64
+ const hashMap = {}
63
65
 
64
66
  for (const file of payload.files) {
65
67
  if (!file?.path || typeof file.content !== "string") {
@@ -81,13 +83,33 @@ export async function syncFromRemote({ project_id, targetDirectory, overwrite =
81
83
  // `content` is base64 from the API (uniform encoding, binary-safe —
82
84
  // same convention as the deploy). Decode to a Buffer so binaries
83
85
  // (images, fonts, etc.) round-trip cleanly.
84
- writeFileSync(fullPath, Buffer.from(file.content, "base64"))
86
+ const buf = Buffer.from(file.content, "base64")
87
+ writeFileSync(fullPath, buf)
88
+ hashMap[file.path] = createHash("sha256").update(buf).digest("hex")
85
89
  written++
86
90
  } catch (e) {
87
91
  failures.push({ path: file.path, reason: e.message })
88
92
  }
89
93
  }
90
94
 
95
+ // Seed the deploy manifest with the synced files' hashes. The next deploy
96
+ // sees the full project already matches the remote and only uploads what
97
+ // the agent actually edits — avoids a pointless 30 MB "full deploy" right
98
+ // after a sync when nothing has changed yet.
99
+ try {
100
+ const dypaiBase = existsSync(join(targetDirectory, "dypai"))
101
+ ? join(targetDirectory, "dypai", ".dypai")
102
+ : join(targetDirectory, ".dypai")
103
+ mkdirSync(dypaiBase, { recursive: true })
104
+ writeFileSync(
105
+ join(dypaiBase, "deploy-manifest.json"),
106
+ JSON.stringify(hashMap, null, 2) + "\n",
107
+ )
108
+ } catch (e) {
109
+ // Non-fatal: the first deploy will just be a full deploy.
110
+ failures.push({ path: ".dypai/deploy-manifest.json", reason: `Manifest seed failed: ${e.message}` })
111
+ }
112
+
91
113
  // Structured next_steps so the agent can act without re-prompting the LLM.
92
114
  // `npm install` is always suggested (idempotent and cheap). When we overwrote
93
115
  // an existing project the user's local files removed upstream are NOT