@dypai-ai/mcp 1.5.24 → 1.5.26

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,115 @@
1
+ /**
2
+ * Bridge to dypai-workspace/scripts/effective-workflows-cli.ts via bun.
3
+ * Used by validate, test-endpoint, planner/push for .flow.ts effective workflows.
4
+ */
5
+
6
+ import { spawnSync } from "node:child_process"
7
+ import { existsSync } from "node:fs"
8
+ import { dirname, join, resolve as resolvePath, basename } from "node:path"
9
+ import { fileURLToPath } from "node:url"
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url))
12
+
13
+ export function resolveProjectRootFromDypaiDir(dypaiRootDir) {
14
+ const resolved = resolvePath(dypaiRootDir)
15
+ return basename(resolved) === "dypai" ? dirname(resolved) : resolved
16
+ }
17
+
18
+ export function resolveMonorepoRoot() {
19
+ if (process.env.DYPAI_MONOREPO_ROOT) {
20
+ return resolvePath(process.env.DYPAI_MONOREPO_ROOT)
21
+ }
22
+
23
+ let cursor = __dirname
24
+ for (let i = 0; i < 10; i++) {
25
+ if (existsSync(join(cursor, "dypai-workspace")) && existsSync(join(cursor, "dypai-flow"))) {
26
+ return cursor
27
+ }
28
+ const parent = dirname(cursor)
29
+ if (parent === cursor) break
30
+ cursor = parent
31
+ }
32
+ return null
33
+ }
34
+
35
+ function parseCliStdout(stdout) {
36
+ const trimmed = (stdout || "").trim()
37
+ if (!trimmed) return { error: "effective-workflows-cli returned empty output" }
38
+ try {
39
+ return JSON.parse(trimmed)
40
+ } catch {
41
+ return { error: `effective-workflows-cli returned non-JSON output: ${trimmed.slice(0, 300)}` }
42
+ }
43
+ }
44
+
45
+ export function runEffectiveWorkflowsCli(projectRoot, commandArgs = []) {
46
+ const monorepoRoot = resolveMonorepoRoot()
47
+ if (!monorepoRoot) {
48
+ return {
49
+ ok: false,
50
+ unavailable: true,
51
+ warning: {
52
+ severity: "warn",
53
+ rule: "effective_workflow_runner_unavailable",
54
+ message: "Effective workflow runner unavailable (monorepo root not found). YAML workflows still work.",
55
+ fix_hint: "Set DYPAI_MONOREPO_ROOT or run MCP local from the DYPAI monorepo checkout with bun installed.",
56
+ },
57
+ }
58
+ }
59
+
60
+ const scriptPath = join(monorepoRoot, "dypai-workspace/scripts/effective-workflows-cli.ts")
61
+ if (!existsSync(scriptPath)) {
62
+ return {
63
+ ok: false,
64
+ unavailable: true,
65
+ warning: {
66
+ severity: "warn",
67
+ rule: "effective_workflow_runner_unavailable",
68
+ message: `Effective workflow CLI missing at ${scriptPath}.`,
69
+ },
70
+ }
71
+ }
72
+
73
+ const bunBin = process.env.DYPAI_BUN_BIN || "bun"
74
+ const args = ["run", scriptPath, ...commandArgs, "--root", resolvePath(projectRoot)]
75
+ const result = spawnSync(bunBin, args, {
76
+ encoding: "utf8",
77
+ cwd: monorepoRoot,
78
+ env: process.env,
79
+ })
80
+
81
+ if (result.error) {
82
+ return {
83
+ ok: false,
84
+ unavailable: true,
85
+ warning: {
86
+ severity: "warn",
87
+ rule: "effective_workflow_runner_unavailable",
88
+ message: `Failed to spawn ${bunBin}: ${result.error.message}`,
89
+ fix_hint: "Install bun or set DYPAI_BUN_BIN to a working bun executable.",
90
+ },
91
+ }
92
+ }
93
+
94
+ const parsed = parseCliStdout(result.stdout)
95
+ if (parsed.error && !parsed.diagnostics && !parsed.entries && !parsed.status) {
96
+ return {
97
+ ok: false,
98
+ unavailable: false,
99
+ error: parsed.error,
100
+ stderr: (result.stderr || "").trim() || undefined,
101
+ exitCode: result.status,
102
+ }
103
+ }
104
+
105
+ if (result.status !== 0 && !parsed.diagnostics && !parsed.entries && !parsed.status) {
106
+ return {
107
+ ok: false,
108
+ unavailable: false,
109
+ error: parsed.error || (result.stderr || "").trim() || `effective-workflows-cli exited with ${result.status}`,
110
+ exitCode: result.status,
111
+ }
112
+ }
113
+
114
+ return { ok: true, data: parsed }
115
+ }
@@ -0,0 +1,17 @@
1
+ import {
2
+ LOCAL_SERVER_INSTRUCTIONS,
3
+ STUDIO_DEBUG_SERVER_INSTRUCTIONS,
4
+ STUDIO_WORKER_SERVER_INSTRUCTIONS,
5
+ } from "./generated/serverInstructions.js";
6
+
7
+ export function loadLocalServerInstructions() {
8
+ return LOCAL_SERVER_INSTRUCTIONS;
9
+ }
10
+
11
+ export function loadStudioWorkerServerInstructions() {
12
+ return STUDIO_WORKER_SERVER_INSTRUCTIONS;
13
+ }
14
+
15
+ export function loadStudioDebugServerInstructions() {
16
+ return STUDIO_DEBUG_SERVER_INSTRUCTIONS;
17
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Studio worker search_docs filtering — hide local-only typegen docs.
3
+ */
4
+
5
+ export const STUDIO_EXCLUDED_DOC_FILES = new Set([
6
+ "docs/flow-types-codegen.md",
7
+ ]);
8
+
9
+ const STUDIO_TYPEGEN_CONTENT = /\bdypai_generate_types\b|endpoints\.gen\.ts|flow-types-codegen/i;
10
+
11
+ function normalizeSearchDocsPayload(result) {
12
+ if (typeof result === "string") {
13
+ try {
14
+ return JSON.parse(result);
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ if (result && typeof result === "object") return result;
20
+ return null;
21
+ }
22
+
23
+ export function filterSearchDocsForStudio(result) {
24
+ const payload = normalizeSearchDocsPayload(result);
25
+ if (!payload || !Array.isArray(payload.chunks)) {
26
+ return result;
27
+ }
28
+
29
+ const chunks = payload.chunks.filter((chunk) => {
30
+ const source = String(chunk?.source_file || "");
31
+ if (STUDIO_EXCLUDED_DOC_FILES.has(source)) return false;
32
+ const blob = `${chunk?.section_title || ""}\n${chunk?.content || ""}`;
33
+ if (STUDIO_TYPEGEN_CONTENT.test(blob)) return false;
34
+ return true;
35
+ });
36
+
37
+ return {
38
+ ...payload,
39
+ chunks,
40
+ results_count: chunks.length,
41
+ };
42
+ }
43
+
44
+ export const __testing = {
45
+ STUDIO_EXCLUDED_DOC_FILES,
46
+ STUDIO_TYPEGEN_CONTENT,
47
+ };
@@ -0,0 +1,230 @@
1
+ import {
2
+ loadLocalServerInstructions,
3
+ loadStudioDebugServerInstructions,
4
+ loadStudioWorkerServerInstructions,
5
+ } from "./promptLoader.js";
6
+
7
+ export const DEFAULT_MCP_PROFILE = "local";
8
+
9
+ const STUDIO_WORKER_TOOLS = [
10
+ "bulk_upsert",
11
+ "dypai_test_endpoint",
12
+ "dypai_validate",
13
+ "execute_sql",
14
+ "generate_image_asset",
15
+ "get_app_credentials",
16
+ "get_endpoint_versions",
17
+ "list_ai_models",
18
+ "manage_database",
19
+ "manage_roles",
20
+ "manage_schedules",
21
+ "manage_storage",
22
+ "manage_users",
23
+ "manage_webhooks",
24
+ "search_docs",
25
+ "search_logs",
26
+ ];
27
+
28
+ const STUDIO_DEBUG_TOOLS = [
29
+ ...STUDIO_WORKER_TOOLS,
30
+ ];
31
+
32
+ /** Removed from every MCP profile (local + studio). */
33
+ export const MCP_TOOLS_REMOVED = new Set([
34
+ "search_capabilities",
35
+ "get_capability_details",
36
+ "search_nodes",
37
+ "search_project_artifacts",
38
+ "fetch_project_artifact",
39
+ "manage_project_artifact",
40
+ "manage_project_access_profile",
41
+ "frontend_logs",
42
+ "search_design_patterns",
43
+ "search_project_templates",
44
+ "search_workflow_templates",
45
+ ]);
46
+
47
+ /** Local-only ship/deploy tools — excluded from Studio allowlists, not from local. */
48
+ export const STUDIO_SHIP_TOOLS = new Set([
49
+ "dypai_push",
50
+ "manage_drafts",
51
+ "manage_frontend",
52
+ ]);
53
+
54
+ /**
55
+ * MCP tool surface profiles.
56
+ * `local` = full catalog minus MCP_TOOLS_REMOVED.
57
+ * Studio profiles = explicit allowlists (subset of local).
58
+ */
59
+ export const MCP_TOOL_PROFILES = {
60
+ local: null,
61
+ "studio-worker": new Set(STUDIO_WORKER_TOOLS),
62
+ "studio-debug": new Set(STUDIO_DEBUG_TOOLS),
63
+ };
64
+
65
+ const STUDIO_FORBIDDEN_TOOL_NAMES = [
66
+ ...MCP_TOOLS_REMOVED,
67
+ ...STUDIO_SHIP_TOOLS,
68
+ ];
69
+
70
+ export function isStudioProfile(profile) {
71
+ return profile === "studio-worker" || profile === "studio-debug";
72
+ }
73
+
74
+ export function resolveMcpProfile(env = process.env) {
75
+ const raw = env.DYPAI_MCP_PROFILE;
76
+ const profile = (typeof raw === "string" && raw.trim())
77
+ ? raw.trim()
78
+ : DEFAULT_MCP_PROFILE;
79
+
80
+ if (!Object.prototype.hasOwnProperty.call(MCP_TOOL_PROFILES, profile)) {
81
+ throw new Error(
82
+ `Unknown DYPAI_MCP_PROFILE "${profile}". Valid profiles: ${Object.keys(MCP_TOOL_PROFILES).join(", ")}`,
83
+ );
84
+ }
85
+
86
+ return profile;
87
+ }
88
+
89
+ export function isToolAllowedForProfile(toolName, profile) {
90
+ if (MCP_TOOLS_REMOVED.has(toolName)) return false;
91
+ const allowed = MCP_TOOL_PROFILES[profile];
92
+ if (profile === "local" || allowed === null) return true;
93
+ return allowed.has(toolName);
94
+ }
95
+
96
+ export function filterToolsForProfile(tools, profile) {
97
+ return tools.filter((tool) => isToolAllowedForProfile(tool.name, profile));
98
+ }
99
+
100
+ export function getServerInstructionsForProfile(profile) {
101
+ if (profile === "studio-worker") {
102
+ return loadStudioWorkerServerInstructions();
103
+ }
104
+ if (profile === "studio-debug") {
105
+ return loadStudioDebugServerInstructions();
106
+ }
107
+ return loadLocalServerInstructions();
108
+ }
109
+
110
+ export function toolNotAllowedError(toolName, profile) {
111
+ return `Tool "${toolName}" is not available in DYPAI_MCP_PROFILE=${profile}`;
112
+ }
113
+
114
+ export function assertToolCallAllowedForProfile(toolName, profile) {
115
+ if (!isToolAllowedForProfile(toolName, profile)) {
116
+ throw new Error(toolNotAllowedError(toolName, profile));
117
+ }
118
+ }
119
+
120
+ /** Test helper: verify Studio profile instructions omit forbidden tool/workflow strings. */
121
+ export function assertStudioInstructionsSanitized(instructions, { allowDebugTools = false } = {}) {
122
+ for (const toolName of STUDIO_FORBIDDEN_TOOL_NAMES) {
123
+ if (instructions.includes(toolName)) {
124
+ throw new Error(`Studio instructions must not mention forbidden tool: ${toolName}`);
125
+ }
126
+ }
127
+
128
+ const withoutNegatedPublish = instructions.replace(/do not publish/gi, "");
129
+ if (/publish/i.test(withoutNegatedPublish)) {
130
+ throw new Error("Studio instructions must not mention publish outside 'do not publish'");
131
+ }
132
+
133
+ const withoutNegatedArtifacts = instructions.replace(/do not use artifacts[^\n]*/gi, "");
134
+ if (/\bartifacts?\b/i.test(withoutNegatedArtifacts)) {
135
+ throw new Error("Studio instructions must not promote artifacts");
136
+ }
137
+
138
+ if (/\bcapabilit(y|ies)\b/i.test(instructions)) {
139
+ throw new Error("Studio instructions must not mention capabilities");
140
+ }
141
+ if (/node catalog|node-catalog/i.test(instructions)) {
142
+ throw new Error("Studio instructions must not mention node catalog");
143
+ }
144
+ if (/responseCardinality/i.test(instructions)) {
145
+ throw new Error("Studio instructions must not mention responseCardinality");
146
+ }
147
+ if (/\bdypai_push\b/.test(instructions)) {
148
+ throw new Error("Studio instructions must not mention dypai_push");
149
+ }
150
+ if (/\bmanage_drafts\b/.test(instructions)) {
151
+ throw new Error("Studio instructions must not mention manage_drafts");
152
+ }
153
+ if (/\bmanage_frontend\b/.test(instructions)) {
154
+ throw new Error("Studio instructions must not mention manage_frontend");
155
+ }
156
+ const withoutNegatedProjectLifecycle = instructions.replace(
157
+ /do not call[^\n]*(?:create_project|list_projects|dypai_pull)[^\n]*/gi,
158
+ "",
159
+ );
160
+ if (/\bcreate_project\b/.test(withoutNegatedProjectLifecycle)) {
161
+ throw new Error("Studio instructions must not mention create_project");
162
+ }
163
+ if (/\blist_projects\b/.test(withoutNegatedProjectLifecycle)) {
164
+ throw new Error("Studio instructions must not mention list_projects");
165
+ }
166
+ if (/\bdypai_pull\b/.test(withoutNegatedProjectLifecycle)) {
167
+ throw new Error("Studio instructions must not mention dypai_pull");
168
+ }
169
+ if (/deploy frontend/i.test(instructions)) {
170
+ throw new Error("Studio instructions must not mention deploy frontend");
171
+ }
172
+ if (/search_design_patterns|design patterns catalog/i.test(instructions)) {
173
+ throw new Error("Studio instructions must not mention design patterns catalog");
174
+ }
175
+
176
+ const withoutNegatedYaml = instructions.replace(/do not create yaml endpoints?/gi, "");
177
+ if (/yaml endpoints?/i.test(withoutNegatedYaml)) {
178
+ throw new Error("Studio instructions must not promote YAML endpoint authoring");
179
+ }
180
+
181
+ if (!/DYPAI Studio worker/i.test(instructions)) {
182
+ throw new Error("Studio instructions must mention DYPAI Studio worker");
183
+ }
184
+ if (!/dypai\/flows\/\*\.flow\.ts/i.test(instructions)) {
185
+ throw new Error("Studio instructions must mention dypai/flows/*.flow.ts");
186
+ }
187
+ if (!/workflow templates|search_docs\("flow ts"\)/i.test(instructions)) {
188
+ throw new Error("Studio instructions must mention workflow templates or search_docs flow guidance");
189
+ }
190
+ if (!/\bdypai_validate\b/.test(instructions)) {
191
+ throw new Error("Studio instructions must mention dypai_validate");
192
+ }
193
+ if (!/do not publish/i.test(instructions)) {
194
+ throw new Error("Studio instructions must contain 'do not publish'");
195
+ }
196
+ if (!/do not ship/i.test(instructions)) {
197
+ throw new Error("Studio instructions must contain 'do not ship'");
198
+ }
199
+ if (!/orchestrator will sync|orchestrator runs|orchestrator owns|orchestrator updates|regenerate endpoint types/i.test(instructions)) {
200
+ throw new Error("Studio instructions must describe orchestrator sync/build/validate");
201
+ }
202
+
203
+ const withoutNegatedTypegen = instructions.replace(
204
+ /endpoint typescript types are handled by studio[^\n]*/gi,
205
+ "",
206
+ );
207
+ if (/\bdypai_generate_types\b/.test(withoutNegatedTypegen)) {
208
+ throw new Error("Studio instructions must not mention dypai_generate_types");
209
+ }
210
+ if (/\bendpoints\.gen\.ts\b/.test(withoutNegatedTypegen)) {
211
+ throw new Error("Studio instructions must not mention endpoints.gen.ts");
212
+ }
213
+
214
+ }
215
+
216
+ /** Test helper: local profile doctrine in instructions. */
217
+ export function assertLocalDoctrine(instructions) {
218
+ if (!/dypai\/flows\/\*\.flow\.ts/i.test(instructions)) {
219
+ throw new Error("local instructions must mention dypai/flows/*.flow.ts");
220
+ }
221
+ if (!/Do not create new YAML endpoints/i.test(instructions)) {
222
+ throw new Error("local instructions must discourage new YAML endpoints");
223
+ }
224
+ if (!/search_docs\("flow ts"\)|search_docs\("workflow patterns"\)/i.test(instructions)) {
225
+ throw new Error("local instructions must mention search_docs for flow/workflow guidance");
226
+ }
227
+ if (!/capability-brief|capability-catalog/i.test(instructions)) {
228
+ throw new Error("local instructions must point to on-disk capability files");
229
+ }
230
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * generate_image_asset — local orchestration around the cloud image generator.
3
+ *
4
+ * The cloud MCP (`dypai-mcp-cloud`) does the heavy work: calls OpenRouter,
5
+ * uploads to DYPAI's temporary R2 bucket, returns a signed URL + metadata.
6
+ *
7
+ * This local file adds ONE optional capability: if the agent passes
8
+ * `target_path`, we download the image from the signed URL and save it to
9
+ * the local filesystem. This keeps the common case ("create a hero image at
10
+ * public/hero.png") in one tool call instead of two (generate → download).
11
+ *
12
+ * If `target_path` is omitted we just forward the cloud response untouched —
13
+ * the calling agent decides what to do with the URL (embed, save elsewhere,
14
+ * stream, etc.).
15
+ *
16
+ * Path safety: `target_path` is always resolved RELATIVE to process.cwd()
17
+ * (where the agent invoked the MCP). Absolute paths and paths escaping cwd
18
+ * are rejected. We also enforce that the destination extension matches the
19
+ * generated image type.
20
+ */
21
+
22
+ import { mkdir, writeFile } from "node:fs/promises"
23
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"
24
+ import { Buffer } from "node:buffer"
25
+ import { proxyToolCall } from "./proxy.js"
26
+
27
+ // Extensions the cloud tool can return. Source of truth lives in
28
+ // `dypai-mcp-cloud/.../images_sdk/handlers.py:_DATA_URL_PATTERN`.
29
+ const ALLOWED_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"])
30
+
31
+ const MAX_DOWNLOAD_BYTES = 16 * 1024 * 1024
32
+ const DOWNLOAD_TIMEOUT_MS = 60_000
33
+
34
+ function isValidTargetPath(input) {
35
+ if (typeof input !== "string") return false
36
+ const trimmed = input.trim()
37
+ if (!trimmed) return false
38
+ if (trimmed.includes("\0")) return false
39
+ return true
40
+ }
41
+
42
+ /**
43
+ * Resolve target_path to an absolute path inside cwd. Reject anything
44
+ * pointing outside the working directory (path traversal, absolute paths
45
+ * to other folders).
46
+ */
47
+ function resolveTargetPath(input) {
48
+ const cwd = process.cwd()
49
+ // Reject absolute paths outright. The agent should write inside the
50
+ // project it's working in — not /tmp, not /etc.
51
+ if (isAbsolute(input)) {
52
+ throw new Error(
53
+ `target_path must be relative to the project (no absolute paths). Got: ${input}`,
54
+ )
55
+ }
56
+ const absolute = resolve(cwd, input)
57
+ const rel = relative(cwd, absolute)
58
+ if (rel.startsWith("..") || isAbsolute(rel)) {
59
+ throw new Error(`target_path escapes the project directory: ${input}`)
60
+ }
61
+ return absolute
62
+ }
63
+
64
+ /**
65
+ * If target_path ends with '/' the agent wants us to pick a filename. We
66
+ * derive one from the cloud's object_key (which already includes a uuid
67
+ * and the right extension). This mirrors the convention `manage_storage`
68
+ * upload uses for missing object_name.
69
+ */
70
+ function finalizeTargetPath(input, objectKey, extensionFromMime) {
71
+ const looksLikeDirectory = input.endsWith("/") || input.endsWith("\\")
72
+ if (looksLikeDirectory) {
73
+ const objectBasename = objectKey.split("/").pop() || `image${extensionFromMime}`
74
+ return resolveTargetPath(join(input, objectBasename))
75
+ }
76
+ return resolveTargetPath(input)
77
+ }
78
+
79
+ function assertExtensionMatches(targetAbsolute, extensionFromMime) {
80
+ const targetExt = extname(targetAbsolute).toLowerCase()
81
+ if (!targetExt) {
82
+ throw new Error(
83
+ `target_path must include an extension (one of ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}). Got: ${targetAbsolute}`,
84
+ )
85
+ }
86
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(targetExt)) {
87
+ throw new Error(`Unsupported target_path extension: ${targetExt}`)
88
+ }
89
+ // .jpg and .jpeg are interchangeable for our purposes.
90
+ const normalize = (ext) => (ext === ".jpeg" ? ".jpg" : ext)
91
+ if (normalize(targetExt) !== normalize(extensionFromMime)) {
92
+ throw new Error(
93
+ `target_path extension ${targetExt} does not match generated image type ${extensionFromMime}. ` +
94
+ `Either change target_path or omit it and let the tool pick a path.`,
95
+ )
96
+ }
97
+ }
98
+
99
+ function extensionFromMime(mime) {
100
+ if (!mime || typeof mime !== "string") return ".png"
101
+ const lower = mime.toLowerCase()
102
+ if (lower === "image/jpeg" || lower === "image/jpg") return ".jpg"
103
+ if (lower === "image/webp") return ".webp"
104
+ return ".png"
105
+ }
106
+
107
+ async function downloadImage(signedUrl) {
108
+ const controller = new AbortController()
109
+ const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS)
110
+ try {
111
+ const response = await fetch(signedUrl, { signal: controller.signal })
112
+ if (!response.ok) {
113
+ throw new Error(`Failed to download generated image: HTTP ${response.status}`)
114
+ }
115
+ const buf = Buffer.from(await response.arrayBuffer())
116
+ if (buf.byteLength > MAX_DOWNLOAD_BYTES) {
117
+ throw new Error(`Downloaded image is too large (${buf.byteLength} bytes)`)
118
+ }
119
+ return buf
120
+ } finally {
121
+ clearTimeout(timer)
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Main entry. `arguments` matches the cloud tool's schema PLUS the optional
127
+ * local-only `target_path`. We forward everything else verbatim — the cloud
128
+ * tool ignores unknown keys, so this stays forward-compatible if the cloud
129
+ * tool grows new params.
130
+ */
131
+ export async function generateImageAsset(args = {}) {
132
+ const targetPathInput = typeof args.target_path === "string" ? args.target_path.trim() : ""
133
+ const wantsLocalSave = Boolean(targetPathInput)
134
+
135
+ if (wantsLocalSave && !isValidTargetPath(targetPathInput)) {
136
+ throw new Error("target_path is not a valid filesystem path")
137
+ }
138
+
139
+ // Strip the local-only param before proxying so the cloud schema validator
140
+ // doesn't complain about unknown keys.
141
+ const cloudArgs = { ...args }
142
+ delete cloudArgs.target_path
143
+
144
+ const cloudResult = await proxyToolCall("generate_image_asset", cloudArgs)
145
+
146
+ // If the cloud returned an error envelope, surface it as-is.
147
+ if (cloudResult?.ok === false) {
148
+ return cloudResult
149
+ }
150
+
151
+ if (!cloudResult?.image_url || typeof cloudResult.image_url !== "string") {
152
+ throw new Error("generate_image_asset cloud response missing image_url")
153
+ }
154
+
155
+ // Case A: agent didn't ask for a local save → forward URL + metadata. The
156
+ // calling agent picks: embed via URL, save somewhere else, etc.
157
+ if (!wantsLocalSave) {
158
+ return {
159
+ ...cloudResult,
160
+ saved_to: null,
161
+ note: cloudResult.note
162
+ || `Image hosted at a temporary URL; download or persist it within ${cloudResult.expires_in_seconds || "the expiration window"} seconds if you need to keep it.`,
163
+ }
164
+ }
165
+
166
+ // Case B: agent asked for a local save. Resolve the target, download the
167
+ // image once, write it to disk.
168
+ const extension = extensionFromMime(cloudResult.mime_type)
169
+ const targetAbsolute = finalizeTargetPath(
170
+ targetPathInput,
171
+ cloudResult.object_key || "image",
172
+ extension,
173
+ )
174
+ assertExtensionMatches(targetAbsolute, extension)
175
+
176
+ const imageBytes = await downloadImage(cloudResult.image_url)
177
+
178
+ await mkdir(dirname(targetAbsolute), { recursive: true })
179
+ await writeFile(targetAbsolute, imageBytes)
180
+
181
+ return {
182
+ ...cloudResult,
183
+ saved_to: relative(process.cwd(), targetAbsolute) || targetAbsolute,
184
+ saved_absolute_path: targetAbsolute,
185
+ note: `Image saved to ${relative(process.cwd(), targetAbsolute)}. The cloud URL also remains valid until ${cloudResult.expires_at || "expiration"}.`,
186
+ }
187
+ }
@@ -445,9 +445,9 @@ async function executeScript({ project_id, sql, timeout_seconds }) {
445
445
  export const manageDatabaseTool = {
446
446
  name: "manage_database",
447
447
  description:
448
- "Database operations beyond a single SQL statement. Use `execute_sql` for " +
449
- "ad-hoc single-statement queries; this tool covers migrations, introspection, " +
450
- "and multi-statement transactional scripts.\n\n" +
448
+ "Database operations beyond ad-hoc SQL. Use `execute_sql` for reads/writes " +
449
+ "and safe multi-statement bootstrap scripts; this tool covers versioned " +
450
+ "migrations and introspection.\n\n" +
451
451
  "Operations:\n" +
452
452
  "- apply_migration: Apply dypai/migrations/<file>.sql with tracking + idempotency.\n" +
453
453
  " Same file twice → no-op. Modified file → checksum_mismatch.\n" +