@dypai-ai/mcp 1.0.10 → 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,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
+ }
@@ -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.content
119
- if (Array.isArray(content) && content[0]?.text) {
120
- try {
121
- return JSON.parse(content[0].text)
122
- } catch {
123
- return content[0].text
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,97 @@
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
+ import { regenerateTypes } from "./codegen.js"
17
+
18
+ // Matches CREATE/ALTER/DROP/TRUNCATE/RENAME on TABLE — the only DDL shapes we
19
+ // currently reflect in schema.sql. CREATE INDEX / FUNCTION / TYPE are skipped.
20
+ const SCHEMA_AFFECTING_DDL = /\b(CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE\b/i
21
+
22
+ /** Detect whether a SQL statement would change what schema.sql captures. */
23
+ export function affectsPublicSchema(sql) {
24
+ if (!sql || typeof sql !== "string") return false
25
+ return SCHEMA_AFFECTING_DDL.test(sql)
26
+ }
27
+
28
+ /**
29
+ * Find the dypai/ folder near the current workspace. We follow the same
30
+ * signals as pull.js so users don't have to configure twice:
31
+ * 1. CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS env vars
32
+ * 2. Walk up from cwd looking for a dypai/ folder that contains schema.sql
33
+ */
34
+ function findDypaiFolder() {
35
+ const envCandidates = [
36
+ process.env.CLAUDE_PROJECT_DIR,
37
+ process.env.DYPAI_WORKSPACE_ROOT,
38
+ process.env.WORKSPACE_FOLDER_PATHS?.split(delimiter)[0],
39
+ process.env.PROJECT_ROOT,
40
+ ].filter(v => v && isAbsolute(v))
41
+
42
+ for (const base of envCandidates) {
43
+ const candidate = join(base, "dypai")
44
+ if (existsSync(join(candidate, "schema.sql"))) return candidate
45
+ // Maybe the user already opened directly into the dypai folder
46
+ if (existsSync(join(base, "schema.sql")) && existsSync(join(base, "endpoints"))) return base
47
+ }
48
+
49
+ // Walk up from cwd
50
+ let cursor = process.cwd()
51
+ const home = homedir()
52
+ for (let i = 0; i < 8; i++) {
53
+ const candidate = join(cursor, "dypai")
54
+ if (existsSync(join(candidate, "schema.sql"))) return candidate
55
+ if (cursor === home || cursor === "/") break
56
+ const parent = dirname(cursor)
57
+ if (parent === cursor) break
58
+ cursor = parent
59
+ }
60
+
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * After a successful execute_sql, rewrite dypai/schema.sql if the query
66
+ * affected the schema AND a dypai/ folder is present locally.
67
+ * Returns { refreshed: boolean, path?: string, reason?: string, error?: string }.
68
+ */
69
+ export async function maybeRefreshSchemaAfterExecuteSql(args, result) {
70
+ if (result?.isError) return { refreshed: false, reason: "execute_sql failed" }
71
+ const sql = args?.sql
72
+ if (!affectsPublicSchema(sql)) return { refreshed: false, reason: "non-DDL query" }
73
+
74
+ const dypaiDir = findDypaiFolder()
75
+ if (!dypaiDir) return { refreshed: false, reason: "no local dypai/ folder found" }
76
+
77
+ try {
78
+ const newSchema = await dumpPublicSchema(args.project_id)
79
+ const target = join(dypaiDir, "schema.sql")
80
+ await writeFile(target, newSchema, "utf8")
81
+
82
+ // Schema changed → frontend TS types need to follow. Best-effort.
83
+ let codegen
84
+ try { codegen = await regenerateTypes(dypaiDir) }
85
+ catch (e) { codegen = { skipped: true, reason: e.message } }
86
+
87
+ return {
88
+ refreshed: true,
89
+ path: target,
90
+ types_regenerated: !!codegen?.regenerated,
91
+ types_output: codegen?.output_dir,
92
+ }
93
+ } catch (e) {
94
+ // Don't fail the execute_sql call itself — just report the side-effect failure.
95
+ return { refreshed: false, error: e.message }
96
+ }
97
+ }
@@ -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"