@dypai-ai/mcp 1.3.1 → 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.
- package/package.json +1 -1
- package/src/api.js +21 -3
- package/src/index.js +401 -346
- package/src/tools/deploy.js +450 -103
- package/src/tools/storage.js +289 -0
- package/src/tools/sync.js +23 -1
|
@@ -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
|
+
}
|
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
|
-
|
|
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
|