@dypai-ai/mcp 1.0.10 → 1.2.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 +5 -2
- package/src/index.js +231 -66
- package/src/tools/codegen.js +362 -0
- package/src/tools/deploy.js +9 -32
- package/src/tools/domains.js +49 -52
- package/src/tools/enrich.js +17 -0
- package/src/tools/frontend.js +93 -0
- package/src/tools/project-context.js +84 -0
- package/src/tools/proxy.js +14 -6
- package/src/tools/sql-side-effects.js +91 -0
- package/src/tools/sync/codec.js +185 -0
- package/src/tools/sync/describe.js +149 -0
- package/src/tools/sync/diff.js +83 -0
- package/src/tools/sync/index.js +18 -0
- package/src/tools/sync/planner.js +426 -0
- package/src/tools/sync/pull.js +411 -0
- package/src/tools/sync/push.js +397 -0
- package/src/tools/sync/schema-dump.js +96 -0
- package/src/tools/sync/test-endpoint.js +210 -0
- package/src/tools/sync/test.js +343 -0
- package/src/tools/sync/transforms.js +157 -0
- package/src/tools/sync/validate.js +567 -0
- package/src/tools/trace-summarize.js +178 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-inject project_id into tool calls that accept it.
|
|
3
|
+
*
|
|
4
|
+
* The agent shouldn't have to pass project_id manually on every call —
|
|
5
|
+
* the local MCP already knows it from dypai/dypai.config.yaml. This keeps
|
|
6
|
+
* the remote MCP's metrics and permission layer happy without polluting
|
|
7
|
+
* the agent-facing API.
|
|
8
|
+
*
|
|
9
|
+
* Strategy:
|
|
10
|
+
* 1. Detect the dypai/ folder near the workspace (env vars + walk up).
|
|
11
|
+
* 2. Parse dypai.config.yaml once, cache the resolved project_id in memory.
|
|
12
|
+
* 3. For remote tool calls: if the tool's inputSchema declares project_id
|
|
13
|
+
* and the caller didn't pass one, inject the cached value.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync } from "fs"
|
|
17
|
+
import { join, isAbsolute, dirname, delimiter } from "path"
|
|
18
|
+
import { homedir } from "os"
|
|
19
|
+
import YAML from "yaml"
|
|
20
|
+
|
|
21
|
+
let _cached = null // { projectId, configPath } | null
|
|
22
|
+
let _resolved = false
|
|
23
|
+
|
|
24
|
+
function findDypaiFolder() {
|
|
25
|
+
const envCandidates = [
|
|
26
|
+
process.env.CLAUDE_PROJECT_DIR,
|
|
27
|
+
process.env.DYPAI_WORKSPACE_ROOT,
|
|
28
|
+
process.env.WORKSPACE_FOLDER_PATHS?.split(delimiter)[0],
|
|
29
|
+
process.env.PROJECT_ROOT,
|
|
30
|
+
].filter(v => v && isAbsolute(v))
|
|
31
|
+
|
|
32
|
+
for (const base of envCandidates) {
|
|
33
|
+
const candidate = join(base, "dypai")
|
|
34
|
+
if (existsSync(join(candidate, "dypai.config.yaml"))) return candidate
|
|
35
|
+
if (existsSync(join(base, "dypai.config.yaml"))) return base
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let cursor = process.cwd()
|
|
39
|
+
const home = homedir()
|
|
40
|
+
for (let i = 0; i < 8; i++) {
|
|
41
|
+
const candidate = join(cursor, "dypai")
|
|
42
|
+
if (existsSync(join(candidate, "dypai.config.yaml"))) return candidate
|
|
43
|
+
if (cursor === home || cursor === "/") break
|
|
44
|
+
const parent = dirname(cursor)
|
|
45
|
+
if (parent === cursor) break
|
|
46
|
+
cursor = parent
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveProjectId() {
|
|
52
|
+
if (_resolved) return _cached
|
|
53
|
+
_resolved = true
|
|
54
|
+
try {
|
|
55
|
+
const dypaiDir = findDypaiFolder()
|
|
56
|
+
if (!dypaiDir) return null
|
|
57
|
+
const configPath = join(dypaiDir, "dypai.config.yaml")
|
|
58
|
+
const raw = readFileSync(configPath, "utf8")
|
|
59
|
+
const doc = YAML.parse(raw)
|
|
60
|
+
if (doc?.project_id) {
|
|
61
|
+
_cached = { projectId: doc.project_id, configPath }
|
|
62
|
+
return _cached
|
|
63
|
+
}
|
|
64
|
+
} catch { /* missing or malformed → silent */ }
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Force re-read on next lookup (useful after dypai_pull creates/updates the config). */
|
|
69
|
+
export function invalidateProjectContext() {
|
|
70
|
+
_resolved = false
|
|
71
|
+
_cached = null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Inject project_id into args if the tool's schema declares it and the caller
|
|
76
|
+
* didn't provide one. No-op otherwise. Never overwrites an explicit value.
|
|
77
|
+
*/
|
|
78
|
+
export function withProjectContext(toolDef, args) {
|
|
79
|
+
if (!toolDef?.inputSchema?.properties?.project_id) return args
|
|
80
|
+
if (args?.project_id) return args
|
|
81
|
+
const ctx = resolveProjectId()
|
|
82
|
+
if (!ctx) return args
|
|
83
|
+
return { ...args, project_id: ctx.projectId }
|
|
84
|
+
}
|
package/src/tools/proxy.js
CHANGED
|
@@ -115,13 +115,21 @@ export async function proxyToolCall(toolName, args) {
|
|
|
115
115
|
|
|
116
116
|
// Extract result from JSON-RPC response
|
|
117
117
|
if (response.result) {
|
|
118
|
-
const content = response.result
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
const { content, isError } = response.result
|
|
119
|
+
const text = Array.isArray(content) && content[0]?.text ? content[0].text : null
|
|
120
|
+
|
|
121
|
+
if (isError) {
|
|
122
|
+
throw new Error(text || "Remote tool call failed with isError=true")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (text != null) {
|
|
126
|
+
let parsed
|
|
127
|
+
try { parsed = JSON.parse(text) } catch { parsed = text }
|
|
128
|
+
// Some remote errors come as a plain string without isError flag
|
|
129
|
+
if (typeof parsed === "string" && /^(Error:|Input validation error:|You don't have)/i.test(parsed)) {
|
|
130
|
+
throw new Error(parsed)
|
|
124
131
|
}
|
|
132
|
+
return parsed
|
|
125
133
|
}
|
|
126
134
|
return response.result
|
|
127
135
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side effects we run client-side after a remote `execute_sql` succeeds.
|
|
3
|
+
*
|
|
4
|
+
* Right now: if the SQL was schema-affecting DDL on public.*, re-dump the
|
|
5
|
+
* public schema and overwrite local dypai/schema.sql so the validator
|
|
6
|
+
* doesn't give false "table not found" errors on the next check.
|
|
7
|
+
*
|
|
8
|
+
* Runs synchronously (the caller awaits) so the agent can trust the state.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from "fs"
|
|
12
|
+
import { writeFile } from "fs/promises"
|
|
13
|
+
import { join, isAbsolute, dirname, delimiter } from "path"
|
|
14
|
+
import { homedir } from "os"
|
|
15
|
+
import { dumpPublicSchema } from "./sync/schema-dump.js"
|
|
16
|
+
// Codegen removed from v1. If we reintroduce TS type generation we'd call it here.
|
|
17
|
+
// import { regenerateTypes } from "./codegen.js"
|
|
18
|
+
|
|
19
|
+
// Matches CREATE/ALTER/DROP/TRUNCATE/RENAME on TABLE — the only DDL shapes we
|
|
20
|
+
// currently reflect in schema.sql. CREATE INDEX / FUNCTION / TYPE are skipped.
|
|
21
|
+
const SCHEMA_AFFECTING_DDL = /\b(CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE\b/i
|
|
22
|
+
|
|
23
|
+
/** Detect whether a SQL statement would change what schema.sql captures. */
|
|
24
|
+
export function affectsPublicSchema(sql) {
|
|
25
|
+
if (!sql || typeof sql !== "string") return false
|
|
26
|
+
return SCHEMA_AFFECTING_DDL.test(sql)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Find the dypai/ folder near the current workspace. We follow the same
|
|
31
|
+
* signals as pull.js so users don't have to configure twice:
|
|
32
|
+
* 1. CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS env vars
|
|
33
|
+
* 2. Walk up from cwd looking for a dypai/ folder that contains schema.sql
|
|
34
|
+
*/
|
|
35
|
+
function findDypaiFolder() {
|
|
36
|
+
const envCandidates = [
|
|
37
|
+
process.env.CLAUDE_PROJECT_DIR,
|
|
38
|
+
process.env.DYPAI_WORKSPACE_ROOT,
|
|
39
|
+
process.env.WORKSPACE_FOLDER_PATHS?.split(delimiter)[0],
|
|
40
|
+
process.env.PROJECT_ROOT,
|
|
41
|
+
].filter(v => v && isAbsolute(v))
|
|
42
|
+
|
|
43
|
+
for (const base of envCandidates) {
|
|
44
|
+
const candidate = join(base, "dypai")
|
|
45
|
+
if (existsSync(join(candidate, "schema.sql"))) return candidate
|
|
46
|
+
// Maybe the user already opened directly into the dypai folder
|
|
47
|
+
if (existsSync(join(base, "schema.sql")) && existsSync(join(base, "endpoints"))) return base
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Walk up from cwd
|
|
51
|
+
let cursor = process.cwd()
|
|
52
|
+
const home = homedir()
|
|
53
|
+
for (let i = 0; i < 8; i++) {
|
|
54
|
+
const candidate = join(cursor, "dypai")
|
|
55
|
+
if (existsSync(join(candidate, "schema.sql"))) return candidate
|
|
56
|
+
if (cursor === home || cursor === "/") break
|
|
57
|
+
const parent = dirname(cursor)
|
|
58
|
+
if (parent === cursor) break
|
|
59
|
+
cursor = parent
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* After a successful execute_sql, rewrite dypai/schema.sql if the query
|
|
67
|
+
* affected the schema AND a dypai/ folder is present locally.
|
|
68
|
+
* Returns { refreshed: boolean, path?: string, reason?: string, error?: string }.
|
|
69
|
+
*/
|
|
70
|
+
export async function maybeRefreshSchemaAfterExecuteSql(args, result) {
|
|
71
|
+
if (result?.isError) return { refreshed: false, reason: "execute_sql failed" }
|
|
72
|
+
const sql = args?.sql
|
|
73
|
+
if (!affectsPublicSchema(sql)) return { refreshed: false, reason: "non-DDL query" }
|
|
74
|
+
|
|
75
|
+
const dypaiDir = findDypaiFolder()
|
|
76
|
+
if (!dypaiDir) return { refreshed: false, reason: "no local dypai/ folder found" }
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const newSchema = await dumpPublicSchema(args.project_id)
|
|
80
|
+
const target = join(dypaiDir, "schema.sql")
|
|
81
|
+
await writeFile(target, newSchema, "utf8")
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
refreshed: true,
|
|
85
|
+
path: target,
|
|
86
|
+
}
|
|
87
|
+
} catch (e) {
|
|
88
|
+
// Don't fail the execute_sql call itself — just report the side-effect failure.
|
|
89
|
+
return { refreshed: false, error: e.message }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint-level codec: DB row ↔ YAML doc.
|
|
3
|
+
*
|
|
4
|
+
* The bidirectional field transforms live in transforms.js. This module
|
|
5
|
+
* handles the structural shape: nodes, edges, triggers, workflow_code wrapping.
|
|
6
|
+
*
|
|
7
|
+
* Exports:
|
|
8
|
+
* serializeEndpoint(row, mapsCtx) → { doc, extractedFiles }
|
|
9
|
+
* deserializeEndpoint(doc, mapsCtx) → row-shaped object
|
|
10
|
+
*
|
|
11
|
+
* "row" is the DB row shape (workflow_code as object, not string).
|
|
12
|
+
* "doc" is the YAML-ready declarative object.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { pullNodeParams, pushNodeParams } from "./transforms.js"
|
|
16
|
+
|
|
17
|
+
// ─── Triggers (execution_config.triggers ↔ doc.trigger) ─────────────────────
|
|
18
|
+
|
|
19
|
+
function triggersToYaml(triggers = {}) {
|
|
20
|
+
const out = {}
|
|
21
|
+
for (const [kind, cfg] of Object.entries(triggers)) {
|
|
22
|
+
if (!cfg) continue
|
|
23
|
+
if (kind === "http_api" && cfg.enabled !== false) {
|
|
24
|
+
out.http_api = { auth_mode: cfg.auth_mode || "jwt" }
|
|
25
|
+
} else if (kind === "webhook" && cfg.enabled) {
|
|
26
|
+
out.webhook = { path: cfg.path || "" }
|
|
27
|
+
} else if (kind === "schedule" && cfg.enabled) {
|
|
28
|
+
const s = { cron: cfg.cron }
|
|
29
|
+
if (cfg.timezone) s.timezone = cfg.timezone
|
|
30
|
+
out.schedule = s
|
|
31
|
+
} else if (kind === "manual" && cfg.enabled) {
|
|
32
|
+
out.manual = true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!Object.keys(out).length) out.http_api = { auth_mode: "jwt" }
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function triggersToDb(trigger = {}) {
|
|
40
|
+
// Default: http_api jwt enabled, others disabled
|
|
41
|
+
const out = {
|
|
42
|
+
webhook: { path: "", enabled: false },
|
|
43
|
+
http_api: { enabled: true, auth_mode: "jwt" },
|
|
44
|
+
schedule: { enabled: false },
|
|
45
|
+
}
|
|
46
|
+
if (trigger.http_api) {
|
|
47
|
+
out.http_api = { enabled: true, auth_mode: trigger.http_api.auth_mode || "jwt" }
|
|
48
|
+
} else if (trigger.webhook || trigger.schedule || trigger.manual) {
|
|
49
|
+
// Non-HTTP trigger: disable http_api
|
|
50
|
+
out.http_api = { enabled: false, auth_mode: "jwt" }
|
|
51
|
+
}
|
|
52
|
+
if (trigger.webhook) {
|
|
53
|
+
out.webhook = { path: trigger.webhook.path || "", enabled: true }
|
|
54
|
+
}
|
|
55
|
+
if (trigger.schedule) {
|
|
56
|
+
out.schedule = { ...trigger.schedule, enabled: true }
|
|
57
|
+
}
|
|
58
|
+
if (trigger.manual) {
|
|
59
|
+
out.manual = { enabled: true }
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Edges (source/target ↔ from/to) ────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function edgesToYaml(edges = []) {
|
|
67
|
+
return edges.map(e => {
|
|
68
|
+
const out = { from: e.source, to: e.target }
|
|
69
|
+
if (e.condition) out.condition = e.condition
|
|
70
|
+
if (e.source_handle) out.source_handle = e.source_handle
|
|
71
|
+
if (e.target_handle) out.target_handle = e.target_handle
|
|
72
|
+
return out
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function edgesToDb(edges = []) {
|
|
77
|
+
return edges.map(e => {
|
|
78
|
+
const out = { source: e.from, target: e.to }
|
|
79
|
+
if (e.condition) out.condition = e.condition
|
|
80
|
+
if (e.source_handle) out.source_handle = e.source_handle
|
|
81
|
+
if (e.target_handle) out.target_handle = e.target_handle
|
|
82
|
+
return out
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Public codec ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export function serializeEndpoint(row, mapsCtx) {
|
|
89
|
+
const wf = row.workflow_code || {}
|
|
90
|
+
const extractedFiles = []
|
|
91
|
+
const emitFile = (path, content) => extractedFiles.push({ path, content })
|
|
92
|
+
|
|
93
|
+
// Pre-count nodes that produce extracted files — used to decide flat vs dotted filenames
|
|
94
|
+
const rawNodes = wf.nodes || []
|
|
95
|
+
const sqlNodeCount = rawNodes.filter(n => n.node_type === "dypai_database" && n.parameters?.query).length
|
|
96
|
+
const promptNodeCount = rawNodes.filter(n => n.node_type === "agent" && n.parameters?.system_prompt).length
|
|
97
|
+
const codeNodeCount = rawNodes.filter(n =>
|
|
98
|
+
(n.node_type === "javascript_code" || n.node_type === "python_code") && n.parameters?.code
|
|
99
|
+
).length
|
|
100
|
+
|
|
101
|
+
const nodes = rawNodes.map(node => {
|
|
102
|
+
const nodeCtx = {
|
|
103
|
+
...mapsCtx,
|
|
104
|
+
endpointName: row.name,
|
|
105
|
+
nodeId: node.id,
|
|
106
|
+
nodeType: node.node_type,
|
|
107
|
+
emitFile,
|
|
108
|
+
sqlNodeCount,
|
|
109
|
+
promptNodeCount,
|
|
110
|
+
codeNodeCount,
|
|
111
|
+
}
|
|
112
|
+
const transformedParams = pullNodeParams(node.parameters || {}, nodeCtx, node.node_type)
|
|
113
|
+
|
|
114
|
+
const clean = { id: node.id, type: node.node_type }
|
|
115
|
+
if (node.variable && node.variable !== node.id) clean.variable = node.variable
|
|
116
|
+
if (node.is_return) clean.return = true
|
|
117
|
+
Object.assign(clean, transformedParams)
|
|
118
|
+
return clean
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const doc = { name: row.name, method: row.method || "GET" }
|
|
122
|
+
if (row.description) doc.description = row.description
|
|
123
|
+
if (row.group_id && mapsCtx.groupIdToName[row.group_id]) {
|
|
124
|
+
doc.group = mapsCtx.groupIdToName[row.group_id]
|
|
125
|
+
}
|
|
126
|
+
if (row.is_tool) {
|
|
127
|
+
doc.tool = true
|
|
128
|
+
if (row.tool_description) doc.tool_description = row.tool_description
|
|
129
|
+
}
|
|
130
|
+
if (row.allowed_roles && row.allowed_roles.length) {
|
|
131
|
+
doc.allowed_roles = row.allowed_roles
|
|
132
|
+
}
|
|
133
|
+
if (row.input) doc.input = row.input
|
|
134
|
+
if (row.output) doc.output = row.output
|
|
135
|
+
doc.trigger = triggersToYaml(wf.execution_config?.triggers)
|
|
136
|
+
|
|
137
|
+
const workflow = { nodes }
|
|
138
|
+
const edges = edgesToYaml(wf.edges)
|
|
139
|
+
if (edges.length) workflow.edges = edges
|
|
140
|
+
if (wf.on_error && wf.on_error !== "stop") workflow.on_error = wf.on_error
|
|
141
|
+
doc.workflow = workflow
|
|
142
|
+
|
|
143
|
+
return { doc, extractedFiles }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function deserializeEndpoint(doc, mapsCtx) {
|
|
147
|
+
const readFile = mapsCtx.readFile || (() => "")
|
|
148
|
+
|
|
149
|
+
const nodes = (doc.workflow?.nodes || []).map(clean => {
|
|
150
|
+
const { id, type, variable, return: isReturn, ...params } = clean
|
|
151
|
+
const nodeCtx = {
|
|
152
|
+
...mapsCtx,
|
|
153
|
+
endpointName: doc.name,
|
|
154
|
+
nodeId: id,
|
|
155
|
+
readFile,
|
|
156
|
+
}
|
|
157
|
+
const transformedParams = pushNodeParams(params, nodeCtx, type)
|
|
158
|
+
|
|
159
|
+
const node = { id, node_type: type, parameters: transformedParams }
|
|
160
|
+
if (variable) node.variable = variable
|
|
161
|
+
if (isReturn) node.is_return = true
|
|
162
|
+
return node
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const workflow_code = {
|
|
166
|
+
nodes,
|
|
167
|
+
edges: edgesToDb(doc.workflow?.edges),
|
|
168
|
+
execution_config: { triggers: triggersToDb(doc.trigger) },
|
|
169
|
+
schema_version: "2.0",
|
|
170
|
+
}
|
|
171
|
+
if (doc.workflow?.on_error) workflow_code.on_error = doc.workflow.on_error
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
name: doc.name,
|
|
175
|
+
method: doc.method || "GET",
|
|
176
|
+
description: doc.description || null,
|
|
177
|
+
workflow_code,
|
|
178
|
+
input: doc.input || null,
|
|
179
|
+
output: doc.output || null,
|
|
180
|
+
allowed_roles: doc.allowed_roles || [],
|
|
181
|
+
is_tool: !!doc.tool,
|
|
182
|
+
tool_description: doc.tool_description || null,
|
|
183
|
+
group_id: doc.group && mapsCtx.groupNameToId ? (mapsCtx.groupNameToId[doc.group] || null) : null,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_describe — one-shot project snapshot for AI agents.
|
|
3
|
+
*
|
|
4
|
+
* Returns everything the agent needs to understand a project in a single call:
|
|
5
|
+
* tables, endpoints, credentials, roles, buckets, SDK config, and suggested
|
|
6
|
+
* next steps. Designed to replace the 5-6 discovery calls a cold agent has to
|
|
7
|
+
* make at the start of every session.
|
|
8
|
+
*
|
|
9
|
+
* This is the "boot" call. Run it first, get the lay of the land, then act.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { proxyToolCall } from "../proxy.js"
|
|
13
|
+
|
|
14
|
+
async function execSql(projectId, sql) {
|
|
15
|
+
const args = projectId ? { project_id: projectId, sql } : { sql }
|
|
16
|
+
const result = await proxyToolCall("execute_sql", args)
|
|
17
|
+
if (result?.error) return null
|
|
18
|
+
if (!result?.rows) return null
|
|
19
|
+
return result.rows
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const dypaiDescribeTool = {
|
|
23
|
+
name: "dypai_describe",
|
|
24
|
+
description:
|
|
25
|
+
"ALWAYS RUN FIRST when working on a DYPAI project you haven't seen yet. " +
|
|
26
|
+
"Returns a one-shot snapshot: tables, endpoints (with groups + tools), credentials, " +
|
|
27
|
+
"roles, buckets, SDK config, and suggested next steps. Replaces 5-6 discovery calls. " +
|
|
28
|
+
"Complementary to dypai_pull (which materializes files locally) — describe just reads.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
project_id: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "Project UUID. Optional if your token is project-scoped.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async execute({ project_id } = {}) {
|
|
40
|
+
const [tables, endpoints, creds, roles, buckets, project] = await Promise.all([
|
|
41
|
+
execSql(project_id, `
|
|
42
|
+
SELECT c.table_name,
|
|
43
|
+
COUNT(*) AS column_count,
|
|
44
|
+
BOOL_OR(c.column_name = 'user_id') AS has_user_id,
|
|
45
|
+
BOOL_OR(c.column_name IN ('created_at','updated_at')) AS has_timestamps
|
|
46
|
+
FROM information_schema.columns c
|
|
47
|
+
WHERE c.table_schema = 'public'
|
|
48
|
+
GROUP BY c.table_name
|
|
49
|
+
ORDER BY c.table_name
|
|
50
|
+
`),
|
|
51
|
+
execSql(project_id, `
|
|
52
|
+
SELECT e.name, e.method, e.is_tool, e.tool_description,
|
|
53
|
+
e.allowed_roles, g.name AS group_name,
|
|
54
|
+
jsonb_array_length(e.workflow_code->'nodes') AS node_count
|
|
55
|
+
FROM system.endpoints e
|
|
56
|
+
LEFT JOIN system.endpoints_group g ON g.id = e.group_id
|
|
57
|
+
WHERE e.is_active = true
|
|
58
|
+
ORDER BY e.name
|
|
59
|
+
`),
|
|
60
|
+
execSql(project_id, "SELECT name, type FROM system.credentials ORDER BY name"),
|
|
61
|
+
execSql(project_id, "SELECT name, description FROM system.roles ORDER BY name"),
|
|
62
|
+
execSql(project_id, `
|
|
63
|
+
SELECT id, name, public FROM storage.buckets ORDER BY name
|
|
64
|
+
`).catch(() => null),
|
|
65
|
+
project_id ? proxyToolCall("get_project", { project_id }).catch(() => null) : null,
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
// Aggregate groups
|
|
69
|
+
const groupSet = new Set((endpoints || []).map(e => e.group_name).filter(Boolean))
|
|
70
|
+
const toolEndpoints = (endpoints || []).filter(e => e.is_tool).map(e => ({
|
|
71
|
+
name: e.name,
|
|
72
|
+
description: e.tool_description || null,
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
// Next steps chosen based on project state
|
|
76
|
+
const nextSteps = []
|
|
77
|
+
if (!endpoints || endpoints.length === 0) {
|
|
78
|
+
nextSteps.push("Project is empty. Run dypai_pull to create ./dypai/, then add endpoints in endpoints/<name>.yaml")
|
|
79
|
+
} else {
|
|
80
|
+
nextSteps.push("Run dypai_pull to materialize ./dypai/ locally (endpoints, SQL, code)")
|
|
81
|
+
nextSteps.push("Read dypai/schema.sql for DB structure when writing queries")
|
|
82
|
+
nextSteps.push("Edit dypai/endpoints/<name>.yaml, then dypai_diff → dypai_push")
|
|
83
|
+
}
|
|
84
|
+
if (toolEndpoints.length > 0) {
|
|
85
|
+
nextSteps.push(`${toolEndpoints.length} tool endpoints exist — agents can call them via tools: [...] in agent nodes`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
project: project ? {
|
|
91
|
+
id: project.id,
|
|
92
|
+
name: project.name,
|
|
93
|
+
engine_url: project.engine_url,
|
|
94
|
+
plan: project.plan,
|
|
95
|
+
} : { id: project_id || "(from token)" },
|
|
96
|
+
|
|
97
|
+
database: {
|
|
98
|
+
table_count: (tables || []).length,
|
|
99
|
+
tables: (tables || []).map(t => ({
|
|
100
|
+
name: t.table_name,
|
|
101
|
+
columns: Number(t.column_count),
|
|
102
|
+
has_user_id: t.has_user_id,
|
|
103
|
+
has_timestamps: t.has_timestamps,
|
|
104
|
+
})),
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
endpoints: {
|
|
108
|
+
total: (endpoints || []).length,
|
|
109
|
+
groups: [...groupSet].sort(),
|
|
110
|
+
by_group: (endpoints || []).reduce((acc, e) => {
|
|
111
|
+
const g = e.group_name || "(no group)"
|
|
112
|
+
if (!acc[g]) acc[g] = []
|
|
113
|
+
acc[g].push({ name: e.name, method: e.method, nodes: Number(e.node_count), is_tool: e.is_tool })
|
|
114
|
+
return acc
|
|
115
|
+
}, {}),
|
|
116
|
+
tool_endpoints: toolEndpoints,
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
credentials: (creds || []).map(c => ({ name: c.name, type: c.type })),
|
|
120
|
+
|
|
121
|
+
roles: (roles || []).map(r => ({ name: r.name, description: r.description })),
|
|
122
|
+
|
|
123
|
+
buckets: (buckets || []).map(b => ({ name: b.name, public: b.public })),
|
|
124
|
+
|
|
125
|
+
placeholders_available: [
|
|
126
|
+
"${input.<field>} — request body / query params",
|
|
127
|
+
"${nodes.<node_id>.<field>} — output of a previous node",
|
|
128
|
+
"${current_user_id} — uuid of the authenticated user (jwt auth only)",
|
|
129
|
+
"${current_user_role} — role name of the authenticated user",
|
|
130
|
+
],
|
|
131
|
+
|
|
132
|
+
common_node_types: [
|
|
133
|
+
"dypai_database — SQL queries / inserts / updates",
|
|
134
|
+
"dypai_storage — file upload / download / delete",
|
|
135
|
+
"agent — LLM call with tools",
|
|
136
|
+
"javascript_code — custom JS logic (async function main(data, ctx))",
|
|
137
|
+
"set_fields — shape output / map values",
|
|
138
|
+
"http_request — call external APIs",
|
|
139
|
+
"logic — conditional branching",
|
|
140
|
+
],
|
|
141
|
+
|
|
142
|
+
next_steps: nextSteps,
|
|
143
|
+
|
|
144
|
+
hint: (endpoints || []).length > 0
|
|
145
|
+
? "This project has existing endpoints. Run dypai_pull to edit them, or use search_endpoints for quick inspection."
|
|
146
|
+
: "This is a fresh project. Create tables first with execute_sql, then add endpoints via dypai_push after creating YAMLs.",
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_diff — preview what dypai_push would change. Read-only, safe.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve as resolvePath } from "path"
|
|
6
|
+
import { fetchRemoteState, readLocalState, readLocalStateSnapshot, readLocalConfig, computePlan } from "./planner.js"
|
|
7
|
+
|
|
8
|
+
export const dypaiDiffTool = {
|
|
9
|
+
name: "dypai_diff",
|
|
10
|
+
description:
|
|
11
|
+
"Compare the local ./dypai/ folder against the remote project state without applying anything. " +
|
|
12
|
+
"Returns a plan: endpoints to create, update (with changed fields), delete, and unchanged. " +
|
|
13
|
+
"Always call this before dypai_push to preview changes safely.",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
project_id: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "Project UUID. Optional if your token is project-scoped.",
|
|
20
|
+
},
|
|
21
|
+
root_dir: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Root of the dypai/ folder (default: ./dypai).",
|
|
24
|
+
default: "./dypai",
|
|
25
|
+
},
|
|
26
|
+
delete_orphans: {
|
|
27
|
+
type: "boolean",
|
|
28
|
+
description: "If true, endpoints present in remote but missing locally are queued for deletion. Default: false (safe).",
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async execute({ project_id, root_dir = "./dypai", delete_orphans = false } = {}) {
|
|
35
|
+
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
36
|
+
|
|
37
|
+
const config = await readLocalConfig(rootDir)
|
|
38
|
+
const targetProjectId = project_id || config?.project_id || null
|
|
39
|
+
|
|
40
|
+
const [local, remote, stateSnapshot] = await Promise.all([
|
|
41
|
+
readLocalState(rootDir),
|
|
42
|
+
fetchRemoteState(targetProjectId),
|
|
43
|
+
readLocalStateSnapshot(rootDir),
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
if (local.errors.length) {
|
|
47
|
+
return {
|
|
48
|
+
success: false,
|
|
49
|
+
phase: "read_local",
|
|
50
|
+
errors: local.errors,
|
|
51
|
+
hint: "Fix the YAML/file errors before running diff again.",
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const plan = computePlan(local, remote, remote.mapsCtx, {
|
|
56
|
+
deleteOrphans: delete_orphans,
|
|
57
|
+
stateSnapshot,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const totalChanges = plan.create.length + plan.update.length + plan.delete.length
|
|
61
|
+
const hasConflicts = (plan.warnings || []).some(w => w.type === "remote_changed_since_pull")
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
success: true,
|
|
65
|
+
summary: {
|
|
66
|
+
create: plan.create.length,
|
|
67
|
+
update: plan.update.length,
|
|
68
|
+
delete: plan.delete.length,
|
|
69
|
+
unchanged: plan.unchanged.length,
|
|
70
|
+
orphans_ignored: plan.orphansIgnored?.length || 0,
|
|
71
|
+
warnings: plan.warnings?.length || 0,
|
|
72
|
+
},
|
|
73
|
+
// Only include plan details when there are actual changes or warnings
|
|
74
|
+
plan: (totalChanges > 0 || plan.warnings?.length) ? plan : undefined,
|
|
75
|
+
// Conflicts and missing creds deserve surfacing; no-op diffs don't
|
|
76
|
+
hint: hasConflicts
|
|
77
|
+
? "Remote changed since your last pull — dypai_pull first, or dypai_push will be blocked."
|
|
78
|
+
: (plan.warnings || []).some(w => w.type === "missing_credential")
|
|
79
|
+
? "Some YAMLs reference credentials missing remotely. Create them in the dashboard before pushing."
|
|
80
|
+
: undefined,
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-first source-of-truth tooling for DYPAI projects.
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* transforms.js — bidirectional field transforms (symmetric by construction)
|
|
6
|
+
* codec.js — endpoint-level serialize / deserialize (structural shape)
|
|
7
|
+
* pull.js — DB → filesystem (uses codec.serializeEndpoint)
|
|
8
|
+
* push.js — filesystem → DB (uses codec.deserializeEndpoint) [pending]
|
|
9
|
+
* diff.js — compares both sides without writing [pending]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { dypaiPullTool } from "./pull.js"
|
|
13
|
+
export { dypaiDiffTool } from "./diff.js"
|
|
14
|
+
export { dypaiPushTool } from "./push.js"
|
|
15
|
+
export { dypaiDescribeTool } from "./describe.js"
|
|
16
|
+
export { dypaiValidateTool } from "./validate.js"
|
|
17
|
+
export { dypaiTestTool } from "./test.js"
|
|
18
|
+
export { dypaiTestEndpointTool } from "./test-endpoint.js"
|