adf-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -0
- package/LICENSE +21 -0
- package/README.md +276 -0
- package/index.js +1083 -0
- package/package.json +57 -0
package/index.js
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
AzureCliCredential,
|
|
8
|
+
ClientSecretCredential,
|
|
9
|
+
DefaultAzureCredential,
|
|
10
|
+
DeviceCodeCredential,
|
|
11
|
+
InteractiveBrowserCredential,
|
|
12
|
+
ManagedIdentityCredential,
|
|
13
|
+
} from "@azure/identity";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
// Single source of truth for the server's version — keeps `package.json`
|
|
17
|
+
// and the MCP server identity in sync without manual edits.
|
|
18
|
+
const PACKAGE = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
|
|
19
|
+
const VERSION = PACKAGE.version;
|
|
20
|
+
|
|
21
|
+
const FACTORY_ID = process.env.ADF_FACTORY_RESOURCE_ID;
|
|
22
|
+
if (!FACTORY_ID) {
|
|
23
|
+
console.error("Missing required env var: ADF_FACTORY_RESOURCE_ID");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SUBSCRIPTION_MATCH = /^\/subscriptions\/([^/]+)/.exec(FACTORY_ID);
|
|
28
|
+
if (!SUBSCRIPTION_MATCH) {
|
|
29
|
+
console.error(
|
|
30
|
+
`ADF_FACTORY_RESOURCE_ID does not look like a valid ARM ID (missing /subscriptions/<id>): ${FACTORY_ID}`,
|
|
31
|
+
);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const SUBSCRIPTION_ID = SUBSCRIPTION_MATCH[1];
|
|
35
|
+
|
|
36
|
+
const AUTH_MODES = [
|
|
37
|
+
"interactive",
|
|
38
|
+
"device-code",
|
|
39
|
+
"cli",
|
|
40
|
+
"service-principal",
|
|
41
|
+
"managed-identity",
|
|
42
|
+
"default",
|
|
43
|
+
];
|
|
44
|
+
// Public Azure CLI client ID — safe default for user-flow modes only.
|
|
45
|
+
const AZURE_CLI_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46";
|
|
46
|
+
|
|
47
|
+
function requireEnv(value, name, mode) {
|
|
48
|
+
if (!value) {
|
|
49
|
+
console.error(`ADF_AUTH_MODE=${mode} requires ${name}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildCredential() {
|
|
56
|
+
const mode = (process.env.ADF_AUTH_MODE || "interactive").toLowerCase();
|
|
57
|
+
if (!AUTH_MODES.includes(mode)) {
|
|
58
|
+
console.error(`Invalid ADF_AUTH_MODE "${mode}". Valid: ${AUTH_MODES.join(", ")}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const tenantId = process.env.AZURE_TENANT_ID;
|
|
62
|
+
const clientId = process.env.AZURE_CLIENT_ID;
|
|
63
|
+
const clientSecret = process.env.AZURE_CLIENT_SECRET;
|
|
64
|
+
|
|
65
|
+
switch (mode) {
|
|
66
|
+
case "interactive":
|
|
67
|
+
return new InteractiveBrowserCredential({
|
|
68
|
+
tenantId: requireEnv(tenantId, "AZURE_TENANT_ID", mode),
|
|
69
|
+
clientId: clientId || AZURE_CLI_CLIENT_ID,
|
|
70
|
+
});
|
|
71
|
+
case "device-code":
|
|
72
|
+
return new DeviceCodeCredential({
|
|
73
|
+
tenantId: requireEnv(tenantId, "AZURE_TENANT_ID", mode),
|
|
74
|
+
clientId: clientId || AZURE_CLI_CLIENT_ID,
|
|
75
|
+
// Default callback writes to stdout, which would corrupt the MCP
|
|
76
|
+
// protocol stream. Redirect to stderr so the MCP client logs it.
|
|
77
|
+
userPromptCallback: (info) => {
|
|
78
|
+
console.error(`[adf-mcp] ${info.message}`);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
case "cli":
|
|
82
|
+
return new AzureCliCredential(tenantId ? { tenantId } : undefined);
|
|
83
|
+
case "service-principal":
|
|
84
|
+
return new ClientSecretCredential(
|
|
85
|
+
requireEnv(tenantId, "AZURE_TENANT_ID", mode),
|
|
86
|
+
requireEnv(clientId, "AZURE_CLIENT_ID", mode),
|
|
87
|
+
requireEnv(clientSecret, "AZURE_CLIENT_SECRET", mode),
|
|
88
|
+
);
|
|
89
|
+
case "managed-identity":
|
|
90
|
+
return new ManagedIdentityCredential(clientId ? { clientId } : undefined);
|
|
91
|
+
case "default":
|
|
92
|
+
return new DefaultAzureCredential(tenantId ? { tenantId } : undefined);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const credential = buildCredential();
|
|
97
|
+
const API_VERSION = "2018-06-01";
|
|
98
|
+
const ARM_BASE = "https://management.azure.com";
|
|
99
|
+
const MAX_RETRIES = 3;
|
|
100
|
+
const RETRY_MAX_DELAY_MS = 60_000;
|
|
101
|
+
|
|
102
|
+
async function getToken() {
|
|
103
|
+
const t = await credential.getToken("https://management.azure.com/.default");
|
|
104
|
+
return t.token;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
108
|
+
|
|
109
|
+
// Calls an arbitrary ARM path (already including /subscriptions/...).
|
|
110
|
+
// `extraQuery` adds query parameters beyond api-version (rerun / cancel).
|
|
111
|
+
// `extraHeaders` adds request headers (used for If-Match ETag concurrency
|
|
112
|
+
// control on mutating ops). Retries on HTTP 429, honoring Retry-After.
|
|
113
|
+
async function armAt(method, fullArmPath, body, extraQuery = {}, extraHeaders = {}) {
|
|
114
|
+
const token = await getToken();
|
|
115
|
+
const url = new URL(`${ARM_BASE}${fullArmPath}`);
|
|
116
|
+
url.searchParams.set("api-version", API_VERSION);
|
|
117
|
+
for (const [k, v] of Object.entries(extraQuery)) {
|
|
118
|
+
if (v != null) url.searchParams.set(k, String(v));
|
|
119
|
+
}
|
|
120
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
121
|
+
const res = await fetch(url, {
|
|
122
|
+
method,
|
|
123
|
+
headers: {
|
|
124
|
+
Authorization: `Bearer ${token}`,
|
|
125
|
+
"Content-Type": "application/json",
|
|
126
|
+
...extraHeaders,
|
|
127
|
+
},
|
|
128
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
129
|
+
});
|
|
130
|
+
if (res.status === 429 && attempt < MAX_RETRIES) {
|
|
131
|
+
const retryAfter = parseInt(res.headers.get("retry-after") ?? "", 10);
|
|
132
|
+
const backoffMs = Number.isFinite(retryAfter)
|
|
133
|
+
? Math.min(retryAfter * 1000, RETRY_MAX_DELAY_MS)
|
|
134
|
+
: Math.min(2 ** attempt * 500, RETRY_MAX_DELAY_MS);
|
|
135
|
+
console.error(
|
|
136
|
+
`[adf-mcp] ARM 429 throttled on ${method} ${fullArmPath}; retrying in ${backoffMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`,
|
|
137
|
+
);
|
|
138
|
+
await sleep(backoffMs);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const text = await res.text();
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const err = new Error(`ADF ${method} ${fullArmPath} -> ${res.status}: ${text}`);
|
|
144
|
+
err.status = res.status;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
return text ? JSON.parse(text) : null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Factory-scoped convenience wrapper. `path` is relative to the factory ARM ID.
|
|
152
|
+
async function arm(method, path, body, extraQuery, extraHeaders) {
|
|
153
|
+
return armAt(method, `${FACTORY_ID}${path}`, body, extraQuery, extraHeaders);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// GET a factory resource and return null on 404 instead of throwing.
|
|
157
|
+
// Used by mutating tools' plan step to detect create vs update.
|
|
158
|
+
async function fetchExistingOrNull(path) {
|
|
159
|
+
try {
|
|
160
|
+
return await arm("GET", path);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (e?.status === 404) return null;
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ok(obj) {
|
|
168
|
+
return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Wraps a tool handler so any thrown error is returned as a structured
|
|
172
|
+
// MCP tool error instead of escaping as a protocol-level failure. This lets
|
|
173
|
+
// the LLM see the error message and react to it.
|
|
174
|
+
function safeTool(handler) {
|
|
175
|
+
return async (args) => {
|
|
176
|
+
try {
|
|
177
|
+
return await handler(args);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
const message = e?.message ?? String(e);
|
|
180
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const TRUNCATE_CHARS = 4096;
|
|
186
|
+
|
|
187
|
+
// Truncate a value when its JSON representation exceeds `max` chars, returning
|
|
188
|
+
// a small envelope describing the truncation. Used on activity input/output
|
|
189
|
+
// blobs that can otherwise blow the LLM context window.
|
|
190
|
+
function maybeTruncate(value, max = TRUNCATE_CHARS) {
|
|
191
|
+
if (value == null) return value;
|
|
192
|
+
const json = JSON.stringify(value);
|
|
193
|
+
if (json == null || json.length <= max) return value;
|
|
194
|
+
return {
|
|
195
|
+
_truncated: true,
|
|
196
|
+
_totalChars: json.length,
|
|
197
|
+
_preview: json.slice(0, max),
|
|
198
|
+
_hint: "Pass full=true to query_activity_runs to receive the untruncated payload.",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Extracts a human-meaningful subject from an Entra access token's middle
|
|
203
|
+
// segment. Returns "unknown" on any parse failure — never throws, since this
|
|
204
|
+
// is only used for audit logging.
|
|
205
|
+
function parseTokenSubject(token) {
|
|
206
|
+
try {
|
|
207
|
+
const parts = token.split(".");
|
|
208
|
+
if (parts.length !== 3) return "unknown";
|
|
209
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
210
|
+
return (
|
|
211
|
+
payload.upn ||
|
|
212
|
+
payload.preferred_username ||
|
|
213
|
+
payload.unique_name ||
|
|
214
|
+
payload.appid ||
|
|
215
|
+
payload.oid ||
|
|
216
|
+
"unknown"
|
|
217
|
+
);
|
|
218
|
+
} catch {
|
|
219
|
+
return "unknown";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Wraps a mutating tool so each invocation is audit-logged to stderr with
|
|
224
|
+
// timestamp, tool name, target resource, caller identity, and outcome.
|
|
225
|
+
// Layered on top of safeTool — errors are still structured for the LLM.
|
|
226
|
+
function writeTool(toolName, getTarget, handler) {
|
|
227
|
+
return safeTool(async (args) => {
|
|
228
|
+
const target = getTarget(args);
|
|
229
|
+
const token = await getToken();
|
|
230
|
+
const caller = parseTokenSubject(token);
|
|
231
|
+
const startedAt = new Date().toISOString();
|
|
232
|
+
console.error(
|
|
233
|
+
`[adf-mcp][AUDIT] ${startedAt} tool=${toolName} target=${target} caller=${caller} status=ATTEMPT`,
|
|
234
|
+
);
|
|
235
|
+
try {
|
|
236
|
+
const result = await handler(args);
|
|
237
|
+
console.error(
|
|
238
|
+
`[adf-mcp][AUDIT] ${new Date().toISOString()} tool=${toolName} target=${target} caller=${caller} status=SUCCESS`,
|
|
239
|
+
);
|
|
240
|
+
return result;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
const msg = (e?.message ?? String(e)).slice(0, 200);
|
|
243
|
+
console.error(
|
|
244
|
+
`[adf-mcp][AUDIT] ${new Date().toISOString()} tool=${toolName} target=${target} caller=${caller} status=FAILURE error=${msg}`,
|
|
245
|
+
);
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const WRITE_ENABLED = (process.env.ADF_MCP_MODE ?? "read").toLowerCase() === "write";
|
|
252
|
+
const DESTRUCTIVE_REQUESTED = process.env.ADF_MCP_ALLOW_DELETE === "true";
|
|
253
|
+
const DESTRUCTIVE_ENABLED = WRITE_ENABLED && DESTRUCTIVE_REQUESTED;
|
|
254
|
+
if (WRITE_ENABLED) {
|
|
255
|
+
console.error("[adf-mcp] write mode enabled — pipeline run + trigger control tools are exposed");
|
|
256
|
+
}
|
|
257
|
+
if (DESTRUCTIVE_ENABLED) {
|
|
258
|
+
console.error(
|
|
259
|
+
"[adf-mcp] destructive mode enabled — create_or_update_* and delete_* tools are exposed (plan/apply confirmation required)",
|
|
260
|
+
);
|
|
261
|
+
} else if (DESTRUCTIVE_REQUESTED && !WRITE_ENABLED) {
|
|
262
|
+
console.error(
|
|
263
|
+
"[adf-mcp] WARNING: ADF_MCP_ALLOW_DELETE=true ignored because ADF_MCP_MODE is not 'write'.",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── Plan/apply token store for destructive mutations ───────────────────────
|
|
268
|
+
// Tokens bind a specific (tool, target, payload) to a confirmation call.
|
|
269
|
+
// The plan step returns a token; the apply step (dry_run=false) must echo it
|
|
270
|
+
// back. Tokens expire after PLAN_TTL_MS. Captured ETag enforces optimistic
|
|
271
|
+
// concurrency on the apply via If-Match.
|
|
272
|
+
const PLAN_TTL_MS = 10 * 60 * 1000;
|
|
273
|
+
const PLAN_STORE_MAX = 100;
|
|
274
|
+
const pendingPlans = new Map();
|
|
275
|
+
|
|
276
|
+
function hashPayload(payload) {
|
|
277
|
+
return JSON.stringify(payload ?? null);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function createPlanToken(toolName, target, payload, etag) {
|
|
281
|
+
// Bound the store: evict the oldest entry (Map preserves insertion order)
|
|
282
|
+
// when at capacity. Prevents a buggy client from exhausting memory before
|
|
283
|
+
// the periodic sweep runs.
|
|
284
|
+
if (pendingPlans.size >= PLAN_STORE_MAX) {
|
|
285
|
+
const oldest = pendingPlans.keys().next().value;
|
|
286
|
+
if (oldest !== undefined) pendingPlans.delete(oldest);
|
|
287
|
+
}
|
|
288
|
+
const token = crypto.randomUUID();
|
|
289
|
+
const expiresAt = Date.now() + PLAN_TTL_MS;
|
|
290
|
+
pendingPlans.set(token, {
|
|
291
|
+
toolName,
|
|
292
|
+
target,
|
|
293
|
+
payloadHash: hashPayload(payload),
|
|
294
|
+
etag,
|
|
295
|
+
expiresAt,
|
|
296
|
+
});
|
|
297
|
+
return { token, expiresAt: new Date(expiresAt).toISOString() };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function consumePlanToken(token, toolName, target, payload) {
|
|
301
|
+
const entry = pendingPlans.get(token);
|
|
302
|
+
if (!entry) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Invalid confirm_token. Tokens expire after ${PLAN_TTL_MS / 60_000}m; request a new plan with dry_run=true.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (Date.now() > entry.expiresAt) {
|
|
308
|
+
pendingPlans.delete(token);
|
|
309
|
+
throw new Error(`confirm_token expired. Request a new plan with dry_run=true.`);
|
|
310
|
+
}
|
|
311
|
+
if (
|
|
312
|
+
entry.toolName !== toolName ||
|
|
313
|
+
entry.target !== target ||
|
|
314
|
+
entry.payloadHash !== hashPayload(payload)
|
|
315
|
+
) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`confirm_token does not match the current call. If the payload changed since the plan, request a new plan.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
pendingPlans.delete(token);
|
|
321
|
+
return entry;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Sweep expired plans every minute. .unref() so the timer doesn't keep the
|
|
325
|
+
// Node event loop alive at shutdown.
|
|
326
|
+
setInterval(() => {
|
|
327
|
+
const now = Date.now();
|
|
328
|
+
for (const [token, entry] of pendingPlans) {
|
|
329
|
+
if (entry.expiresAt < now) pendingPlans.delete(token);
|
|
330
|
+
}
|
|
331
|
+
}, 60_000).unref();
|
|
332
|
+
|
|
333
|
+
// Shared plan/apply executor used by every destructive tool. Splits the call
|
|
334
|
+
// into "compute plan + issue token" (dry_run, default) vs. "consume token +
|
|
335
|
+
// apply with If-Match" (dry_run=false).
|
|
336
|
+
async function executePlanApply({
|
|
337
|
+
toolName,
|
|
338
|
+
action,
|
|
339
|
+
target,
|
|
340
|
+
payload,
|
|
341
|
+
dry_run,
|
|
342
|
+
confirm_token,
|
|
343
|
+
fetchBefore,
|
|
344
|
+
buildAfter,
|
|
345
|
+
apply,
|
|
346
|
+
}) {
|
|
347
|
+
const isDryRun = dry_run !== false;
|
|
348
|
+
if (isDryRun) {
|
|
349
|
+
const before = await fetchBefore();
|
|
350
|
+
const etag = before?.properties?.etag ?? before?.etag;
|
|
351
|
+
const { token, expiresAt } = createPlanToken(toolName, target, payload, etag);
|
|
352
|
+
return ok({
|
|
353
|
+
plan_type: "DRY_RUN",
|
|
354
|
+
action,
|
|
355
|
+
target,
|
|
356
|
+
before: before ?? null,
|
|
357
|
+
after: buildAfter(),
|
|
358
|
+
confirm_token: token,
|
|
359
|
+
expires_at: expiresAt,
|
|
360
|
+
hint: `To apply, call ${toolName} again with dry_run=false and confirm_token="${token}".`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (!confirm_token) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
"confirm_token is required when dry_run=false. Run with dry_run=true first to generate a plan.",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const entry = consumePlanToken(confirm_token, toolName, target, payload);
|
|
369
|
+
try {
|
|
370
|
+
const result = await apply(entry.etag);
|
|
371
|
+
return ok({ plan_type: "APPLIED", action, target, result });
|
|
372
|
+
} catch (e) {
|
|
373
|
+
if (e?.status === 412) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`Resource ${target} changed since the plan was computed (HTTP 412 Precondition Failed). Request a new plan with dry_run=true.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
throw e;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const server = new McpServer({ name: "adf-mcp", version: VERSION });
|
|
383
|
+
|
|
384
|
+
server.registerTool(
|
|
385
|
+
"list_pipelines",
|
|
386
|
+
{
|
|
387
|
+
description: "List all pipelines in the configured ADF factory.",
|
|
388
|
+
inputSchema: {},
|
|
389
|
+
},
|
|
390
|
+
safeTool(async () => {
|
|
391
|
+
const data = await arm("GET", "/pipelines");
|
|
392
|
+
const summary = (data.value ?? []).map((p) => ({
|
|
393
|
+
name: p.name,
|
|
394
|
+
activityCount: p.properties?.activities?.length ?? 0,
|
|
395
|
+
parameters: Object.keys(p.properties?.parameters ?? {}),
|
|
396
|
+
annotations: p.properties?.annotations ?? [],
|
|
397
|
+
folder: p.properties?.folder?.name,
|
|
398
|
+
}));
|
|
399
|
+
return ok(summary);
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
server.registerTool(
|
|
404
|
+
"get_pipeline",
|
|
405
|
+
{
|
|
406
|
+
description: "Get the full JSON definition of a specific pipeline.",
|
|
407
|
+
inputSchema: {
|
|
408
|
+
name: z.string().describe("The pipeline name"),
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
safeTool(async ({ name }) => {
|
|
412
|
+
const data = await arm("GET", `/pipelines/${encodeURIComponent(name)}`);
|
|
413
|
+
return ok(data);
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
server.registerTool(
|
|
418
|
+
"query_pipeline_runs",
|
|
419
|
+
{
|
|
420
|
+
description:
|
|
421
|
+
"Query pipeline runs in a time window. Default window is the last 24 hours, ordered most-recent first. Returns a continuationToken when more results are available; pass it back as continuation_token to fetch the next page.",
|
|
422
|
+
inputSchema: {
|
|
423
|
+
last_updated_after: z
|
|
424
|
+
.string()
|
|
425
|
+
.optional()
|
|
426
|
+
.describe("ISO 8601 timestamp; defaults to now - 24h"),
|
|
427
|
+
last_updated_before: z.string().optional().describe("ISO 8601 timestamp; defaults to now"),
|
|
428
|
+
pipeline_name: z.string().optional().describe("Filter to a specific pipeline name"),
|
|
429
|
+
status: z
|
|
430
|
+
.enum(["Succeeded", "Failed", "InProgress", "Cancelled", "Queued"])
|
|
431
|
+
.optional()
|
|
432
|
+
.describe("Filter by run status"),
|
|
433
|
+
continuation_token: z
|
|
434
|
+
.string()
|
|
435
|
+
.optional()
|
|
436
|
+
.describe("Continuation token from a previous response, for paging"),
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
safeTool(
|
|
440
|
+
async ({
|
|
441
|
+
last_updated_after,
|
|
442
|
+
last_updated_before,
|
|
443
|
+
pipeline_name,
|
|
444
|
+
status,
|
|
445
|
+
continuation_token,
|
|
446
|
+
}) => {
|
|
447
|
+
const now = new Date();
|
|
448
|
+
const before = last_updated_before ?? now.toISOString();
|
|
449
|
+
const after = last_updated_after ?? new Date(now.getTime() - 24 * 3600 * 1000).toISOString();
|
|
450
|
+
const filters = [];
|
|
451
|
+
if (pipeline_name)
|
|
452
|
+
filters.push({
|
|
453
|
+
operand: "PipelineName",
|
|
454
|
+
operator: "Equals",
|
|
455
|
+
values: [pipeline_name],
|
|
456
|
+
});
|
|
457
|
+
if (status) filters.push({ operand: "Status", operator: "Equals", values: [status] });
|
|
458
|
+
const body = {
|
|
459
|
+
lastUpdatedAfter: after,
|
|
460
|
+
lastUpdatedBefore: before,
|
|
461
|
+
orderBy: [{ orderBy: "RunStart", order: "DESC" }],
|
|
462
|
+
};
|
|
463
|
+
if (filters.length) body.filters = filters;
|
|
464
|
+
if (continuation_token) body.continuationToken = continuation_token;
|
|
465
|
+
const data = await arm("POST", "/queryPipelineRuns", body);
|
|
466
|
+
const runs = (data.value ?? []).map((r) => ({
|
|
467
|
+
runId: r.runId,
|
|
468
|
+
pipelineName: r.pipelineName,
|
|
469
|
+
status: r.status,
|
|
470
|
+
runStart: r.runStart,
|
|
471
|
+
runEnd: r.runEnd,
|
|
472
|
+
durationInMs: r.durationInMs,
|
|
473
|
+
message: r.message,
|
|
474
|
+
invokedBy: r.invokedBy?.name,
|
|
475
|
+
parameters: r.parameters,
|
|
476
|
+
}));
|
|
477
|
+
return ok({ runs, continuationToken: data.continuationToken ?? null });
|
|
478
|
+
},
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
server.registerTool(
|
|
483
|
+
"get_pipeline_run",
|
|
484
|
+
{
|
|
485
|
+
description:
|
|
486
|
+
"Get full details for a single pipeline run by ID. Use this when you already know the runId and want everything ARM returns about it.",
|
|
487
|
+
inputSchema: {
|
|
488
|
+
run_id: z.string().describe("The pipeline run ID"),
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
safeTool(async ({ run_id }) => {
|
|
492
|
+
const data = await arm("GET", `/pipelineruns/${encodeURIComponent(run_id)}`);
|
|
493
|
+
return ok(data);
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
server.registerTool(
|
|
498
|
+
"query_activity_runs",
|
|
499
|
+
{
|
|
500
|
+
description:
|
|
501
|
+
"Query activity runs for a specific pipeline run. Use this to drill into which activity failed and read the error message. Activity input/output payloads are truncated by default to protect context; pass full=true to receive them untruncated.",
|
|
502
|
+
inputSchema: {
|
|
503
|
+
pipeline_run_id: z.string().describe("The pipeline run ID (from query_pipeline_runs)"),
|
|
504
|
+
last_updated_after: z
|
|
505
|
+
.string()
|
|
506
|
+
.optional()
|
|
507
|
+
.describe("ISO 8601 timestamp; defaults to now - 7d"),
|
|
508
|
+
last_updated_before: z.string().optional().describe("ISO 8601 timestamp; defaults to now"),
|
|
509
|
+
status: z.string().optional().describe("Filter by activity status (e.g., Failed, Succeeded)"),
|
|
510
|
+
full: z
|
|
511
|
+
.boolean()
|
|
512
|
+
.optional()
|
|
513
|
+
.describe("Return untruncated input/output blobs. Defaults to false."),
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
safeTool(async ({ pipeline_run_id, last_updated_after, last_updated_before, status, full }) => {
|
|
517
|
+
const now = new Date();
|
|
518
|
+
const before = last_updated_before ?? now.toISOString();
|
|
519
|
+
const after =
|
|
520
|
+
last_updated_after ?? new Date(now.getTime() - 7 * 24 * 3600 * 1000).toISOString();
|
|
521
|
+
const filters = [];
|
|
522
|
+
if (status) filters.push({ operand: "Status", operator: "Equals", values: [status] });
|
|
523
|
+
const body = {
|
|
524
|
+
lastUpdatedAfter: after,
|
|
525
|
+
lastUpdatedBefore: before,
|
|
526
|
+
};
|
|
527
|
+
if (filters.length) body.filters = filters;
|
|
528
|
+
const data = await arm(
|
|
529
|
+
"POST",
|
|
530
|
+
`/pipelineruns/${encodeURIComponent(pipeline_run_id)}/queryActivityruns`,
|
|
531
|
+
body,
|
|
532
|
+
);
|
|
533
|
+
const summary = (data.value ?? []).map((a) => ({
|
|
534
|
+
activityName: a.activityName,
|
|
535
|
+
activityType: a.activityType,
|
|
536
|
+
status: a.status,
|
|
537
|
+
activityRunStart: a.activityRunStart,
|
|
538
|
+
activityRunEnd: a.activityRunEnd,
|
|
539
|
+
durationInMs: a.durationInMs,
|
|
540
|
+
error: a.error,
|
|
541
|
+
output: full ? a.output : maybeTruncate(a.output),
|
|
542
|
+
input: full ? a.input : maybeTruncate(a.input),
|
|
543
|
+
}));
|
|
544
|
+
return ok(summary);
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
server.registerTool(
|
|
549
|
+
"list_triggers",
|
|
550
|
+
{
|
|
551
|
+
description: "List all triggers in the factory and their runtime state.",
|
|
552
|
+
inputSchema: {},
|
|
553
|
+
},
|
|
554
|
+
safeTool(async () => {
|
|
555
|
+
const data = await arm("GET", "/triggers");
|
|
556
|
+
const summary = (data.value ?? []).map((t) => ({
|
|
557
|
+
name: t.name,
|
|
558
|
+
type: t.properties?.type,
|
|
559
|
+
runtimeState: t.properties?.runtimeState,
|
|
560
|
+
pipelines: (t.properties?.pipelines ?? []).map((p) => p.pipelineReference?.referenceName),
|
|
561
|
+
recurrence: t.properties?.typeProperties?.recurrence,
|
|
562
|
+
annotations: t.properties?.annotations ?? [],
|
|
563
|
+
}));
|
|
564
|
+
return ok(summary);
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
server.registerTool(
|
|
569
|
+
"list_linked_services",
|
|
570
|
+
{
|
|
571
|
+
description: "List all linked services in the factory (databases, storage accounts, etc).",
|
|
572
|
+
inputSchema: {},
|
|
573
|
+
},
|
|
574
|
+
safeTool(async () => {
|
|
575
|
+
const data = await arm("GET", "/linkedservices");
|
|
576
|
+
const summary = (data.value ?? []).map((ls) => ({
|
|
577
|
+
name: ls.name,
|
|
578
|
+
type: ls.properties?.type,
|
|
579
|
+
parameters: Object.keys(ls.properties?.parameters ?? {}),
|
|
580
|
+
annotations: ls.properties?.annotations ?? [],
|
|
581
|
+
connectVia: ls.properties?.connectVia?.referenceName,
|
|
582
|
+
}));
|
|
583
|
+
return ok(summary);
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
server.registerTool(
|
|
588
|
+
"list_datasets",
|
|
589
|
+
{
|
|
590
|
+
description: "List all datasets in the factory and the linked service each one belongs to.",
|
|
591
|
+
inputSchema: {},
|
|
592
|
+
},
|
|
593
|
+
safeTool(async () => {
|
|
594
|
+
const data = await arm("GET", "/datasets");
|
|
595
|
+
const summary = (data.value ?? []).map((d) => ({
|
|
596
|
+
name: d.name,
|
|
597
|
+
type: d.properties?.type,
|
|
598
|
+
linkedServiceName: d.properties?.linkedServiceName?.referenceName,
|
|
599
|
+
parameters: Object.keys(d.properties?.parameters ?? {}),
|
|
600
|
+
annotations: d.properties?.annotations ?? [],
|
|
601
|
+
folder: d.properties?.folder?.name,
|
|
602
|
+
}));
|
|
603
|
+
return ok(summary);
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
server.registerTool(
|
|
608
|
+
"list_integration_runtimes",
|
|
609
|
+
{
|
|
610
|
+
description:
|
|
611
|
+
"List all integration runtimes (IRs) in the factory and their type/state. Useful for spotting offline self-hosted IRs that cause pipeline failures.",
|
|
612
|
+
inputSchema: {},
|
|
613
|
+
},
|
|
614
|
+
safeTool(async () => {
|
|
615
|
+
const data = await arm("GET", "/integrationRuntimes");
|
|
616
|
+
const summary = (data.value ?? []).map((ir) => ({
|
|
617
|
+
name: ir.name,
|
|
618
|
+
type: ir.properties?.type,
|
|
619
|
+
description: ir.properties?.description,
|
|
620
|
+
state: ir.properties?.state,
|
|
621
|
+
}));
|
|
622
|
+
return ok(summary);
|
|
623
|
+
}),
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
server.registerTool(
|
|
627
|
+
"list_factories",
|
|
628
|
+
{
|
|
629
|
+
description:
|
|
630
|
+
"List all Data Factory v2 instances visible in the current subscription (derived from ADF_FACTORY_RESOURCE_ID). Useful for discovering the ARM IDs of other factories.",
|
|
631
|
+
inputSchema: {},
|
|
632
|
+
},
|
|
633
|
+
safeTool(async () => {
|
|
634
|
+
const data = await armAt(
|
|
635
|
+
"GET",
|
|
636
|
+
`/subscriptions/${SUBSCRIPTION_ID}/providers/Microsoft.DataFactory/factories`,
|
|
637
|
+
);
|
|
638
|
+
const summary = (data.value ?? []).map((f) => ({
|
|
639
|
+
name: f.name,
|
|
640
|
+
id: f.id,
|
|
641
|
+
location: f.location,
|
|
642
|
+
resourceGroup: /resourceGroups\/([^/]+)/.exec(f.id ?? "")?.[1],
|
|
643
|
+
}));
|
|
644
|
+
return ok(summary);
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
// ─── Write tools — registered only when ADF_MCP_MODE=write ─────────────────
|
|
649
|
+
|
|
650
|
+
if (WRITE_ENABLED) {
|
|
651
|
+
server.registerTool(
|
|
652
|
+
"create_pipeline_run",
|
|
653
|
+
{
|
|
654
|
+
description:
|
|
655
|
+
"Kick off a new run of a pipeline. Returns the new runId. WRITE OPERATION: this consumes ADF resources and may incur cost.",
|
|
656
|
+
inputSchema: {
|
|
657
|
+
pipeline_name: z.string().describe("The pipeline name"),
|
|
658
|
+
parameters: z
|
|
659
|
+
.record(z.unknown())
|
|
660
|
+
.optional()
|
|
661
|
+
.describe("Pipeline parameters, as a name→value object"),
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
writeTool(
|
|
665
|
+
"create_pipeline_run",
|
|
666
|
+
({ pipeline_name }) => `pipeline=${pipeline_name}`,
|
|
667
|
+
async ({ pipeline_name, parameters }) => {
|
|
668
|
+
const data = await arm(
|
|
669
|
+
"POST",
|
|
670
|
+
`/pipelines/${encodeURIComponent(pipeline_name)}/createRun`,
|
|
671
|
+
parameters && Object.keys(parameters).length ? parameters : undefined,
|
|
672
|
+
);
|
|
673
|
+
return ok({ runId: data?.runId, pipelineName: pipeline_name });
|
|
674
|
+
},
|
|
675
|
+
),
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
server.registerTool(
|
|
679
|
+
"cancel_pipeline_run",
|
|
680
|
+
{
|
|
681
|
+
description: "Cancel an in-progress pipeline run. By default cancels child runs as well.",
|
|
682
|
+
inputSchema: {
|
|
683
|
+
run_id: z.string().describe("The pipeline run ID to cancel"),
|
|
684
|
+
recursive: z
|
|
685
|
+
.boolean()
|
|
686
|
+
.optional()
|
|
687
|
+
.describe("Cancel child pipeline runs too. Defaults to true."),
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
writeTool(
|
|
691
|
+
"cancel_pipeline_run",
|
|
692
|
+
({ run_id }) => `run=${run_id}`,
|
|
693
|
+
async ({ run_id, recursive }) => {
|
|
694
|
+
await arm("POST", `/pipelineruns/${encodeURIComponent(run_id)}/cancel`, undefined, {
|
|
695
|
+
isRecursive: recursive ?? true,
|
|
696
|
+
});
|
|
697
|
+
return ok({ cancelled: true, runId: run_id });
|
|
698
|
+
},
|
|
699
|
+
),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
server.registerTool(
|
|
703
|
+
"rerun_pipeline_run",
|
|
704
|
+
{
|
|
705
|
+
description:
|
|
706
|
+
"Re-execute a previous pipeline run. By default resumes from the failed activity (the common 'fix and retry' workflow); set from_failed_activity=false to replay from the beginning.",
|
|
707
|
+
inputSchema: {
|
|
708
|
+
pipeline_name: z.string().describe("The original pipeline name"),
|
|
709
|
+
reference_run_id: z.string().describe("The runId of the previous run to rerun"),
|
|
710
|
+
from_failed_activity: z
|
|
711
|
+
.boolean()
|
|
712
|
+
.optional()
|
|
713
|
+
.describe("Resume from the failed activity. Defaults to true."),
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
writeTool(
|
|
717
|
+
"rerun_pipeline_run",
|
|
718
|
+
({ pipeline_name, reference_run_id }) => `pipeline=${pipeline_name} ref=${reference_run_id}`,
|
|
719
|
+
async ({ pipeline_name, reference_run_id, from_failed_activity }) => {
|
|
720
|
+
const data = await arm(
|
|
721
|
+
"POST",
|
|
722
|
+
`/pipelines/${encodeURIComponent(pipeline_name)}/createRun`,
|
|
723
|
+
undefined,
|
|
724
|
+
{
|
|
725
|
+
referencePipelineRunId: reference_run_id,
|
|
726
|
+
startFromFailure: from_failed_activity ?? true,
|
|
727
|
+
},
|
|
728
|
+
);
|
|
729
|
+
return ok({
|
|
730
|
+
runId: data?.runId,
|
|
731
|
+
pipelineName: pipeline_name,
|
|
732
|
+
referenceRunId: reference_run_id,
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
),
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
server.registerTool(
|
|
739
|
+
"start_trigger",
|
|
740
|
+
{
|
|
741
|
+
description:
|
|
742
|
+
"Start a trigger so it begins firing per its schedule. Long-running; verify the resulting runtimeState with list_triggers.",
|
|
743
|
+
inputSchema: {
|
|
744
|
+
name: z.string().describe("The trigger name"),
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
writeTool(
|
|
748
|
+
"start_trigger",
|
|
749
|
+
({ name }) => `trigger=${name}`,
|
|
750
|
+
async ({ name }) => {
|
|
751
|
+
await arm("POST", `/triggers/${encodeURIComponent(name)}/start`);
|
|
752
|
+
return ok({ started: true, trigger: name });
|
|
753
|
+
},
|
|
754
|
+
),
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
server.registerTool(
|
|
758
|
+
"stop_trigger",
|
|
759
|
+
{
|
|
760
|
+
description:
|
|
761
|
+
"Stop a trigger so it stops firing. Long-running; verify the resulting runtimeState with list_triggers.",
|
|
762
|
+
inputSchema: {
|
|
763
|
+
name: z.string().describe("The trigger name"),
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
writeTool(
|
|
767
|
+
"stop_trigger",
|
|
768
|
+
({ name }) => `trigger=${name}`,
|
|
769
|
+
async ({ name }) => {
|
|
770
|
+
await arm("POST", `/triggers/${encodeURIComponent(name)}/stop`);
|
|
771
|
+
return ok({ stopped: true, trigger: name });
|
|
772
|
+
},
|
|
773
|
+
),
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ─── Destructive tools — registered only when ──────────────────────────────
|
|
778
|
+
// ADF_MCP_MODE=write AND ADF_MCP_ALLOW_DELETE=true
|
|
779
|
+
// Each tool uses the dry_run/confirm_token plan-apply pattern. The plan step
|
|
780
|
+
// fetches the existing resource (captures its ETag), shows before/after, and
|
|
781
|
+
// returns a single-use token. The apply step (dry_run=false) requires that
|
|
782
|
+
// token and uses If-Match for optimistic concurrency.
|
|
783
|
+
|
|
784
|
+
if (DESTRUCTIVE_ENABLED) {
|
|
785
|
+
const planApplyInputs = {
|
|
786
|
+
dry_run: z
|
|
787
|
+
.boolean()
|
|
788
|
+
.optional()
|
|
789
|
+
.describe("If true (default), return the planned change without applying."),
|
|
790
|
+
confirm_token: z
|
|
791
|
+
.string()
|
|
792
|
+
.optional()
|
|
793
|
+
.describe("Token returned by a recent dry_run. Required when dry_run=false."),
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
// ── pipelines ──
|
|
797
|
+
server.registerTool(
|
|
798
|
+
"create_or_update_pipeline",
|
|
799
|
+
{
|
|
800
|
+
description:
|
|
801
|
+
"Create a new pipeline or overwrite an existing one. Two-step: first call (dry_run=true, default) returns a diff and a confirm_token; second call (dry_run=false with that token) applies the change.",
|
|
802
|
+
inputSchema: {
|
|
803
|
+
name: z.string().describe("The pipeline name"),
|
|
804
|
+
definition: z
|
|
805
|
+
.record(z.unknown())
|
|
806
|
+
.describe(
|
|
807
|
+
"The pipeline body as the ADF REST API expects it, typically { properties: { activities: [...], parameters: {...} } }.",
|
|
808
|
+
),
|
|
809
|
+
...planApplyInputs,
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
writeTool(
|
|
813
|
+
"create_or_update_pipeline",
|
|
814
|
+
({ name }) => `pipeline=${name}`,
|
|
815
|
+
async ({ name, definition, dry_run, confirm_token }) => {
|
|
816
|
+
const path = `/pipelines/${encodeURIComponent(name)}`;
|
|
817
|
+
return executePlanApply({
|
|
818
|
+
toolName: "create_or_update_pipeline",
|
|
819
|
+
action: "create_or_update",
|
|
820
|
+
target: `pipeline=${name}`,
|
|
821
|
+
payload: { definition },
|
|
822
|
+
dry_run,
|
|
823
|
+
confirm_token,
|
|
824
|
+
fetchBefore: () => fetchExistingOrNull(path),
|
|
825
|
+
buildAfter: () => definition,
|
|
826
|
+
apply: (etag) =>
|
|
827
|
+
arm("PUT", path, definition, undefined, etag ? { "If-Match": etag } : {}),
|
|
828
|
+
});
|
|
829
|
+
},
|
|
830
|
+
),
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
server.registerTool(
|
|
834
|
+
"delete_pipeline",
|
|
835
|
+
{
|
|
836
|
+
description:
|
|
837
|
+
"Delete a pipeline. Two-step: first call (dry_run=true, default) returns the resource that will be removed and a confirm_token; second call (dry_run=false with that token) deletes it.",
|
|
838
|
+
inputSchema: {
|
|
839
|
+
name: z.string().describe("The pipeline name"),
|
|
840
|
+
...planApplyInputs,
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
writeTool(
|
|
844
|
+
"delete_pipeline",
|
|
845
|
+
({ name }) => `pipeline=${name}`,
|
|
846
|
+
async ({ name, dry_run, confirm_token }) => {
|
|
847
|
+
const path = `/pipelines/${encodeURIComponent(name)}`;
|
|
848
|
+
return executePlanApply({
|
|
849
|
+
toolName: "delete_pipeline",
|
|
850
|
+
action: "delete",
|
|
851
|
+
target: `pipeline=${name}`,
|
|
852
|
+
payload: { name },
|
|
853
|
+
dry_run,
|
|
854
|
+
confirm_token,
|
|
855
|
+
fetchBefore: async () => {
|
|
856
|
+
const r = await fetchExistingOrNull(path);
|
|
857
|
+
if (!r) throw new Error(`pipeline "${name}" does not exist; nothing to delete.`);
|
|
858
|
+
return r;
|
|
859
|
+
},
|
|
860
|
+
buildAfter: () => null,
|
|
861
|
+
apply: (etag) =>
|
|
862
|
+
arm("DELETE", path, undefined, undefined, etag ? { "If-Match": etag } : {}),
|
|
863
|
+
});
|
|
864
|
+
},
|
|
865
|
+
),
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
// ── triggers ──
|
|
869
|
+
server.registerTool(
|
|
870
|
+
"create_or_update_trigger",
|
|
871
|
+
{
|
|
872
|
+
description:
|
|
873
|
+
"Create or overwrite a trigger. Same dry_run/confirm_token pattern as create_or_update_pipeline. NOTE: a newly-created trigger is in Stopped state — call start_trigger to activate it.",
|
|
874
|
+
inputSchema: {
|
|
875
|
+
name: z.string().describe("The trigger name"),
|
|
876
|
+
definition: z
|
|
877
|
+
.record(z.unknown())
|
|
878
|
+
.describe("The trigger body, typically { properties: { type: '...', ... } }."),
|
|
879
|
+
...planApplyInputs,
|
|
880
|
+
},
|
|
881
|
+
},
|
|
882
|
+
writeTool(
|
|
883
|
+
"create_or_update_trigger",
|
|
884
|
+
({ name }) => `trigger=${name}`,
|
|
885
|
+
async ({ name, definition, dry_run, confirm_token }) => {
|
|
886
|
+
const path = `/triggers/${encodeURIComponent(name)}`;
|
|
887
|
+
return executePlanApply({
|
|
888
|
+
toolName: "create_or_update_trigger",
|
|
889
|
+
action: "create_or_update",
|
|
890
|
+
target: `trigger=${name}`,
|
|
891
|
+
payload: { definition },
|
|
892
|
+
dry_run,
|
|
893
|
+
confirm_token,
|
|
894
|
+
fetchBefore: () => fetchExistingOrNull(path),
|
|
895
|
+
buildAfter: () => definition,
|
|
896
|
+
apply: (etag) =>
|
|
897
|
+
arm("PUT", path, definition, undefined, etag ? { "If-Match": etag } : {}),
|
|
898
|
+
});
|
|
899
|
+
},
|
|
900
|
+
),
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
server.registerTool(
|
|
904
|
+
"delete_trigger",
|
|
905
|
+
{
|
|
906
|
+
description:
|
|
907
|
+
"Delete a trigger. Must be stopped first (use stop_trigger). Same dry_run/confirm_token pattern as delete_pipeline.",
|
|
908
|
+
inputSchema: {
|
|
909
|
+
name: z.string().describe("The trigger name"),
|
|
910
|
+
...planApplyInputs,
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
writeTool(
|
|
914
|
+
"delete_trigger",
|
|
915
|
+
({ name }) => `trigger=${name}`,
|
|
916
|
+
async ({ name, dry_run, confirm_token }) => {
|
|
917
|
+
const path = `/triggers/${encodeURIComponent(name)}`;
|
|
918
|
+
return executePlanApply({
|
|
919
|
+
toolName: "delete_trigger",
|
|
920
|
+
action: "delete",
|
|
921
|
+
target: `trigger=${name}`,
|
|
922
|
+
payload: { name },
|
|
923
|
+
dry_run,
|
|
924
|
+
confirm_token,
|
|
925
|
+
fetchBefore: async () => {
|
|
926
|
+
const r = await fetchExistingOrNull(path);
|
|
927
|
+
if (!r) throw new Error(`trigger "${name}" does not exist; nothing to delete.`);
|
|
928
|
+
return r;
|
|
929
|
+
},
|
|
930
|
+
buildAfter: () => null,
|
|
931
|
+
apply: (etag) =>
|
|
932
|
+
arm("DELETE", path, undefined, undefined, etag ? { "If-Match": etag } : {}),
|
|
933
|
+
});
|
|
934
|
+
},
|
|
935
|
+
),
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
// ── linked services ──
|
|
939
|
+
server.registerTool(
|
|
940
|
+
"create_or_update_linked_service",
|
|
941
|
+
{
|
|
942
|
+
description:
|
|
943
|
+
"Create or overwrite a linked service (database / storage connection). Same dry_run/confirm_token pattern. Secrets in connection strings should be referenced from Key Vault, not inlined.",
|
|
944
|
+
inputSchema: {
|
|
945
|
+
name: z.string().describe("The linked service name"),
|
|
946
|
+
definition: z
|
|
947
|
+
.record(z.unknown())
|
|
948
|
+
.describe(
|
|
949
|
+
"The linked service body, typically { properties: { type: '...', typeProperties: {...} } }.",
|
|
950
|
+
),
|
|
951
|
+
...planApplyInputs,
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
writeTool(
|
|
955
|
+
"create_or_update_linked_service",
|
|
956
|
+
({ name }) => `linkedservice=${name}`,
|
|
957
|
+
async ({ name, definition, dry_run, confirm_token }) => {
|
|
958
|
+
const path = `/linkedservices/${encodeURIComponent(name)}`;
|
|
959
|
+
return executePlanApply({
|
|
960
|
+
toolName: "create_or_update_linked_service",
|
|
961
|
+
action: "create_or_update",
|
|
962
|
+
target: `linkedservice=${name}`,
|
|
963
|
+
payload: { definition },
|
|
964
|
+
dry_run,
|
|
965
|
+
confirm_token,
|
|
966
|
+
fetchBefore: () => fetchExistingOrNull(path),
|
|
967
|
+
buildAfter: () => definition,
|
|
968
|
+
apply: (etag) =>
|
|
969
|
+
arm("PUT", path, definition, undefined, etag ? { "If-Match": etag } : {}),
|
|
970
|
+
});
|
|
971
|
+
},
|
|
972
|
+
),
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
server.registerTool(
|
|
976
|
+
"delete_linked_service",
|
|
977
|
+
{
|
|
978
|
+
description:
|
|
979
|
+
"Delete a linked service. Any datasets that reference it will start failing — check with list_datasets first. Same dry_run/confirm_token pattern.",
|
|
980
|
+
inputSchema: {
|
|
981
|
+
name: z.string().describe("The linked service name"),
|
|
982
|
+
...planApplyInputs,
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
writeTool(
|
|
986
|
+
"delete_linked_service",
|
|
987
|
+
({ name }) => `linkedservice=${name}`,
|
|
988
|
+
async ({ name, dry_run, confirm_token }) => {
|
|
989
|
+
const path = `/linkedservices/${encodeURIComponent(name)}`;
|
|
990
|
+
return executePlanApply({
|
|
991
|
+
toolName: "delete_linked_service",
|
|
992
|
+
action: "delete",
|
|
993
|
+
target: `linkedservice=${name}`,
|
|
994
|
+
payload: { name },
|
|
995
|
+
dry_run,
|
|
996
|
+
confirm_token,
|
|
997
|
+
fetchBefore: async () => {
|
|
998
|
+
const r = await fetchExistingOrNull(path);
|
|
999
|
+
if (!r) throw new Error(`linked service "${name}" does not exist; nothing to delete.`);
|
|
1000
|
+
return r;
|
|
1001
|
+
},
|
|
1002
|
+
buildAfter: () => null,
|
|
1003
|
+
apply: (etag) =>
|
|
1004
|
+
arm("DELETE", path, undefined, undefined, etag ? { "If-Match": etag } : {}),
|
|
1005
|
+
});
|
|
1006
|
+
},
|
|
1007
|
+
),
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
// ── datasets ──
|
|
1011
|
+
server.registerTool(
|
|
1012
|
+
"create_or_update_dataset",
|
|
1013
|
+
{
|
|
1014
|
+
description: "Create or overwrite a dataset. Same dry_run/confirm_token pattern.",
|
|
1015
|
+
inputSchema: {
|
|
1016
|
+
name: z.string().describe("The dataset name"),
|
|
1017
|
+
definition: z
|
|
1018
|
+
.record(z.unknown())
|
|
1019
|
+
.describe(
|
|
1020
|
+
"The dataset body, typically { properties: { type: '...', linkedServiceName: {...} } }.",
|
|
1021
|
+
),
|
|
1022
|
+
...planApplyInputs,
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
writeTool(
|
|
1026
|
+
"create_or_update_dataset",
|
|
1027
|
+
({ name }) => `dataset=${name}`,
|
|
1028
|
+
async ({ name, definition, dry_run, confirm_token }) => {
|
|
1029
|
+
const path = `/datasets/${encodeURIComponent(name)}`;
|
|
1030
|
+
return executePlanApply({
|
|
1031
|
+
toolName: "create_or_update_dataset",
|
|
1032
|
+
action: "create_or_update",
|
|
1033
|
+
target: `dataset=${name}`,
|
|
1034
|
+
payload: { definition },
|
|
1035
|
+
dry_run,
|
|
1036
|
+
confirm_token,
|
|
1037
|
+
fetchBefore: () => fetchExistingOrNull(path),
|
|
1038
|
+
buildAfter: () => definition,
|
|
1039
|
+
apply: (etag) =>
|
|
1040
|
+
arm("PUT", path, definition, undefined, etag ? { "If-Match": etag } : {}),
|
|
1041
|
+
});
|
|
1042
|
+
},
|
|
1043
|
+
),
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
server.registerTool(
|
|
1047
|
+
"delete_dataset",
|
|
1048
|
+
{
|
|
1049
|
+
description:
|
|
1050
|
+
"Delete a dataset. Any pipelines that reference it will start failing — check with list_pipelines first. Same dry_run/confirm_token pattern.",
|
|
1051
|
+
inputSchema: {
|
|
1052
|
+
name: z.string().describe("The dataset name"),
|
|
1053
|
+
...planApplyInputs,
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
writeTool(
|
|
1057
|
+
"delete_dataset",
|
|
1058
|
+
({ name }) => `dataset=${name}`,
|
|
1059
|
+
async ({ name, dry_run, confirm_token }) => {
|
|
1060
|
+
const path = `/datasets/${encodeURIComponent(name)}`;
|
|
1061
|
+
return executePlanApply({
|
|
1062
|
+
toolName: "delete_dataset",
|
|
1063
|
+
action: "delete",
|
|
1064
|
+
target: `dataset=${name}`,
|
|
1065
|
+
payload: { name },
|
|
1066
|
+
dry_run,
|
|
1067
|
+
confirm_token,
|
|
1068
|
+
fetchBefore: async () => {
|
|
1069
|
+
const r = await fetchExistingOrNull(path);
|
|
1070
|
+
if (!r) throw new Error(`dataset "${name}" does not exist; nothing to delete.`);
|
|
1071
|
+
return r;
|
|
1072
|
+
},
|
|
1073
|
+
buildAfter: () => null,
|
|
1074
|
+
apply: (etag) =>
|
|
1075
|
+
arm("DELETE", path, undefined, undefined, etag ? { "If-Match": etag } : {}),
|
|
1076
|
+
});
|
|
1077
|
+
},
|
|
1078
|
+
),
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const transport = new StdioServerTransport();
|
|
1083
|
+
await server.connect(transport);
|