@dypai-ai/mcp 1.0.9 → 1.1.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,362 @@
1
+ /**
2
+ * Codegen — generates TypeScript types + a typed api wrapper from the
3
+ * local dypai/ folder. Output lives inside src/ (by default src/dypai/)
4
+ * so it plays nicely with the user's tsconfig / bundler / `@/` alias.
5
+ *
6
+ * Reads:
7
+ * dypai/schema.sql → src/dypai/database.ts
8
+ * dypai/endpoints/**.yaml → src/dypai/api.ts
9
+ *
10
+ * Re-run is idempotent and cheap (<1s for typical projects). Auto-fired by
11
+ * pull / push / DDL so frontend types stay in sync without manual steps.
12
+ */
13
+
14
+ import { readFile, readdir, mkdir, writeFile, access } from "fs/promises"
15
+ import { join, resolve as resolvePath, dirname } from "path"
16
+ import YAML from "yaml"
17
+ import { readLocalConfig } from "./sync/planner.js"
18
+
19
+ // ─── Naming helpers ─────────────────────────────────────────────────────────
20
+
21
+ export function toCamel(name) {
22
+ return String(name).replace(/[-_](\w)/g, (_, c) => c.toUpperCase())
23
+ .replace(/^\w/, c => c.toLowerCase())
24
+ }
25
+
26
+ export function toPascal(name) {
27
+ const c = toCamel(name)
28
+ return c.charAt(0).toUpperCase() + c.slice(1)
29
+ }
30
+
31
+ // ─── SQL → TS type mapping ──────────────────────────────────────────────────
32
+
33
+ function sqlTypeToTs(sqlType) {
34
+ const t = String(sqlType).toLowerCase().trim()
35
+ if (/^(uuid|text|varchar|char|character|citext|name)/.test(t)) return "string"
36
+ if (/^(timestamp|date|time|interval)/.test(t)) return "string"
37
+ if (/^(int|smallint|bigint|integer|numeric|decimal|real|double|serial|bigserial|float|money)/.test(t)) return "number"
38
+ if (/^(boolean|bool)/.test(t)) return "boolean"
39
+ if (/^(json|jsonb)/.test(t)) return "unknown"
40
+ if (/^bytea/.test(t)) return "string"
41
+ if (/\[\]$/.test(t)) {
42
+ const inner = sqlTypeToTs(t.replace(/\[\]$/, ""))
43
+ return `${inner}[]`
44
+ }
45
+ // Enums and user-defined types → string at MVP (could be refined later)
46
+ return "string"
47
+ }
48
+
49
+ // ─── Generate database.ts from schema.sql ───────────────────────────────────
50
+
51
+ function parseCreateTable(ddl) {
52
+ // Extracts { table, columns: [{name, type, nullable, hasDefault}] }
53
+ const m = ddl.match(/CREATE\s+TABLE\s+public\.(\w+)\s*\(([\s\S]*?)\);/i)
54
+ if (!m) return null
55
+ const table = m[1]
56
+ const body = m[2]
57
+ const lines = body.split(/,\s*\n/).map(l => l.trim()).filter(Boolean)
58
+ const columns = []
59
+ for (const line of lines) {
60
+ // Skip constraint lines (PRIMARY KEY (...), FOREIGN KEY...)
61
+ if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|FOREIGN KEY|CHECK)\b/i.test(line)) continue
62
+ const colMatch = line.match(/^(\w+)\s+([^,]+?)(?:\s+(NOT NULL|NULL))?(?:\s+(PRIMARY KEY))?(?:\s+(DEFAULT\s+.+))?$/i)
63
+ if (!colMatch) {
64
+ const simple = line.match(/^(\w+)\s+(\w+(?:\s*\(\d+(?:\s*,\s*\d+)?\))?(?:\[\])?)\b/)
65
+ if (simple) {
66
+ columns.push({
67
+ name: simple[1],
68
+ type: simple[2],
69
+ nullable: !/NOT NULL/i.test(line),
70
+ hasDefault: /DEFAULT/i.test(line),
71
+ })
72
+ }
73
+ continue
74
+ }
75
+ columns.push({
76
+ name: colMatch[1],
77
+ type: colMatch[2].trim(),
78
+ nullable: !/NOT NULL/i.test(line),
79
+ hasDefault: /DEFAULT/i.test(line),
80
+ })
81
+ }
82
+ return { table, columns }
83
+ }
84
+
85
+ export function generateDatabaseTypes(schemaSql) {
86
+ // Split on the CREATE TABLE boundaries (keeping the "CREATE" prefix)
87
+ const tables = []
88
+ const re = /CREATE\s+TABLE\s+public\.\w+\s*\([\s\S]*?\);/gi
89
+ let m
90
+ while ((m = re.exec(schemaSql)) !== null) {
91
+ const parsed = parseCreateTable(m[0])
92
+ if (parsed) tables.push(parsed)
93
+ }
94
+
95
+ const lines = [
96
+ "// Auto-generated by dypai. Do NOT edit by hand — regenerated on every pull / push / DDL.",
97
+ "// Derived from dypai/schema.sql.",
98
+ "",
99
+ "export interface Database {",
100
+ " public: {",
101
+ " Tables: {",
102
+ ]
103
+
104
+ for (const t of tables) {
105
+ lines.push(` ${t.table}: {`)
106
+ // Row = exactly what comes out of SELECT
107
+ lines.push(` Row: {`)
108
+ for (const c of t.columns) {
109
+ const ts = sqlTypeToTs(c.type)
110
+ const opt = c.nullable ? " | null" : ""
111
+ lines.push(` ${c.name}: ${ts}${opt}`)
112
+ }
113
+ lines.push(` }`)
114
+ // Insert = what you can pass to INSERT (optional for nullable or with default)
115
+ lines.push(` Insert: {`)
116
+ for (const c of t.columns) {
117
+ const ts = sqlTypeToTs(c.type)
118
+ const optional = c.nullable || c.hasDefault ? "?" : ""
119
+ const or = c.nullable ? " | null" : ""
120
+ lines.push(` ${c.name}${optional}: ${ts}${or}`)
121
+ }
122
+ lines.push(` }`)
123
+ // Update = partial of Insert
124
+ lines.push(` Update: Partial<Database["public"]["Tables"]["${t.table}"]["Insert"]>`)
125
+ lines.push(` }`)
126
+ }
127
+
128
+ lines.push(" }")
129
+ lines.push(" }")
130
+ lines.push("}")
131
+ lines.push("")
132
+ return lines.join("\n")
133
+ }
134
+
135
+ // ─── JSON Schema → TS inline ────────────────────────────────────────────────
136
+
137
+ function jsonSchemaToTs(schema, indent = "") {
138
+ if (!schema || typeof schema !== "object") return "unknown"
139
+ if (Array.isArray(schema.enum) && schema.enum.length) {
140
+ return schema.enum.map(v => typeof v === "string" ? JSON.stringify(v) : String(v)).join(" | ")
141
+ }
142
+ const type = schema.type
143
+ if (type === "string") return "string"
144
+ if (type === "number" || type === "integer") return "number"
145
+ if (type === "boolean") return "boolean"
146
+ if (type === "null") return "null"
147
+ if (type === "array") {
148
+ const inner = jsonSchemaToTs(schema.items, indent)
149
+ return `Array<${inner}>`
150
+ }
151
+ if (type === "object" || schema.properties) {
152
+ const props = schema.properties || {}
153
+ const required = new Set(schema.required || [])
154
+ const keys = Object.keys(props)
155
+ if (!keys.length) return "Record<string, unknown>"
156
+ const next = indent + " "
157
+ const body = keys.map(k => {
158
+ const opt = required.has(k) ? "" : "?"
159
+ return `${next}${JSON.stringify(k)}${opt}: ${jsonSchemaToTs(props[k], next)}`
160
+ }).join("\n")
161
+ return `{\n${body}\n${indent}}`
162
+ }
163
+ // Union of types (e.g. ["object","string"])
164
+ if (Array.isArray(type)) {
165
+ return type.map(t => jsonSchemaToTs({ ...schema, type: t }, indent)).join(" | ")
166
+ }
167
+ return "unknown"
168
+ }
169
+
170
+ // ─── Generate api.ts from endpoint YAMLs ────────────────────────────────────
171
+
172
+ async function collectEndpoints(rootDir) {
173
+ const endpointsDir = join(rootDir, "endpoints")
174
+ const results = []
175
+ async function walk(dir) {
176
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
177
+ for (const e of entries) {
178
+ const full = join(dir, e.name)
179
+ if (e.isDirectory()) await walk(full)
180
+ else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
181
+ try {
182
+ const raw = await readFile(full, "utf8")
183
+ const doc = YAML.parse(raw) || {}
184
+ if (doc.name) results.push(doc)
185
+ } catch { /* skip malformed */ }
186
+ }
187
+ }
188
+ }
189
+ await walk(endpointsDir)
190
+ return results
191
+ }
192
+
193
+ function isBodyMethod(m) {
194
+ return ["POST", "PUT", "PATCH"].includes(String(m || "GET").toUpperCase())
195
+ }
196
+
197
+ export function generateApiTypes(endpoints, sdkImport = "../lib/dypai") {
198
+ const methods = []
199
+ const usedNames = new Set()
200
+
201
+ for (const ep of endpoints) {
202
+ let methodName = toCamel(ep.name)
203
+ // Ensure JS-safe identifier
204
+ if (!/^[a-z][A-Za-z0-9]*$/.test(methodName)) {
205
+ methodName = methodName.replace(/[^A-Za-z0-9]/g, "_")
206
+ }
207
+ let unique = methodName
208
+ let i = 2
209
+ while (usedNames.has(unique)) {
210
+ unique = `${methodName}${i++}`
211
+ }
212
+ usedNames.add(unique)
213
+
214
+ const http = String(ep.method || "GET").toUpperCase()
215
+ const hasBody = isBodyMethod(http)
216
+ const inputTs = ep.input ? jsonSchemaToTs(ep.input, " ") : "void"
217
+ const outputTs = ep.output ? jsonSchemaToTs(ep.output, " ") : "unknown"
218
+
219
+ const inputParam = hasBody
220
+ ? (inputTs === "void" ? "body?: Record<string, unknown>" : `body: ${inputTs}`)
221
+ : (inputTs === "void" ? "params?: Record<string, unknown>" : `params?: ${inputTs}`)
222
+
223
+ const callArg = hasBody ? "body" : "params"
224
+ const httpLower = http.toLowerCase()
225
+
226
+ // Short JSDoc header
227
+ const desc = (ep.description || "").replace(/\*\//g, "* /")
228
+
229
+ methods.push([
230
+ ` /**`,
231
+ ` * ${http} /${ep.name}`,
232
+ desc ? ` * ${desc}` : null,
233
+ ` */`,
234
+ ` ${unique}: (${inputParam}) =>`,
235
+ ` dypai.api.${httpLower}<${outputTs}>(${JSON.stringify(ep.name)}, ${callArg}),`,
236
+ ``,
237
+ ].filter(Boolean).join("\n"))
238
+ }
239
+
240
+ return [
241
+ "// Auto-generated by dypai. Do NOT edit by hand — regenerated on every pull / push.",
242
+ "// Derived from dypai/endpoints/*.yaml.",
243
+ "",
244
+ `import { dypai } from ${JSON.stringify(sdkImport)}`,
245
+ "",
246
+ "export const api = {",
247
+ ...methods,
248
+ "} as const",
249
+ "",
250
+ ].join("\n")
251
+ }
252
+
253
+ // ─── Orchestrator ───────────────────────────────────────────────────────────
254
+
255
+ async function ensureDir(p) {
256
+ await mkdir(p, { recursive: true })
257
+ }
258
+
259
+ async function fileExists(p) {
260
+ try { await access(p); return true } catch { return false }
261
+ }
262
+
263
+ /**
264
+ * Main entry: regenerate types from the dypai/ folder into the configured
265
+ * output directory (defaults to src/dypai/ inside the same workspace).
266
+ *
267
+ * Non-destructive when the workspace structure can't be resolved (e.g. no
268
+ * src/ folder) — skips silently so the action that triggered it doesn't fail.
269
+ */
270
+ export async function regenerateTypes(rootDir, opts = {}) {
271
+ const config = await readLocalConfig(rootDir)
272
+ const codegenCfg = config?.codegen || {}
273
+ // Resolution: output_dir is relative to the WORKSPACE (parent of dypai/),
274
+ // not to dypai/ itself.
275
+ const workspace = dirname(rootDir)
276
+ const outputDir = resolvePath(workspace, codegenCfg.output_dir || "src/dypai")
277
+ const sdkImport = codegenCfg.sdk_import || "../lib/dypai"
278
+
279
+ const files_written = []
280
+ const skipped = []
281
+
282
+ // Ensure src/ exists — if not, skip with a note (probably not a typical web project)
283
+ const srcDir = resolvePath(workspace, "src")
284
+ if (!(await fileExists(srcDir)) && !codegenCfg.output_dir) {
285
+ return {
286
+ skipped: true,
287
+ reason: "no src/ folder found — skipping codegen. Set codegen.output_dir in dypai.config.yaml to override.",
288
+ }
289
+ }
290
+
291
+ await ensureDir(outputDir)
292
+
293
+ // database.ts (only if schema.sql exists)
294
+ const schemaPath = join(rootDir, "schema.sql")
295
+ if (await fileExists(schemaPath)) {
296
+ const schema = await readFile(schemaPath, "utf8")
297
+ const dbTs = generateDatabaseTypes(schema)
298
+ const dbPath = join(outputDir, "database.ts")
299
+ await writeFile(dbPath, dbTs, "utf8")
300
+ files_written.push(dbPath)
301
+ } else {
302
+ skipped.push("database.ts (no schema.sql)")
303
+ }
304
+
305
+ // api.ts (only if endpoints/ has at least one yaml)
306
+ const endpoints = await collectEndpoints(rootDir)
307
+ if (endpoints.length > 0) {
308
+ const apiTs = generateApiTypes(endpoints, sdkImport)
309
+ const apiPath = join(outputDir, "api.ts")
310
+ await writeFile(apiPath, apiTs, "utf8")
311
+ files_written.push(apiPath)
312
+ } else {
313
+ skipped.push("api.ts (no endpoint YAMLs)")
314
+ }
315
+
316
+ // index.ts
317
+ if (files_written.length > 0) {
318
+ const exports = []
319
+ if (files_written.some(f => f.endsWith("database.ts"))) exports.push('export type { Database } from "./database"')
320
+ if (files_written.some(f => f.endsWith("api.ts"))) exports.push('export { api } from "./api"')
321
+ const indexPath = join(outputDir, "index.ts")
322
+ await writeFile(indexPath, exports.join("\n") + "\n", "utf8")
323
+ files_written.push(indexPath)
324
+ }
325
+
326
+ return {
327
+ regenerated: true,
328
+ output_dir: outputDir,
329
+ files_written,
330
+ endpoints_count: endpoints.length,
331
+ skipped: skipped.length ? skipped : undefined,
332
+ }
333
+ }
334
+
335
+ // ─── Standalone tool ────────────────────────────────────────────────────────
336
+
337
+ export const dypaiCodegenTool = {
338
+ name: "dypai_codegen",
339
+ description:
340
+ "Regenerate TypeScript types from dypai/schema.sql + dypai/endpoints/*.yaml into src/dypai/ (default). " +
341
+ "Produces: database.ts (Database interface for tables), api.ts (typed wrapper around dypai.api.*), index.ts (re-exports). " +
342
+ "Auto-runs after dypai_pull, dypai_push, and DDL via execute_sql — this tool is for manual/CI regeneration. " +
343
+ "Configure output location via dypai.config.yaml codegen.output_dir and codegen.sdk_import.",
344
+ inputSchema: {
345
+ type: "object",
346
+ properties: {
347
+ root_dir: { type: "string", default: "./dypai", description: "Path to the dypai/ folder." },
348
+ },
349
+ },
350
+ async execute({ root_dir = "./dypai" } = {}) {
351
+ const rootDir = resolvePath(process.cwd(), root_dir)
352
+ const result = await regenerateTypes(rootDir)
353
+ if (result.skipped) {
354
+ return { success: true, ...result, hint: result.reason }
355
+ }
356
+ return {
357
+ success: true,
358
+ ...result,
359
+ hint: `Import from '@/dypai' (or relative): \`import { api } from '@/dypai'\`. Types regenerate automatically on pull/push/DDL.`,
360
+ }
361
+ },
362
+ }
@@ -129,34 +129,12 @@ function detectFramework(dir) {
129
129
  } catch { return null }
130
130
  }
131
131
 
132
- export const deployTool = {
133
- name: "deploy_frontend",
134
- description: `Deploy frontend source code from a local directory.
135
-
136
- Reads all source files from the specified directory and uploads them to DYPAI.
137
- The build runs in the cloud no local Node.js or npm required.
138
-
139
- Supports: React, Vite, Next.js, Astro, SvelteKit, Nuxt, Vue, CRA, and more.
140
- DYPAI auto-detects the framework and routes to the optimal backend.
141
-
142
- After deploying, the site is live at https://{slug}.dypai.app within ~30s.`,
143
-
144
- inputSchema: {
145
- type: "object",
146
- properties: {
147
- sourceDirectory: {
148
- type: "string",
149
- description: "Absolute path to the project source directory (e.g. /Users/me/my-app). Must contain a package.json.",
150
- },
151
- project_id: {
152
- type: "string",
153
- description: "Project UUID. Required.",
154
- },
155
- },
156
- required: ["sourceDirectory", "project_id"],
157
- },
158
-
159
- async execute({ sourceDirectory, project_id }) {
132
+ /**
133
+ * Pure deploy function — used by the consolidated `manage_frontend` tool.
134
+ * Reads the source directory, uploads to the API, returns a structured
135
+ * response the caller can hand back to the agent.
136
+ */
137
+ export async function deployFromSource({ sourceDirectory, project_id }) {
160
138
  if (!existsSync(sourceDirectory)) {
161
139
  return { error: `Directory not found: ${sourceDirectory}` }
162
140
  }
@@ -180,88 +158,40 @@ After deploying, the site is live at https://{slug}.dypai.app within ~30s.`,
180
158
 
181
159
  const label = framework?.label ?? "Project"
182
160
 
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
-
161
+ // Fire-and-forget: return immediately with "queued" status. The agent
162
+ // is expected to call `get_build_status` to poll progress until the
163
+ // terminal "success" / "failure" is reached. Blocking here would stall
164
+ // the agent for up to 2 minutes and prevent it from doing anything
165
+ // else in parallel.
247
166
  const skippedMsg = skipped.length > 0
248
167
  ? `\n\nSkipped ${skipped.length} file(s):\n${skipped.map(s => ` - ${s.path}: ${s.reason}`).join("\n")}`
249
168
  : ""
250
169
 
251
170
  return {
252
171
  success: true,
253
- url: result.url,
172
+ deployed: false, // explicit: not live yet, still building
173
+ url: result.url, // final URL once success
254
174
  files_pushed: files.length,
255
175
  files_skipped: skipped.length,
256
176
  skipped_files: skipped.length > 0 ? skipped : undefined,
257
177
  size_bytes: total,
258
178
  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}`,
179
+ build_status: "queued",
180
+ next_step: {
181
+ action: "poll_build_status",
182
+ tool: "manage_frontend",
183
+ arg: { operation: "build_status", project_id },
184
+ interval_seconds: 5,
185
+ expected_total_seconds: 60,
186
+ terminal_statuses: ["success", "failure"],
187
+ },
188
+ message:
189
+ `Deploy accepted — ${files.length} files (${Math.round(total / 1024)} KB) queued. ` +
190
+ `The build is running in the cloud (${label}, typically 20–60s). ` +
191
+ `Call manage_frontend({operation:"build_status", project_id:"${project_id}"}) every ~5s until status is "success" or "failure" ` +
192
+ `before reporting completion to the user. The site is NOT live yet.${skippedMsg}`,
262
193
  }
263
194
  } catch (e) {
264
195
  return { error: `Deploy failed: ${e.message}` }
265
196
  }
266
- },
267
197
  }
@@ -1,73 +1,70 @@
1
1
  /**
2
- * Domain management tools add, list, remove custom domains.
2
+ * manage_domain single entry point for custom-domain operations.
3
+ *
4
+ * Consolidates what used to be add_domain / list_domains / remove_domain into
5
+ * one `operation`-dispatched tool (same pattern as manage_users / manage_roles).
6
+ * `verify` is also included so the agent can trigger an on-demand DNS/SSL
7
+ * check after the user configures their CNAME.
3
8
  */
4
9
 
5
10
  import { api } from "../api.js"
6
11
 
7
- export const addDomainTool = {
8
- name: "add_domain",
9
- description: `Add a custom domain to your project's frontend.
10
-
11
- Returns DNS instructions (CNAME record) that the user needs to configure
12
- at their domain registrar. SSL is automatic once DNS propagates.`,
12
+ export const manageDomainTool = {
13
+ name: "manage_domain",
14
+ description:
15
+ "Manage custom domains for the project's frontend. " +
16
+ "Operations: list (all domains + DNS/SSL status), add (returns the CNAME the user must configure at their registrar — SSL is automatic), " +
17
+ "remove (disconnect a domain), verify (force an on-demand DNS/SSL re-check for a domain). " +
18
+ "The platform polls DNS automatically in the background, so `verify` is only needed when the user just updated their DNS and wants immediate feedback.",
13
19
 
14
20
  inputSchema: {
15
21
  type: "object",
16
22
  properties: {
17
- project_id: { type: "string", description: "Project UUID." },
18
- domain: { type: "string", description: "Domain to add (e.g. www.example.com)" },
23
+ operation: {
24
+ type: "string",
25
+ enum: ["list", "add", "remove", "verify"],
26
+ description: "Which action to run.",
27
+ },
28
+ project_id: {
29
+ type: "string",
30
+ description: "Project UUID. Auto-injected from dypai.config.yaml when omitted.",
31
+ },
32
+ domain: {
33
+ type: "string",
34
+ description: "Domain name (required for add, remove, verify). E.g. 'www.example.com'.",
35
+ },
19
36
  },
20
- required: ["project_id", "domain"],
37
+ required: ["operation"],
21
38
  },
22
39
 
23
- async execute({ project_id, domain }) {
24
- try {
25
- return await api.post(`/api/engine/${project_id}/frontend/domains`, { domain })
26
- } catch (e) {
27
- return { error: e.message }
40
+ async execute({ operation, project_id, domain } = {}) {
41
+ if (!operation) {
42
+ return { success: false, error: "operation is required (list | add | remove | verify)." }
28
43
  }
29
- },
30
- }
31
-
32
- export const listDomainsTool = {
33
- name: "list_domains",
34
- description: "List all custom domains configured for the project's frontend.",
35
-
36
- inputSchema: {
37
- type: "object",
38
- properties: {
39
- project_id: { type: "string", description: "Project UUID." },
40
- },
41
- required: ["project_id"],
42
- },
43
-
44
- async execute({ project_id }) {
45
- try {
46
- return await api.get(`/api/engine/${project_id}/frontend/domains`)
47
- } catch (e) {
48
- return { error: e.message }
44
+ if (!project_id) {
45
+ return { success: false, error: "project_id is required. Set it in dypai.config.yaml or pass it explicitly." }
49
46
  }
50
- },
51
- }
52
-
53
- export const removeDomainTool = {
54
- name: "remove_domain",
55
- description: "Remove a custom domain from the project's frontend.",
56
47
 
57
- inputSchema: {
58
- type: "object",
59
- properties: {
60
- project_id: { type: "string", description: "Project UUID." },
61
- domain: { type: "string", description: "Domain to remove." },
62
- },
63
- required: ["project_id", "domain"],
64
- },
48
+ const needsDomain = ["add", "remove", "verify"]
49
+ if (needsDomain.includes(operation) && !domain) {
50
+ return { success: false, error: `operation '${operation}' requires a 'domain' argument.` }
51
+ }
65
52
 
66
- async execute({ project_id, domain }) {
67
53
  try {
68
- return await api.delete(`/api/engine/${project_id}/frontend/domains/${domain}`)
54
+ switch (operation) {
55
+ case "list":
56
+ return await api.get(`/api/engine/${project_id}/frontend/domains`)
57
+ case "add":
58
+ return await api.post(`/api/engine/${project_id}/frontend/domains`, { domain })
59
+ case "remove":
60
+ return await api.delete(`/api/engine/${project_id}/frontend/domains/${encodeURIComponent(domain)}`)
61
+ case "verify":
62
+ return await api.get(`/api/engine/${project_id}/frontend/domains/${encodeURIComponent(domain)}/verify`)
63
+ default:
64
+ return { success: false, error: `Unknown operation '${operation}'. Use list | add | remove | verify.` }
65
+ }
69
66
  } catch (e) {
70
- return { error: e.message }
67
+ return { success: false, error: e.message, operation, domain: domain || null }
71
68
  }
72
69
  },
73
70
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Client-side enrichment pass-through.
3
+ *
4
+ * Response enrichment (hints on errors, next_step on non-obvious success) lives
5
+ * on the REMOTE MCP server now (dypai-mcp, Python) so every client — local
6
+ * MCP, dashboard, direct curl — gets the same agent-UX treatment. Keeping
7
+ * this file as a no-op stub lets us re-introduce client-only enrichment
8
+ * later (e.g. for local filesystem context) without touching index.js.
9
+ */
10
+
11
+ export function enrichSuccess(_toolName, result) {
12
+ return result
13
+ }
14
+
15
+ export function enrichError(_toolName, error) {
16
+ return error
17
+ }