@dypai-ai/mcp 1.3.1 → 1.4.1

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
+ }
@@ -74,14 +74,17 @@ export const NODE_FIELD_TRANSFORMS = [
74
74
  name: "sql_extraction",
75
75
  appliesWhen: (nodeType) => nodeType === "dypai_database",
76
76
  pull(params, ctx) {
77
- if (params.query && !shouldInlineSql(params.query)) {
78
- // Flat layout: sql/<endpoint>.sql if only one SQL node, else sql/<endpoint>.<node>.sql
79
- const suffix = ctx.sqlNodeCount > 1 ? `.${ctx.nodeId}` : ""
80
- const path = `sql/${ctx.endpointName}${suffix}.sql`
81
- ctx.emitFile(path, params.query.trim() + "\n")
82
- return { query_file: path }
77
+ if (!params.query) return {}
78
+ if (shouldInlineSql(params.query)) {
79
+ // Short SQL stays inline. Must be re-emitted here because
80
+ // `pullConsumes: ["query"]` deletes it from the base object.
81
+ return { query: params.query }
83
82
  }
84
- return {}
83
+ // Flat layout: sql/<endpoint>.sql if only one SQL node, else sql/<endpoint>.<node>.sql
84
+ const suffix = ctx.sqlNodeCount > 1 ? `.${ctx.nodeId}` : ""
85
+ const path = `sql/${ctx.endpointName}${suffix}.sql`
86
+ ctx.emitFile(path, params.query.trim() + "\n")
87
+ return { query_file: path }
85
88
  },
86
89
  push(params, ctx) {
87
90
  if (params.query_file) {
@@ -97,13 +100,16 @@ export const NODE_FIELD_TRANSFORMS = [
97
100
  name: "prompt_extraction",
98
101
  appliesWhen: (nodeType) => nodeType === "agent",
99
102
  pull(params, ctx) {
100
- if (params.system_prompt && params.system_prompt.length > PROMPT_INLINE_MAX_CHARS) {
101
- const suffix = ctx.promptNodeCount > 1 ? `.${ctx.nodeId}` : ""
102
- const path = `prompts/${ctx.endpointName}${suffix}.md`
103
- ctx.emitFile(path, params.system_prompt.trim() + "\n")
104
- return { system_prompt_file: path }
103
+ if (!params.system_prompt) return {}
104
+ if (params.system_prompt.length <= PROMPT_INLINE_MAX_CHARS) {
105
+ // Short prompt stays inline. Must be re-emitted here because
106
+ // `pullConsumes: ["system_prompt"]` deletes it from the base object.
107
+ return { system_prompt: params.system_prompt }
105
108
  }
106
- return {}
109
+ const suffix = ctx.promptNodeCount > 1 ? `.${ctx.nodeId}` : ""
110
+ const path = `prompts/${ctx.endpointName}${suffix}.md`
111
+ ctx.emitFile(path, params.system_prompt.trim() + "\n")
112
+ return { system_prompt_file: path }
107
113
  },
108
114
  push(params, ctx) {
109
115
  if (params.system_prompt_file) {
@@ -119,14 +125,20 @@ export const NODE_FIELD_TRANSFORMS = [
119
125
  name: "code_extraction",
120
126
  appliesWhen: (nodeType) => nodeType === "javascript_code" || nodeType === "python_code",
121
127
  pull(params, ctx) {
122
- if (params.code && params.code.length > SQL_INLINE_MAX_CHARS) {
123
- const ext = ctx.nodeType === "python_code" ? "py" : "js"
124
- const suffix = ctx.codeNodeCount > 1 ? `.${ctx.nodeId}` : ""
125
- const path = `code/${ctx.endpointName}${suffix}.${ext}`
126
- ctx.emitFile(path, params.code.trim() + "\n")
127
- return { code_file: path }
128
+ if (!params.code) return {}
129
+ if (params.code.length <= SQL_INLINE_MAX_CHARS) {
130
+ // Short code stays inline. Must be re-emitted here because
131
+ // `pullConsumes: ["code"]` deletes it from the base object.
132
+ // Before this fix, short code was silently dropped from the YAML
133
+ // (neither extracted to a file nor kept inline) — endpoints pulled
134
+ // from the engine ended up with just `timeout_ms` and no handler.
135
+ return { code: params.code }
128
136
  }
129
- return {}
137
+ const ext = ctx.nodeType === "python_code" ? "py" : "js"
138
+ const suffix = ctx.codeNodeCount > 1 ? `.${ctx.nodeId}` : ""
139
+ const path = `code/${ctx.endpointName}${suffix}.${ext}`
140
+ ctx.emitFile(path, params.code.trim() + "\n")
141
+ return { code_file: path }
130
142
  },
131
143
  push(params, ctx) {
132
144
  if (params.code_file) {
@@ -580,7 +580,12 @@ function validateEndpoint(entry, ctx) {
580
580
  // Enum / range checks for primitive values
581
581
  const prop = properties[key]
582
582
  const v = node[key]
583
- if (prop.enum && typeof v === "string" && !prop.enum.includes(v)) {
583
+ // Skip enum validation if the value contains a ${...} placeholder —
584
+ // the actual value is resolved at runtime, so the engine enforces
585
+ // the enum then. Common case: operation: ${input.operation} in
586
+ // dypai_storage / dypai_database nodes.
587
+ const hasPlaceholder = typeof v === "string" && v.includes("${")
588
+ if (prop.enum && typeof v === "string" && !hasPlaceholder && !prop.enum.includes(v)) {
584
589
  diagnostics.push({
585
590
  severity: "error",
586
591
  rule: "param_enum_violation",
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