coalesce-transform-mcp 0.1.6 → 0.1.7

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/README.md CHANGED
@@ -66,26 +66,30 @@ The server defaults to the US region. See [Environment Variables](#environment-v
66
66
 
67
67
  Only `COALESCE_ACCESS_TOKEN` is required. Everything else is optional.
68
68
 
69
+ <!-- ENV_METADATA_CORE_TABLE_START -->
69
70
  | Variable | Description | Default |
70
- | -------- | ----------- | ------- |
71
+ | -------- | -------- | -------- |
71
72
  | `COALESCE_ACCESS_TOKEN` | **Required.** Bearer token from the Coalesce Deploy tab. | — |
72
- | `COALESCE_BASE_URL` | Region-specific base URL. | `https://app.coalescesoftware.io` (US) |
73
- | `COALESCE_ORG_ID` | Fallback org ID for `cancel-run`. | — |
73
+ | `COALESCE_BASE_URL` | Region-specific base URL. | `https://app.coalescesoftware.io (US)` |
74
+ | `COALESCE_ORG_ID` | Fallback org ID for cancel-run. | — |
74
75
  | `COALESCE_REPO_PATH` | Local repo root for repo-backed tools and pipeline planning. | — |
75
76
  | `COALESCE_MCP_AUTO_CACHE_MAX_BYTES` | JSON size threshold before auto-caching to disk. | `32768` |
76
77
  | `COALESCE_MCP_MAX_REQUEST_BODY_BYTES` | Max outbound API request body size. | `524288` |
78
+ <!-- ENV_METADATA_CORE_TABLE_END -->
77
79
 
78
80
  ### Snowflake (for run tools only)
79
81
 
80
82
  Required for `start-run`, `retry-run`, `run-and-wait`, and `retry-and-wait`. The server starts without them — they're validated when you first use a run tool.
81
83
 
84
+ <!-- ENV_METADATA_SNOWFLAKE_TABLE_START -->
82
85
  | Variable | Required | Description |
83
- | -------- | -------- | ----------- |
86
+ | -------- | -------- | -------- |
84
87
  | `SNOWFLAKE_USERNAME` | Yes | Snowflake account username |
85
88
  | `SNOWFLAKE_KEY_PAIR_KEY` | Yes | Path to PEM-encoded private key |
86
89
  | `SNOWFLAKE_KEY_PAIR_PASS` | No | Passphrase for encrypted keys |
87
90
  | `SNOWFLAKE_WAREHOUSE` | Yes | Snowflake compute warehouse |
88
91
  | `SNOWFLAKE_ROLE` | Yes | Snowflake user role |
92
+ <!-- ENV_METADATA_SNOWFLAKE_TABLE_END -->
89
93
 
90
94
  To use optional variables, add them to your shell profile and pass them through in your MCP config. Here's a full example with everything enabled:
91
95
 
@@ -1,3 +1,21 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { CoalesceClient } from "../client.js";
3
+ /**
4
+ * Generates a confirmation token for a pipeline plan to prevent bypass of user approval.
5
+ *
6
+ * The token is a SHA256 hash (truncated to 16 hex chars) of the canonicalized plan JSON.
7
+ * AI agents must provide this token when calling pipeline creation tools with `confirmed=true`,
8
+ * proving they received and can reference the exact plan that should have been presented to the user.
9
+ *
10
+ * **Important limitations:**
11
+ * - The token proves the agent received the correct plan (plan integrity)
12
+ * - It does NOT verify the agent presented the plan accurately to the user
13
+ * - An agent could theoretically show incomplete/misleading info but still provide the valid token
14
+ * - This is an acceptable tradeoff: the token prevents accidental bypass and honest mistakes,
15
+ * while deliberate deception by a malicious agent is out of scope
16
+ *
17
+ * @param plan - The pipeline plan object to fingerprint
18
+ * @returns A 16-character hex token uniquely identifying this plan's content
19
+ */
20
+ export declare function buildPlanConfirmationToken(plan: unknown): string;
3
21
  export declare function registerPipelineTools(server: McpServer, client: CoalesceClient): void;
@@ -4,24 +4,94 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFile
4
4
  import { join } from "node:path";
5
5
  import { CACHE_DIR_NAME } from "../cache-dir.js";
6
6
  import { PipelinePlanSchema, planPipeline, } from "../services/pipelines/planning.js";
7
- import { createPipelineFromPlan, createPipelineFromSql, } from "../services/pipelines/execution.js";
7
+ import { createPipelineFromPlan, } from "../services/pipelines/execution.js";
8
8
  import { NodeConfigInputSchema } from "../schemas/node-payloads.js";
9
9
  import { buildJsonToolResponse, handleToolError, READ_ONLY_ANNOTATIONS, WRITE_ANNOTATIONS, } from "../coalesce/types.js";
10
10
  import { isPlainObject } from "../utils.js";
11
- const REWRITTEN_SQL_ERROR_MESSAGE = "The sql parameter contains {{ ref() }} syntax, which means you rewrote the user's SQL. " +
12
- "Pass the user's EXACT SQL unchanged the planner resolves source references automatically. " +
13
- "Do NOT replace table names with {{ ref() }}.";
14
- function buildPlanFingerprint(workspaceID, repoPath, workspaceNodeTypes, requestInputs) {
15
- const input = [
16
- `workspace:${workspaceID}`,
17
- `repo:${repoPath ?? "none"}`,
18
- `types:${[...workspaceNodeTypes].sort().join(",")}`,
19
- `goal:${requestInputs?.goal ?? ""}`,
20
- `sql:${requestInputs?.sql ?? ""}`,
21
- `sources:${requestInputs?.sourceNodeIDs ? [...requestInputs.sourceNodeIDs].sort().join(",") : ""}`,
22
- `targetType:${requestInputs?.targetNodeType ?? ""}`,
23
- ].join("|");
24
- return createHash("sha256").update(input).digest("hex").slice(0, 16);
11
+ /**
12
+ * Recursively sorts JSON values to ensure deterministic serialization.
13
+ *
14
+ * Object keys are sorted alphabetically to guarantee that structurally
15
+ * identical objects produce identical JSON strings when serialized.
16
+ * This is essential for generating consistent confirmation tokens via
17
+ * hashing, where the same plan content must always yield the same hash
18
+ * regardless of key insertion order.
19
+ *
20
+ * @param value - The value to sort (arrays, objects, or primitives)
21
+ * @returns A deep copy with all object keys sorted alphabetically
22
+ */
23
+ function sortJsonValue(value) {
24
+ if (Array.isArray(value)) {
25
+ return value.map(sortJsonValue);
26
+ }
27
+ if (!isPlainObject(value)) {
28
+ return value;
29
+ }
30
+ const sorted = {};
31
+ for (const key of Object.keys(value).sort()) {
32
+ const nested = sortJsonValue(value[key]);
33
+ if (nested !== undefined) {
34
+ sorted[key] = nested;
35
+ }
36
+ }
37
+ return sorted;
38
+ }
39
+ function normalizePlanFingerprintSelection(selection) {
40
+ if (!isPlainObject(selection)) {
41
+ return null;
42
+ }
43
+ return {
44
+ strategy: typeof selection.strategy === "string" ? selection.strategy : null,
45
+ selectedNodeType: typeof selection.selectedNodeType === "string" ? selection.selectedNodeType : null,
46
+ selectedDisplayName: typeof selection.selectedDisplayName === "string"
47
+ ? selection.selectedDisplayName
48
+ : null,
49
+ selectedShortName: typeof selection.selectedShortName === "string" ? selection.selectedShortName : null,
50
+ selectedFamily: typeof selection.selectedFamily === "string" ? selection.selectedFamily : null,
51
+ confidence: typeof selection.confidence === "string" ? selection.confidence : null,
52
+ autoExecutable: selection.autoExecutable === true,
53
+ repoPath: typeof selection.repoPath === "string" ? selection.repoPath : null,
54
+ resolvedRepoPath: typeof selection.resolvedRepoPath === "string" ? selection.resolvedRepoPath : null,
55
+ supportedNodeTypes: Array.isArray(selection.supportedNodeTypes)
56
+ ? selection.supportedNodeTypes.filter((value) => typeof value === "string")
57
+ : [],
58
+ consideredNodeTypes: Array.isArray(selection.consideredNodeTypes)
59
+ ? selection.consideredNodeTypes
60
+ .filter(isPlainObject)
61
+ .map((candidate) => ({
62
+ nodeType: typeof candidate.nodeType === "string" ? candidate.nodeType : null,
63
+ displayName: typeof candidate.displayName === "string" ? candidate.displayName : null,
64
+ shortName: typeof candidate.shortName === "string" ? candidate.shortName : null,
65
+ family: typeof candidate.family === "string" ? candidate.family : null,
66
+ usageCount: typeof candidate.usageCount === "number" ? candidate.usageCount : null,
67
+ workspaceUsageCount: typeof candidate.workspaceUsageCount === "number"
68
+ ? candidate.workspaceUsageCount
69
+ : null,
70
+ observedInWorkspace: candidate.observedInWorkspace === true,
71
+ autoExecutable: candidate.autoExecutable === true,
72
+ score: typeof candidate.score === "number" ? candidate.score : null,
73
+ reasons: Array.isArray(candidate.reasons)
74
+ ? candidate.reasons.filter((value) => typeof value === "string")
75
+ : [],
76
+ }))
77
+ : [],
78
+ };
79
+ }
80
+ function buildPlanFingerprint(workspaceID, selection, supportedNodeTypes, requestInputs) {
81
+ const payload = sortJsonValue({
82
+ workspaceID,
83
+ requestInputs: {
84
+ goal: requestInputs?.goal ?? null,
85
+ sql: requestInputs?.sql ?? null,
86
+ sourceNodeIDs: requestInputs?.sourceNodeIDs
87
+ ? [...requestInputs.sourceNodeIDs].sort()
88
+ : [],
89
+ targetNodeType: requestInputs?.targetNodeType ?? null,
90
+ },
91
+ supportedNodeTypes: [...supportedNodeTypes],
92
+ selection: normalizePlanFingerprintSelection(selection),
93
+ });
94
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 16);
25
95
  }
26
96
  function getPlanSummaryDir() {
27
97
  return join(process.cwd(), CACHE_DIR_NAME, "plans");
@@ -69,9 +139,10 @@ function writePlanSummary(plan, fingerprint) {
69
139
  `Fingerprint: ${fingerprint}`,
70
140
  `Generated: ${new Date().toISOString()}`,
71
141
  ``,
72
- `This file is automatically invalidated when the repo node types or workspace`,
73
- `node types change. If you install new packages or commit new node type`,
74
- `definitions, call plan-pipeline again to refresh.`,
142
+ `This file is automatically invalidated when repo-backed ranking inputs or`,
143
+ `workspace node types change enough to alter the planner's ranked guidance.`,
144
+ `If you install new packages, commit new node type definitions, or otherwise`,
145
+ `change ranking-relevant repo content, call plan-pipeline again to refresh.`,
75
146
  ``,
76
147
  `## Ranked Node Types`,
77
148
  ``,
@@ -161,11 +232,95 @@ function buildPlanSummaryForElicitation(plan) {
161
232
  lines.push("Confirm to proceed with node creation, or cancel to abort.");
162
233
  return lines.join("\n");
163
234
  }
235
+ /**
236
+ * Generates a confirmation token for a pipeline plan to prevent bypass of user approval.
237
+ *
238
+ * The token is a SHA256 hash (truncated to 16 hex chars) of the canonicalized plan JSON.
239
+ * AI agents must provide this token when calling pipeline creation tools with `confirmed=true`,
240
+ * proving they received and can reference the exact plan that should have been presented to the user.
241
+ *
242
+ * **Important limitations:**
243
+ * - The token proves the agent received the correct plan (plan integrity)
244
+ * - It does NOT verify the agent presented the plan accurately to the user
245
+ * - An agent could theoretically show incomplete/misleading info but still provide the valid token
246
+ * - This is an acceptable tradeoff: the token prevents accidental bypass and honest mistakes,
247
+ * while deliberate deception by a malicious agent is out of scope
248
+ *
249
+ * @param plan - The pipeline plan object to fingerprint
250
+ * @returns A 16-character hex token uniquely identifying this plan's content
251
+ */
252
+ export function buildPlanConfirmationToken(plan) {
253
+ return createHash("sha256")
254
+ .update(JSON.stringify(sortJsonValue(plan)))
255
+ .digest("hex")
256
+ .slice(0, 16);
257
+ }
258
+ async function requirePipelineCreationApproval(server, toolName, plan, confirmed, confirmationToken, payload = {}) {
259
+ if (confirmed === true) {
260
+ // Verify the agent has the exact plan by comparing confirmation tokens.
261
+ // This prevents bypass where an agent sets confirmed=true without actually
262
+ // presenting the plan, but doesn't guarantee the agent presented it accurately.
263
+ const expected = buildPlanConfirmationToken(plan);
264
+ if (confirmationToken !== expected) {
265
+ return buildJsonToolResponse(toolName, {
266
+ created: false,
267
+ STOP_AND_CONFIRM: `STOP. The confirmationToken is missing or does not match the current plan. ` +
268
+ `Present the pipeline plan to the user in a table showing each node name and nodeType. ` +
269
+ `Ask for explicit approval BEFORE creating any nodes. Once the user approves, call ${toolName} again with confirmed=true and the confirmationToken from this response.`,
270
+ confirmationToken: expected,
271
+ ...payload,
272
+ });
273
+ }
274
+ return null;
275
+ }
276
+ const clientCapabilities = server.server.getClientCapabilities();
277
+ if (!clientCapabilities?.elicitation?.form) {
278
+ // Client does not support form elicitation — fall back to STOP_AND_CONFIRM convention
279
+ const token = buildPlanConfirmationToken(plan);
280
+ return buildJsonToolResponse(toolName, {
281
+ created: false,
282
+ confirmationToken: token,
283
+ STOP_AND_CONFIRM: `STOP. Present the pipeline plan to the user in a table showing each node name and nodeType. ` +
284
+ `Ask for explicit approval BEFORE creating any nodes. Once the user approves, call ${toolName} again with confirmed=true and confirmationToken="${token}".`,
285
+ ...payload,
286
+ });
287
+ }
288
+ const planSummary = buildPlanSummaryForElicitation(plan);
289
+ const elicitation = await server.server.elicitInput({
290
+ message: planSummary,
291
+ requestedSchema: {
292
+ type: "object",
293
+ properties: {
294
+ confirmed: {
295
+ type: "boolean",
296
+ title: "Create these pipeline nodes?",
297
+ description: "Select true to proceed with node creation, false to cancel.",
298
+ },
299
+ },
300
+ required: ["confirmed"],
301
+ },
302
+ });
303
+ if (elicitation.action !== "accept" || elicitation.content?.confirmed !== true) {
304
+ const ACTION_LABELS = {
305
+ decline: "declined",
306
+ cancel: "cancelled",
307
+ };
308
+ return buildJsonToolResponse(toolName, {
309
+ created: false,
310
+ cancelled: true,
311
+ reason: elicitation.action === "accept"
312
+ ? "User declined pipeline creation."
313
+ : `Pipeline creation ${ACTION_LABELS[elicitation.action] ?? elicitation.action} by user.`,
314
+ ...payload,
315
+ });
316
+ }
317
+ return null;
318
+ }
164
319
  export function registerPipelineTools(server, client) {
165
320
  server.tool("plan-pipeline", "Plan a Coalesce pipeline by discovering and ranking all available node types from the repo. ALWAYS call this before creating nodes to get the correct node type.\n\nThe planner scans the repo for all committed node type definitions, scores them against your use case, and returns ranked candidates. When available, it also returns a cached `planSummaryUri` MCP resource for the ranked node type summary so you can reuse that guidance throughout the pipeline without calling the planner again.\n\nIMPORTANT — DO NOT WRITE SQL: The `sql` parameter is ONLY for converting SQL that the USER provided (pasted or typed). If you are building a pipeline yourself, provide `goal` + `sourceNodeIDs` instead.\n\nPREREQUISITE: Before calling this tool, use list-workspace-nodes to discover available source/upstream nodes and their IDs in the workspace.\n\nPreferred approach: Provide `goal` AND `sourceNodeIDs`. The planner selects the best node type and scaffolds the pipeline. Without sourceNodeIDs, the planner returns clarification questions.\n\nUser-provided SQL: When a user pastes SQL, pass it in `sql`. The planner parses refs and column projections.\n\nConsult coalesce://context/node-type-corpus for node type patterns and metadata structures.", {
166
321
  workspaceID: z.string().describe("The workspace ID"),
167
322
  goal: z.string().optional().describe("Optional natural-language pipeline goal"),
168
- sql: z.string().optional().describe("The user's EXACT SQL, copied verbatim. Do NOT rewrite table names, do NOT add {{ ref() }} syntax, do NOT modify it. Pass it exactly as the user provided it. If you are building a pipeline yourself, do NOT write SQL — use goal + sourceNodeIDs instead."),
323
+ sql: z.string().optional().describe("The user's EXACT SQL, copied verbatim. It may use raw table names or existing Coalesce {{ ref() }} syntax. Do NOT rewrite between SQL styles or modify the query. If you are building a pipeline yourself, do NOT write SQL — use goal + sourceNodeIDs instead."),
169
324
  targetName: z.string().optional().describe("Optional target node name override"),
170
325
  targetNodeType: z
171
326
  .string()
@@ -188,20 +343,12 @@ export function registerPipelineTools(server, client) {
188
343
  .describe("Optional upstream node IDs when planning from a non-SQL goal."),
189
344
  }, READ_ONLY_ANNOTATIONS, async (params) => {
190
345
  try {
191
- // Reject SQL that the agent rewrote with {{ ref() }}
192
- if (params.sql && /\{\{\s*ref\s*\(/.test(params.sql)) {
193
- return handleToolError(new Error(REWRITTEN_SQL_ERROR_MESSAGE));
194
- }
195
346
  const result = await planPipeline(client, params);
196
- // Build fingerprint from workspace + repo + observed types
347
+ // Build fingerprint from the actual ranked node-type output used in the summary.
197
348
  const selection = isPlainObject(result.nodeTypeSelection) ? result.nodeTypeSelection : null;
198
- const workspaceNodeTypes = Array.isArray(selection?.workspaceObservedNodeTypes)
199
- ? selection.workspaceObservedNodeTypes
200
- : [];
201
- const repoPath = typeof selection?.resolvedRepoPath === "string"
202
- ? selection.resolvedRepoPath
203
- : null;
204
- const fingerprint = buildPlanFingerprint(params.workspaceID, repoPath, workspaceNodeTypes, {
349
+ const fingerprint = buildPlanFingerprint(params.workspaceID, selection, Array.isArray(result.supportedNodeTypes)
350
+ ? result.supportedNodeTypes.filter((value) => typeof value === "string")
351
+ : [], {
205
352
  goal: params.goal,
206
353
  sql: params.sql,
207
354
  sourceNodeIDs: params.sourceNodeIDs,
@@ -230,8 +377,8 @@ export function registerPipelineTools(server, client) {
230
377
  planSummaryUri: summaryPath,
231
378
  planCached: !!cached,
232
379
  instruction: cached
233
- ? `Cached node type rankings found at planSummaryUri (fingerprint unchanged). Reference this resource for all subsequent node creations — no need to call plan-pipeline again unless you install new packages or commit new node type definitions.`
234
- : `Node type rankings saved to planSummaryUri. Reference this resource for all subsequent node creations in this pipeline. The cache auto-invalidates when repo or workspace node types change.`,
380
+ ? `Cached node type rankings found at planSummaryUri (ranking fingerprint unchanged). Reference this resource for all subsequent node creations — no need to call plan-pipeline again unless repo-backed ranking inputs or workspace node types change enough to alter the planner's ranking.`
381
+ : `Node type rankings saved to planSummaryUri. Reference this resource for all subsequent node creations in this pipeline. The cache auto-invalidates when repo-backed ranking inputs or workspace node types change enough to alter the planner's ranking.`,
235
382
  }
236
383
  : {
237
384
  ...(selectedNodeType ? {
@@ -250,6 +397,14 @@ export function registerPipelineTools(server, client) {
250
397
  server.tool("create-pipeline-from-plan", "Create a Coalesce pipeline from a previously approved plan. Projection-capable node types execute by creating predecessor-based nodes first and then persisting the final full node body with set-workspace-node.", {
251
398
  workspaceID: z.string().describe("The workspace ID"),
252
399
  plan: PipelinePlanSchema.describe("The plan object returned by plan-pipeline."),
400
+ confirmed: z
401
+ .boolean()
402
+ .optional()
403
+ .describe("Set to true only after presenting the plan to the user and receiving explicit approval. Must be paired with the confirmationToken returned by the prior STOP_AND_CONFIRM response."),
404
+ confirmationToken: z
405
+ .string()
406
+ .optional()
407
+ .describe("The token returned in the STOP_AND_CONFIRM response. Required when confirmed=true to prove the plan was presented to the user."),
253
408
  dryRun: z
254
409
  .boolean()
255
410
  .optional()
@@ -257,42 +412,9 @@ export function registerPipelineTools(server, client) {
257
412
  }, WRITE_ANNOTATIONS, async (params) => {
258
413
  try {
259
414
  if (!params.dryRun) {
260
- const planSummary = buildPlanSummaryForElicitation(params.plan);
261
- try {
262
- const elicitation = await server.server.elicitInput({
263
- message: planSummary,
264
- requestedSchema: {
265
- type: "object",
266
- properties: {
267
- confirmed: {
268
- type: "boolean",
269
- title: "Create these pipeline nodes?",
270
- description: "Select true to proceed with node creation, false to cancel.",
271
- },
272
- },
273
- required: ["confirmed"],
274
- },
275
- });
276
- if (elicitation.action !== "accept" || elicitation.content?.confirmed !== true) {
277
- return buildJsonToolResponse("create-pipeline-from-plan", {
278
- created: false,
279
- cancelled: true,
280
- reason: elicitation.action === "accept"
281
- ? "User declined pipeline creation."
282
- : `Pipeline creation ${elicitation.action}d by user.`,
283
- });
284
- }
285
- }
286
- catch (elicitError) {
287
- // Client does not support elicitation — fall back to STOP_AND_CONFIRM convention
288
- if (elicitError instanceof Error && elicitError.message.includes("does not support")) {
289
- return buildJsonToolResponse("create-pipeline-from-plan", {
290
- created: false,
291
- STOP_AND_CONFIRM: "STOP. Present the pipeline plan to the user in a table showing each node name and nodeType. Ask for explicit approval BEFORE creating any nodes. Once the user approves, call create-pipeline-from-plan again.",
292
- plan: params.plan,
293
- });
294
- }
295
- throw elicitError;
415
+ const approvalResponse = await requirePipelineCreationApproval(server, "create-pipeline-from-plan", params.plan, params.confirmed, params.confirmationToken, { plan: params.plan });
416
+ if (approvalResponse) {
417
+ return approvalResponse;
296
418
  }
297
419
  }
298
420
  const result = await createPipelineFromPlan(client, params);
@@ -302,9 +424,9 @@ export function registerPipelineTools(server, client) {
302
424
  return handleToolError(error);
303
425
  }
304
426
  });
305
- server.tool("create-pipeline-from-sql", "Plan and create a Coalesce pipeline from user-provided SQL. Pass the user's EXACT SQL unchanged do NOT rewrite it, do NOT replace table references with {{ ref() }}, do NOT modify the SQL in any way. The planner handles source resolution automatically.\n\nIf you are building a pipeline yourself, use declarative tools directly: create-workspace-node-from-predecessor → convert-join-to-aggregation → replace-workspace-node-columns.\n\nThis tool validates candidate node types against currently observed workspace nodes. If a selected type is not observed, the plan will include a warning asking the user to confirm installation in Coalesce.\n\nConsult coalesce://context/node-type-corpus for node type patterns and metadata structures.", {
427
+ server.tool("create-pipeline-from-sql", "Plan and create a Coalesce pipeline from user-provided SQL. Pass the user's EXACT SQL unchanged. The SQL may use raw table names or already contain Coalesce {{ ref() }} syntax if that is what the user provided. Do NOT rewrite between styles or otherwise modify the query. The planner resolves workspace sources automatically and generates a Coalesce-compatible joinCondition for the final node.\n\nIf you are building a pipeline yourself, use declarative tools directly: create-workspace-node-from-predecessor → convert-join-to-aggregation → replace-workspace-node-columns.\n\nThis tool validates candidate node types against currently observed workspace nodes. If a selected type is not observed, the plan will include a warning asking the user to confirm installation in Coalesce.\n\nConsult coalesce://context/node-type-corpus for node type patterns and metadata structures.", {
306
428
  workspaceID: z.string().describe("The workspace ID"),
307
- sql: z.string().describe("The user's EXACT SQL, copied verbatim. Do NOT rewrite table names, do NOT add {{ ref() }} syntax, do NOT modify it in any way. Pass it exactly as the user provided it."),
429
+ sql: z.string().describe("The user's EXACT SQL, copied verbatim. It may use raw table names or existing Coalesce {{ ref() }} syntax. Do NOT rewrite between SQL styles or modify it in any way. Pass it exactly as the user provided it."),
308
430
  goal: z.string().optional().describe("Optional business goal or context for the SQL"),
309
431
  targetName: z.string().optional().describe("Optional target node name override"),
310
432
  targetNodeType: z
@@ -322,17 +444,45 @@ export function registerPipelineTools(server, client) {
322
444
  .string()
323
445
  .optional()
324
446
  .describe("Optional local committed Coalesce repo path for repo-first node-type ranking. Falls back to COALESCE_REPO_PATH when omitted."),
447
+ confirmed: z
448
+ .boolean()
449
+ .optional()
450
+ .describe("Set to true only after presenting the ready plan to the user and receiving explicit approval. Must be paired with the confirmationToken returned by the prior STOP_AND_CONFIRM response."),
451
+ confirmationToken: z
452
+ .string()
453
+ .optional()
454
+ .describe("The token returned in the STOP_AND_CONFIRM response. Required when confirmed=true to prove the plan was presented to the user."),
325
455
  dryRun: z
326
456
  .boolean()
327
457
  .optional()
328
458
  .describe("When true, return the generated plan without creating nodes."),
329
459
  }, WRITE_ANNOTATIONS, async (params) => {
330
460
  try {
331
- // Reject SQL that the agent rewrote with {{ ref() }} — the user's original SQL won't contain these
332
- if (/\{\{\s*ref\s*\(/.test(params.sql)) {
333
- return handleToolError(new Error(REWRITTEN_SQL_ERROR_MESSAGE));
461
+ const plan = await planPipeline(client, params);
462
+ if (params.dryRun || plan.status !== "ready") {
463
+ return buildJsonToolResponse("create-pipeline-from-sql", {
464
+ created: false,
465
+ ...(params.dryRun ? { dryRun: true } : {}),
466
+ plan,
467
+ ...(plan.status !== "ready"
468
+ ? {
469
+ warning: "SQL was planned but still needs clarification before creation. Review openQuestions and warnings. Present the plan to the user and wait for approval.",
470
+ }
471
+ : {}),
472
+ });
334
473
  }
335
- const result = await createPipelineFromSql(client, params);
474
+ const approvalResponse = await requirePipelineCreationApproval(server, "create-pipeline-from-sql", plan, params.confirmed, params.confirmationToken, { plan });
475
+ if (approvalResponse) {
476
+ return approvalResponse;
477
+ }
478
+ const execution = await createPipelineFromPlan(client, {
479
+ workspaceID: params.workspaceID,
480
+ plan,
481
+ });
482
+ const result = {
483
+ plan,
484
+ ...(isPlainObject(execution) ? execution : { execution }),
485
+ };
336
486
  return buildJsonToolResponse("create-pipeline-from-sql", result);
337
487
  }
338
488
  catch (error) {
@@ -172,9 +172,23 @@ function listCacheFilePaths(directory) {
172
172
  }
173
173
  return filePaths.sort();
174
174
  }
175
+ function isCompleteSnapshotArtifact(filePath) {
176
+ if (filePath.includes(".tmp-")) {
177
+ return false;
178
+ }
179
+ if (filePath.endsWith(".ndjson")) {
180
+ return existsSync(filePath.replace(/\.ndjson$/, ".meta.json"));
181
+ }
182
+ if (filePath.endsWith(".meta.json")) {
183
+ return existsSync(filePath.replace(/\.meta\.json$/, ".ndjson"));
184
+ }
185
+ return true;
186
+ }
175
187
  function listCacheResources(baseDir) {
176
188
  const cacheDir = getCacheDir(baseDir);
177
- return listCacheFilePaths(cacheDir).flatMap((filePath) => {
189
+ return listCacheFilePaths(cacheDir)
190
+ .filter(isCompleteSnapshotArtifact)
191
+ .flatMap((filePath) => {
178
192
  const uri = buildCacheResourceUri(filePath, baseDir);
179
193
  if (!uri) {
180
194
  return [];
@@ -233,7 +247,7 @@ export function registerResources(server) {
233
247
  description: "Dynamic resources for cached tool responses, cache snapshots, and pipeline summaries.",
234
248
  }, async (resourceUri) => {
235
249
  const resolved = resolveCacheResourceUri(resourceUri.toString());
236
- if (!resolved) {
250
+ if (!resolved || !isCompleteSnapshotArtifact(resolved.filePath)) {
237
251
  throw new Error(`Unknown cache resource: ${resourceUri.toString()}`);
238
252
  }
239
253
  try {
@@ -1,4 +1,5 @@
1
- import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { appendFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { listEnvironmentNodes, listWorkspaceNodes } from "../../coalesce/api/nodes.js";
4
5
  import { listRuns } from "../../coalesce/api/runs.js";
@@ -62,50 +63,63 @@ export async function streamAllPaginatedToDisk(fetchPage, baseParams, params, op
62
63
  const orderBy = params.orderBy ?? "id";
63
64
  const orderByDirection = params.orderByDirection;
64
65
  const cachedAt = new Date().toISOString();
65
- // Ensure parent directory exists
66
+ const tempSuffix = `.tmp-${process.pid}-${randomUUID()}`;
67
+ const tempNdjsonPath = `${ndjsonPath}${tempSuffix}`;
68
+ const tempMetaPath = `${metaPath}${tempSuffix}`;
69
+ // Ensure parent directories exist
66
70
  mkdirSync(dirname(ndjsonPath), { recursive: true });
67
- // Write empty file to start (truncates any previous file)
68
- writeFileSync(ndjsonPath, "", "utf8");
69
- let totalItems = 0;
70
- let next;
71
- let isFirstPage = true;
72
- let pageCount = 0;
73
- while (isFirstPage || next) {
74
- const response = await fetchPage({
75
- ...baseParams,
76
- limit: pageSize,
77
- orderBy,
78
- ...(orderByDirection ? { orderByDirection } : {}),
79
- ...(next ? { startingFrom: next } : {}),
80
- });
81
- const page = parseCollectionPage(response);
82
- pageCount += 1;
83
- // Write each item as a single NDJSON line
84
- for (const item of page.data) {
85
- const transformed = itemTransform ? itemTransform(item) : item;
86
- appendFileSync(ndjsonPath, JSON.stringify(transformed) + "\n", "utf8");
87
- totalItems += 1;
88
- }
89
- if (page.next) {
90
- if (seenCursors.has(page.next)) {
91
- throw new Error(`Pagination repeated cursor ${page.next}`);
71
+ mkdirSync(dirname(metaPath), { recursive: true });
72
+ // Start with isolated temp files so failed streams never leave partial snapshots behind.
73
+ writeFileSync(tempNdjsonPath, "", "utf8");
74
+ try {
75
+ let totalItems = 0;
76
+ let next;
77
+ let isFirstPage = true;
78
+ let pageCount = 0;
79
+ while (isFirstPage || next) {
80
+ const response = await fetchPage({
81
+ ...baseParams,
82
+ limit: pageSize,
83
+ orderBy,
84
+ ...(orderByDirection ? { orderByDirection } : {}),
85
+ ...(next ? { startingFrom: next } : {}),
86
+ });
87
+ const page = parseCollectionPage(response);
88
+ pageCount += 1;
89
+ // Write each item as a single NDJSON line
90
+ for (const item of page.data) {
91
+ const transformed = itemTransform ? itemTransform(item) : item;
92
+ appendFileSync(tempNdjsonPath, JSON.stringify(transformed) + "\n", "utf8");
93
+ totalItems += 1;
92
94
  }
93
- seenCursors.add(page.next);
95
+ if (page.next) {
96
+ if (seenCursors.has(page.next)) {
97
+ throw new Error(`Pagination repeated cursor ${page.next}`);
98
+ }
99
+ seenCursors.add(page.next);
100
+ }
101
+ next = page.next;
102
+ isFirstPage = false;
94
103
  }
95
- next = page.next;
96
- isFirstPage = false;
104
+ // Write meta file only on successful completion, then promote both files into place.
105
+ const meta = {
106
+ totalItems,
107
+ pageCount,
108
+ pageSize,
109
+ orderBy,
110
+ ...(orderByDirection ? { orderByDirection } : {}),
111
+ cachedAt,
112
+ };
113
+ writeFileSync(tempMetaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
114
+ renameSync(tempNdjsonPath, ndjsonPath);
115
+ renameSync(tempMetaPath, metaPath);
116
+ return meta;
117
+ }
118
+ catch (error) {
119
+ rmSync(tempNdjsonPath, { force: true });
120
+ rmSync(tempMetaPath, { force: true });
121
+ throw error;
97
122
  }
98
- // Write meta file only on successful completion
99
- const meta = {
100
- totalItems,
101
- pageCount,
102
- pageSize,
103
- orderBy,
104
- ...(orderByDirection ? { orderByDirection } : {}),
105
- cachedAt,
106
- };
107
- writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
108
- return meta;
109
123
  }
110
124
  function ensureDirectory(...parts) {
111
125
  const directory = join(...parts);
@@ -17,4 +17,5 @@ export declare function createPipelineFromSql(client: CoalesceClient, params: {
17
17
  schema?: string;
18
18
  repoPath?: string;
19
19
  dryRun?: boolean;
20
+ confirmed?: boolean;
20
21
  }): Promise<unknown>;