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 +8 -4
- package/dist/mcp/pipelines.d.ts +18 -0
- package/dist/mcp/pipelines.js +225 -75
- package/dist/resources/index.js +16 -2
- package/dist/services/cache/snapshots.js +55 -41
- package/dist/services/pipelines/execution.d.ts +1 -0
- package/dist/services/pipelines/execution.js +29 -13
- package/dist/services/pipelines/planning.d.ts +8 -0
- package/dist/services/pipelines/planning.js +306 -120
- package/dist/services/workspace/mutations.js +14 -12
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +15 -0
- package/package.json +1 -1
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
|
|
73
|
-
| `COALESCE_ORG_ID` | Fallback org ID for
|
|
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
|
|
package/dist/mcp/pipelines.d.ts
CHANGED
|
@@ -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;
|
package/dist/mcp/pipelines.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
73
|
-
`node types change
|
|
74
|
-
`
|
|
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.
|
|
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
|
|
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
|
|
199
|
-
?
|
|
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
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
332
|
-
if (
|
|
333
|
-
return
|
|
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
|
|
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) {
|
package/dist/resources/index.js
CHANGED
|
@@ -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)
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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);
|