@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.
@@ -1,26 +1,29 @@
1
1
  /**
2
2
  * Auto-inject project_id into tool calls that accept it.
3
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.
4
+ * Resolution order:
5
+ * 1. DYPAI_PROJECT_ID env (Studio / cursor-worker bound project)
6
+ * 2. dypai/dypai.config.yaml in the workspace
8
7
  *
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.
8
+ * Studio profiles hide project_id from tool schemas — the bound id is injected
9
+ * server-side so the agent never passes it.
14
10
  */
15
11
 
16
12
  import { readFileSync, existsSync } from "fs"
17
13
  import { join, isAbsolute, dirname, delimiter } from "path"
18
14
  import { homedir } from "os"
19
15
  import YAML from "yaml"
16
+ import { isStudioProfile } from "../toolProfiles.js"
20
17
 
21
18
  let _cached = null // { projectId, configPath } | null
22
19
  let _resolved = false
23
20
 
21
+ export function getEnvBoundProjectId(env = process.env) {
22
+ const raw = env.DYPAI_PROJECT_ID
23
+ if (typeof raw === "string" && raw.trim()) return raw.trim()
24
+ return null
25
+ }
26
+
24
27
  function findDypaiFolder() {
25
28
  const envCandidates = [
26
29
  process.env.CLAUDE_PROJECT_DIR,
@@ -48,7 +51,7 @@ function findDypaiFolder() {
48
51
  return null
49
52
  }
50
53
 
51
- function resolveProjectId() {
54
+ function resolveProjectIdFromConfig() {
52
55
  if (_resolved) return _cached
53
56
  _resolved = true
54
57
  try {
@@ -71,6 +74,56 @@ export function invalidateProjectContext() {
71
74
  _cached = null
72
75
  }
73
76
 
77
+ /**
78
+ * Resolved project id for tool calls.
79
+ * @returns {{ projectId: string, source: "env" | "config", configPath?: string } | null}
80
+ */
81
+ export function resolveBoundProjectId() {
82
+ const envId = getEnvBoundProjectId()
83
+ if (envId) return { projectId: envId, source: "env" }
84
+
85
+ const configCtx = resolveProjectIdFromConfig()
86
+ if (configCtx) {
87
+ return { projectId: configCtx.projectId, source: "config", configPath: configCtx.configPath }
88
+ }
89
+ return null
90
+ }
91
+
92
+ export function omitProjectIdFromInputSchema(inputSchema) {
93
+ if (!inputSchema?.properties?.project_id) return inputSchema
94
+
95
+ const { project_id: _removed, ...properties } = inputSchema.properties
96
+ const required = Array.isArray(inputSchema.required)
97
+ ? inputSchema.required.filter((key) => key !== "project_id")
98
+ : inputSchema.required
99
+
100
+ const next = { ...inputSchema, properties }
101
+ if (required !== undefined) next.required = required
102
+ return next
103
+ }
104
+
105
+ export function presentToolInputSchema(tool, profile) {
106
+ if (!isStudioProfile(profile)) return tool.inputSchema
107
+ return omitProjectIdFromInputSchema(tool.inputSchema)
108
+ }
109
+
110
+ /**
111
+ * Studio runs bind one project — reject explicit overrides from the agent.
112
+ * @returns {string | null} error message
113
+ */
114
+ export function assertBoundProjectIdMatches(profile, args) {
115
+ if (!isStudioProfile(profile)) return null
116
+ const bound = resolveBoundProjectId()
117
+ if (!bound?.projectId || !args?.project_id) return null
118
+ if (args.project_id !== bound.projectId) {
119
+ return (
120
+ `project_id is bound to ${bound.projectId} in Studio (DYPAI_PROJECT_ID). ` +
121
+ "Do not pass project_id — the MCP server injects it automatically."
122
+ )
123
+ }
124
+ return null
125
+ }
126
+
74
127
  /**
75
128
  * Inject project_id into args if the tool's schema declares it and the caller
76
129
  * didn't provide one. No-op otherwise. Never overwrites an explicit value.
@@ -78,7 +131,7 @@ export function invalidateProjectContext() {
78
131
  export function withProjectContext(toolDef, args) {
79
132
  if (!toolDef?.inputSchema?.properties?.project_id) return args
80
133
  if (args?.project_id) return args
81
- const ctx = resolveProjectId()
82
- if (!ctx) return args
83
- return { ...args, project_id: ctx.projectId }
134
+ const bound = resolveBoundProjectId()
135
+ if (!bound) return args
136
+ return { ...args, project_id: bound.projectId }
84
137
  }
@@ -13,8 +13,40 @@ import http from "http"
13
13
 
14
14
  const MCP_BASE = process.env.DYPAI_MCP_URL || "https://mcp.dypai.dev"
15
15
  const MCP_ENDPOINT = `${MCP_BASE}/mcp`
16
+ // Slightly above MCP cloud POST timeout so the server 504s before we abort.
17
+ const MCP_PROXY_TIMEOUT_MS = Math.max(
18
+ 30_000,
19
+ Number(process.env.DYPAI_MCP_PROXY_TIMEOUT_MS || 620_000),
20
+ )
16
21
 
17
22
  let sessionId = null
23
+ let initPromise = null
24
+
25
+ function requestLabel(body) {
26
+ const method = body?.method || "mcp"
27
+ const tool = body?.params?.name
28
+ return tool ? `${method}:${tool}` : method
29
+ }
30
+
31
+ function wrapTransportError(error, label) {
32
+ const message = error?.message || String(error)
33
+ const wrapped = new Error(`MCP ${label} transport error: ${message}`)
34
+ if (error?.code) wrapped.code = error.code
35
+ return wrapped
36
+ }
37
+
38
+ function isRecoverableTransportError(error) {
39
+ const message = error?.message || ""
40
+ return /ECONNRESET|ECONNREFUSED|EPIPE|socket hang up|fetch failed|invalid session|session not found|MCP remote error 404/i.test(message)
41
+ }
42
+
43
+ function resetMcpSession(reason) {
44
+ if (sessionId || initPromise) {
45
+ process.stderr.write(`[dypai-mcp-proxy] resetting MCP session: ${reason}\n`)
46
+ }
47
+ sessionId = null
48
+ initPromise = null
49
+ }
18
50
 
19
51
  function extractLastSseData(text) {
20
52
  const events = text.split(/\r?\n\r?\n/).filter(Boolean)
@@ -82,6 +114,7 @@ function normalizeJsonRpcResponse(response) {
82
114
 
83
115
  function mcpRequest(body) {
84
116
  const token = process.env.DYPAI_TOKEN || ""
117
+ const label = requestLabel(body)
85
118
 
86
119
  return new Promise((resolve, reject) => {
87
120
  const parsed = new URL(MCP_ENDPOINT)
@@ -94,6 +127,10 @@ function mcpRequest(body) {
94
127
  Authorization: `Bearer ${token}`,
95
128
  Accept: "application/json, text/event-stream",
96
129
  }
130
+ const docsChannel = process.env.DOCS_CHANNEL?.trim()
131
+ const docSet = process.env.DOC_SET?.trim()
132
+ if (docsChannel) headers["X-DYPAI-Docs-Channel"] = docsChannel
133
+ if (docSet) headers["X-DYPAI-Doc-Set"] = docSet
97
134
  if (sessionId) {
98
135
  headers["Mcp-Session-Id"] = sessionId
99
136
  }
@@ -111,6 +148,12 @@ function mcpRequest(body) {
111
148
 
112
149
  let buf = ""
113
150
  res.on("data", (c) => (buf += c))
151
+ res.on("aborted", () => {
152
+ reject(new Error(`MCP ${label} response aborted after ${buf.length} bytes`))
153
+ })
154
+ res.on("error", (error) => {
155
+ reject(wrapTransportError(error, label))
156
+ })
114
157
  res.on("end", () => {
115
158
  if (res.statusCode >= 200 && res.statusCode < 300) {
116
159
  // Handle SSE responses (text/event-stream)
@@ -121,21 +164,19 @@ function mcpRequest(body) {
121
164
  try { resolve(JSON.parse(buf)) } catch { resolve({ result: buf }) }
122
165
  }
123
166
  } else {
124
- reject(new Error(`MCP remote error ${res.statusCode}: ${buf.slice(0, 300)}`))
167
+ reject(new Error(`MCP remote error ${res.statusCode} during ${label}: ${buf.slice(0, 300)}`))
125
168
  }
126
169
  })
127
170
  })
128
- req.on("error", reject)
171
+ req.on("error", (error) => reject(wrapTransportError(error, label)))
172
+ req.setTimeout(MCP_PROXY_TIMEOUT_MS, () => {
173
+ req.destroy(new Error(`MCP proxy timeout after ${MCP_PROXY_TIMEOUT_MS}ms during ${label} (${MCP_ENDPOINT})`))
174
+ })
129
175
  req.write(data)
130
176
  req.end()
131
177
  })
132
178
  }
133
179
 
134
- // Single in-flight init promise so concurrent first-calls (e.g. push with
135
- // concurrency=5) don't all race to initialize and clobber each other's
136
- // session id mid-handshake.
137
- let initPromise = null
138
-
139
180
  async function ensureInitialized() {
140
181
  if (sessionId) return
141
182
  if (initPromise) return initPromise
@@ -169,51 +210,63 @@ async function ensureInitialized() {
169
210
  }
170
211
 
171
212
  export async function proxyToolCall(toolName, args) {
172
- await ensureInitialized()
173
-
174
- const response = normalizeJsonRpcResponse(await mcpRequest({
175
- jsonrpc: "2.0",
176
- id: `proxy-${Date.now()}`,
177
- method: "tools/call",
178
- params: {
179
- name: toolName,
180
- arguments: args || {},
181
- },
182
- }))
183
-
184
- // Extract result from JSON-RPC response
185
- if (response.result) {
186
- const { content, isError } = response.result
187
- const text = Array.isArray(content) && content[0]?.text ? content[0].text : null
188
-
189
- if (isError) {
190
- throw new Error(text || "Remote tool call failed with isError=true")
191
- }
213
+ async function callOnce() {
214
+ await ensureInitialized()
215
+
216
+ const response = normalizeJsonRpcResponse(await mcpRequest({
217
+ jsonrpc: "2.0",
218
+ id: `proxy-${Date.now()}`,
219
+ method: "tools/call",
220
+ params: {
221
+ name: toolName,
222
+ arguments: args || {},
223
+ },
224
+ }))
192
225
 
193
- if (text != null) {
194
- let parsed
195
- parsed = decodeTransportValue(text)
196
- // Some remote errors come as a plain string without isError flag
197
- if (typeof parsed === "string" && /^(Error:|Input validation error:|You don't have)/i.test(parsed)) {
198
- throw new Error(parsed)
226
+ // Extract result from JSON-RPC response
227
+ if (response.result) {
228
+ const { content, isError } = response.result
229
+ const text = Array.isArray(content) && content[0]?.text ? content[0].text : null
230
+
231
+ if (isError) {
232
+ throw new Error(text || "Remote tool call failed with isError=true")
199
233
  }
200
- // Other remote errors come as a JSON object with success:false but no isError
201
- // flag. The push pipeline was treating these as successes — promote them to
202
- // throws here so the caller's try/catch sees them.
203
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
204
- if (parsed.success === false || parsed.ok === false) {
205
- const msg = parsed.error || parsed.message || parsed.detail || JSON.stringify(parsed).slice(0, 300)
206
- throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg))
234
+
235
+ if (text != null) {
236
+ let parsed
237
+ parsed = decodeTransportValue(text)
238
+ // Some remote errors come as a plain string without isError flag
239
+ if (typeof parsed === "string" && /^(Error:|Input validation error:|You don't have)/i.test(parsed)) {
240
+ throw new Error(parsed)
207
241
  }
242
+ // Other remote errors come as a JSON object with success:false but no isError
243
+ // flag. The push pipeline was treating these as successes — promote them to
244
+ // throws here so the caller's try/catch sees them.
245
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
246
+ if (parsed.success === false || parsed.ok === false) {
247
+ const msg = parsed.error || parsed.message || parsed.detail || JSON.stringify(parsed).slice(0, 300)
248
+ throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg))
249
+ }
250
+ }
251
+ return parsed
208
252
  }
209
- return parsed
253
+ return response.result
254
+ }
255
+
256
+ if (response.error) {
257
+ throw new Error(response.error.message || "Remote tool call failed")
210
258
  }
211
- return response.result
212
- }
213
259
 
214
- if (response.error) {
215
- throw new Error(response.error.message || "Remote tool call failed")
260
+ return response
216
261
  }
217
262
 
218
- return response
263
+ try {
264
+ return await callOnce()
265
+ } catch (error) {
266
+ if (isRecoverableTransportError(error)) {
267
+ resetMcpSession(error.message)
268
+ return await callOnce()
269
+ }
270
+ throw error
271
+ }
219
272
  }
@@ -162,3 +162,20 @@ export function formatValidationError(v) {
162
162
  if (v.ok) return null
163
163
  return v.hint ? `${v.error}\nHint: ${v.hint}` : v.error
164
164
  }
165
+
166
+ const MULTI_STATEMENT_RE =
167
+ /;\s*(?:SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|SET|GRANT|REVOKE|TRUNCATE|COPY|WITH|DO|COMMENT|LOAD|EXPLAIN)\b/i
168
+
169
+ export function shouldRouteSqlAsScript(sql) {
170
+ const cleaned = stripSqlNoise(sql)
171
+ return MULTI_STATEMENT_RE.test(cleaned)
172
+ }
173
+
174
+ export function estimateStatementCount(sql) {
175
+ if (!shouldRouteSqlAsScript(sql)) return 1
176
+ const cleaned = stripSqlNoise(sql.trim())
177
+ let count = 1
178
+ const re = new RegExp(MULTI_STATEMENT_RE.source, "gi")
179
+ while (re.exec(cleaned)) count += 1
180
+ return count
181
+ }
@@ -6,21 +6,13 @@
6
6
  * - DRAFT : what's already been staged via prior pushes (system.config_drafts)
7
7
  * - LIVE : what the engine actually serves at <project_id>.dypai.dev
8
8
  *
9
- * The diff body reports the LOCAL → LIVE plan (existing behavior) — that's
10
- * what `dypai_push` will queue. We additionally surface the DRAFT layer so
11
- * the agent can spot:
12
- * 1. Endpoints with a pending draft AND a local change → pushing again
13
- * will REPLACE the existing draft with the new local version.
14
- * 2. Endpoints with only a pending draft → run manage_drafts to publish/discard.
15
- * 3. Endpoints with only a local change → push will create a new draft.
16
- *
17
- * A full 3-way semantic diff (local vs draft vs live, field-by-field) would
18
- * require reserializing each draft payload and comparing — deferred. The
19
- * overlap signal here covers the common workflow without the extra cost.
9
+ * The diff body reports LOCAL → EFFECTIVE_REMOTE (live + pending drafts) —
10
+ * what `dypai_push` will actually queue. We additionally surface the DRAFT
11
+ * layer metadata so the agent can spot overlap and publish/discard decisions.
20
12
  */
21
13
 
22
14
  import { resolve as resolvePath } from "path"
23
- import { fetchRemoteState, readLocalState, readLocalStateSnapshot, readLocalConfig, computePlan } from "./planner.js"
15
+ import { fetchRemoteState, readLocalEffectiveState, readLocalStateSnapshot, readLocalConfig, computePlan, buildEffectiveRemoteState } from "./planner.js"
24
16
  import { proxyToolCall } from "../proxy.js"
25
17
 
26
18
  export const dypaiDiffTool = {
@@ -56,7 +48,7 @@ export const dypaiDiffTool = {
56
48
  const targetProjectId = project_id || config?.project_id || null
57
49
 
58
50
  const [local, remote, stateSnapshot, draftsResult] = await Promise.all([
59
- readLocalState(rootDir),
51
+ readLocalEffectiveState(rootDir),
60
52
  fetchRemoteState(targetProjectId),
61
53
  readLocalStateSnapshot(rootDir),
62
54
  // Pending drafts. Cheap on dev (always 0); on prod surfaces what's
@@ -85,9 +77,11 @@ export const dypaiDiffTool = {
85
77
  }
86
78
  }
87
79
 
88
- const plan = computePlan(local, remote, remote.mapsCtx, {
80
+ const effectiveRemote = buildEffectiveRemoteState(remote, draftsResult)
81
+ const plan = computePlan(local, effectiveRemote, remote.mapsCtx, {
89
82
  deleteOrphans: delete_orphans,
90
83
  stateSnapshot,
84
+ liveRemote: remote,
91
85
  })
92
86
 
93
87
  const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
@@ -144,6 +138,8 @@ export const dypaiDiffTool = {
144
138
  ? "Some YAMLs reference credentials missing remotely. Create them in the dashboard before pushing."
145
139
  : overlap.length > 0
146
140
  ? `${overlap.length} endpoint(s) have BOTH a pending draft AND a local change — dypai_push will REPLACE the existing draft with the new version. Review pending_drafts.overlap_with_local before pushing.`
141
+ : totalChanges === 0 && draftCount > 0
142
+ ? `${draftCount} pending draft(s) — local matches what's already staged. Test with dypai_test_endpoint(mode:'draft'), then manage_drafts(operation:'publish', confirm:true) when ready.`
147
143
  : draftCount > 0 && totalChanges > 0
148
144
  ? `${draftCount} draft(s) already pending; this diff would queue ${totalChanges} more. Consider manage_drafts(operation:'publish'|'discard', confirm:true) first to keep the staged set focused.`
149
145
  : draftCount > 0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Regenerate dypai/types/endpoints.gen.ts from effective Flow/YAML contracts.
3
+ */
4
+
5
+ import { resolve as resolvePath } from "path"
6
+ import {
7
+ resolveProjectRootFromDypaiDir,
8
+ runEffectiveWorkflowsCli,
9
+ } from "../../lib/effective-workflows-runner.js"
10
+
11
+ export async function runGenerateEndpointTypes(rootDir) {
12
+ const projectRoot = resolveProjectRootFromDypaiDir(resolvePath(rootDir))
13
+ const result = runEffectiveWorkflowsCli(projectRoot, ["generate-types"])
14
+
15
+ if (result.unavailable) {
16
+ return {
17
+ ok: false,
18
+ skipped: true,
19
+ reason: "runner_unavailable",
20
+ warning: result.warning?.message || "Effective workflow runner unavailable.",
21
+ }
22
+ }
23
+
24
+ if (!result.ok) {
25
+ return {
26
+ ok: false,
27
+ error: result.error || "generate-types failed",
28
+ stderr: result.stderr,
29
+ }
30
+ }
31
+
32
+ const payload = result.data
33
+ if (!payload?.ok) {
34
+ return {
35
+ ok: false,
36
+ error: payload?.error || "generate-types returned failure",
37
+ }
38
+ }
39
+
40
+ return {
41
+ ok: true,
42
+ endpointCount: payload.endpointCount ?? 0,
43
+ typesPath: payload.typesPath,
44
+ contractPath: payload.contractPath,
45
+ endpoints: payload.endpoints || [],
46
+ }
47
+ }
48
+
49
+ export const dypaiGenerateTypesTool = {
50
+ name: "dypai_generate_types",
51
+ description:
52
+ "Regenerate frontend endpoint types from the local dypai/ backend (Flow IR + legacy YAML). " +
53
+ "Writes dypai/types/endpoints.gen.ts and dypai/types/endpoints.contract.json. " +
54
+ "Call after editing dypai/flows/*.flow.ts or endpoint YAML, before frontend work. " +
55
+ "Also runs automatically during dypai_push after validation.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ root_dir: {
60
+ type: "string",
61
+ description: "Root of the dypai/ folder (default: ./dypai).",
62
+ default: "./dypai",
63
+ },
64
+ },
65
+ },
66
+
67
+ async execute({ root_dir = "./dypai" } = {}) {
68
+ const outcome = await runGenerateEndpointTypes(root_dir)
69
+ if (outcome.skipped) {
70
+ return {
71
+ success: false,
72
+ generated: false,
73
+ reason: outcome.reason,
74
+ hint: outcome.warning,
75
+ }
76
+ }
77
+ if (!outcome.ok) {
78
+ return {
79
+ success: false,
80
+ generated: false,
81
+ error: outcome.error,
82
+ stderr: outcome.stderr,
83
+ }
84
+ }
85
+ return {
86
+ success: true,
87
+ generated: true,
88
+ endpoint_count: outcome.endpointCount,
89
+ types_path: "dypai/types/endpoints.gen.ts",
90
+ contract_path: "dypai/types/endpoints.contract.json",
91
+ endpoints: outcome.endpoints.slice(0, 50),
92
+ hint: outcome.endpointCount
93
+ ? `Types refreshed for ${outcome.endpointCount} endpoint(s). Frontend can import from dypai/types/endpoints.gen.ts.`
94
+ : "No effective endpoints found — types file is empty.",
95
+ }
96
+ },
97
+ }
98
+
99
+ export const __testing = {
100
+ runGenerateEndpointTypes,
101
+ }
@@ -5,13 +5,15 @@
5
5
  * transforms.js — bidirectional field transforms (symmetric by construction)
6
6
  * codec.js — endpoint-level serialize / deserialize (structural shape)
7
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]
8
+ * push.js — filesystem → DB (uses codec.deserializeEndpoint + effective Flow IR)
9
+ * diff.js — compares both sides without writing
10
+ * generate-types.js — regenerates dypai/types/endpoints.gen.ts from effective contracts
10
11
  */
11
12
 
12
13
  export { dypaiPullTool } from "./pull.js"
13
14
  export { dypaiDiffTool } from "./diff.js"
14
15
  export { dypaiPushTool } from "./push.js"
16
+ export { dypaiGenerateTypesTool } from "./generate-types.js"
15
17
  export { dypaiDescribeTool } from "./describe.js"
16
18
  export { dypaiValidateTool } from "./validate.js"
17
19
  export { dypaiTestTool } from "./test.js"