jira-dash-mcp 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.
@@ -0,0 +1,911 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Jira Grafana Dashboard MCP Server
4
+ *
5
+ * Self-contained stdio MCP server for Grafana dashboard operations.
6
+ * No npm dependencies are required. Requires Node.js >= 18 for global fetch.
7
+ *
8
+ * Main tool: update_dashboard
9
+ * - Full dashboard create/update through Grafana HTTP API
10
+ * - Patch-style update: fetch dashboard by uid, apply JSON Patch-like operations, write back
11
+ */
12
+
13
+ const VERSION = "1.4.1";
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const DEFAULT_PROTOCOL_VERSION = "2024-11-05";
17
+
18
+ const env = process.env;
19
+ const GRAFANA_URL = normalizeBaseUrl(env.GRAFANA_URL || env.GRAFANA_BASE_URL || "");
20
+ const GRAFANA_TOKEN = env.GRAFANA_TOKEN || env.GRAFANA_SERVICE_ACCOUNT_TOKEN || env.GRAFANA_API_TOKEN || "";
21
+ const DEFAULT_FOLDER_UID = env.GRAFANA_FOLDER_UID || "";
22
+ const DEFAULT_DATASOURCE_UID = env.GRAFANA_DATASOURCE_UID || "";
23
+ const STATE_FILE = env.DASHBOARD_STATE_FILE || path.join(process.cwd(), ".runtime", "last-dashboard.json");
24
+
25
+ function normalizeBaseUrl(url) {
26
+ return String(url || "").replace(/\/+$/, "");
27
+ }
28
+
29
+ function requireGrafanaConfig() {
30
+ const missing = [];
31
+ if (!GRAFANA_URL) missing.push("GRAFANA_URL");
32
+ if (!GRAFANA_TOKEN) missing.push("GRAFANA_TOKEN or GRAFANA_SERVICE_ACCOUNT_TOKEN");
33
+ if (missing.length) {
34
+ throw new Error(`Missing required Grafana config: ${missing.join(", ")}`);
35
+ }
36
+ }
37
+
38
+ async function grafanaRequest(method, path, body) {
39
+ requireGrafanaConfig();
40
+ const url = `${GRAFANA_URL}${path}`;
41
+ const headers = {
42
+ "Accept": "application/json",
43
+ "Content-Type": "application/json",
44
+ "Authorization": `Bearer ${GRAFANA_TOKEN}`,
45
+ };
46
+ const res = await fetch(url, {
47
+ method,
48
+ headers,
49
+ body: body === undefined ? undefined : JSON.stringify(body),
50
+ });
51
+ const text = await res.text();
52
+ let data = null;
53
+ if (text) {
54
+ try { data = JSON.parse(text); }
55
+ catch { data = { raw: text }; }
56
+ }
57
+ if (!res.ok) {
58
+ const msg = data && (data.message || data.error) ? (data.message || data.error) : text;
59
+ throw new Error(`Grafana API ${method} ${path} failed: HTTP ${res.status} ${res.statusText}${msg ? ` - ${msg}` : ""}`);
60
+ }
61
+ return data;
62
+ }
63
+
64
+ function encodeQuery(params) {
65
+ const q = new URLSearchParams();
66
+ Object.entries(params || {}).forEach(([k, v]) => {
67
+ if (v !== undefined && v !== null && String(v) !== "") q.set(k, String(v));
68
+ });
69
+ const s = q.toString();
70
+ return s ? `?${s}` : "";
71
+ }
72
+
73
+ function slugify(title) {
74
+ return String(title || "dashboard")
75
+ .trim()
76
+ .toLowerCase()
77
+ .replace(/[\s_]+/g, "-")
78
+ .replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, "")
79
+ .replace(/-+/g, "-")
80
+ .replace(/^-|-$/g, "") || "dashboard";
81
+ }
82
+
83
+ function dashboardUrl(uid, title) {
84
+ return `${GRAFANA_URL}/d/${encodeURIComponent(uid)}/${encodeURIComponent(slugify(title))}`;
85
+ }
86
+
87
+ function folderUrl(folderUid, title) {
88
+ return `${GRAFANA_URL}/dashboards/f/${encodeURIComponent(folderUid)}/${encodeURIComponent(slugify(title || folderUid))}`;
89
+ }
90
+
91
+ function summarizePanel(panel) {
92
+ return {
93
+ id: panel.id,
94
+ title: panel.title || "",
95
+ type: panel.type || "",
96
+ gridPos: panel.gridPos || null,
97
+ datasource: panel.datasource || null,
98
+ targets: Array.isArray(panel.targets)
99
+ ? panel.targets.map((t) => ({
100
+ refId: t.refId,
101
+ datasource: t.datasource || panel.datasource || null,
102
+ rawSql: t.rawSql,
103
+ format: t.format,
104
+ }))
105
+ : [],
106
+ };
107
+ }
108
+
109
+ function summarizeDashboard(payload) {
110
+ const d = payload.dashboard || payload;
111
+ const meta = payload.meta || {};
112
+ return {
113
+ uid: d.uid,
114
+ id: d.id,
115
+ title: d.title,
116
+ url: meta.url ? `${GRAFANA_URL}${meta.url}` : (d.uid ? dashboardUrl(d.uid, d.title) : null),
117
+ folderUid: meta.folderUid || DEFAULT_FOLDER_UID || null,
118
+ folderTitle: meta.folderTitle || null,
119
+ tags: d.tags || [],
120
+ timezone: d.timezone || "browser",
121
+ time: d.time || null,
122
+ refresh: d.refresh || null,
123
+ schemaVersion: d.schemaVersion,
124
+ variables: d.templating && Array.isArray(d.templating.list)
125
+ ? d.templating.list.map((v) => ({ name: v.name, type: v.type, label: v.label, query: v.query, current: v.current }))
126
+ : [],
127
+ panelCount: Array.isArray(d.panels) ? d.panels.length : 0,
128
+ panels: Array.isArray(d.panels) ? d.panels.map(summarizePanel) : [],
129
+ };
130
+ }
131
+
132
+ function decodePointerSegment(s) {
133
+ return s.replace(/~1/g, "/").replace(/~0/g, "~");
134
+ }
135
+
136
+ function pointerParts(path) {
137
+ if (path === "" || path === "/") return [];
138
+ if (!path.startsWith("/")) throw new Error(`JSON pointer must start with '/': ${path}`);
139
+ return path.slice(1).split("/").map(decodePointerSegment);
140
+ }
141
+
142
+ function getAtPath(root, parts) {
143
+ let cur = root;
144
+ for (const part of parts) {
145
+ if (cur === undefined || cur === null) return undefined;
146
+ if (Array.isArray(cur)) {
147
+ const idx = Number(part);
148
+ if (!Number.isInteger(idx)) throw new Error(`Invalid array index '${part}'`);
149
+ cur = cur[idx];
150
+ } else {
151
+ cur = cur[part];
152
+ }
153
+ }
154
+ return cur;
155
+ }
156
+
157
+ function setAtPath(root, parts, value, mode) {
158
+ if (parts.length === 0) {
159
+ if (mode === "add" || mode === "replace") return value;
160
+ throw new Error("Cannot remove document root");
161
+ }
162
+ const parentParts = parts.slice(0, -1);
163
+ const key = parts[parts.length - 1];
164
+ const parent = getAtPath(root, parentParts);
165
+ if (parent === undefined || parent === null) throw new Error(`Parent path not found: /${parentParts.join("/")}`);
166
+ if (Array.isArray(parent)) {
167
+ if (key === "-" && mode === "add") {
168
+ parent.push(value);
169
+ return root;
170
+ }
171
+ const idx = Number(key);
172
+ if (!Number.isInteger(idx)) throw new Error(`Invalid array index '${key}'`);
173
+ if (mode === "add") parent.splice(idx, 0, value);
174
+ else if (mode === "replace") {
175
+ if (idx < 0 || idx >= parent.length) throw new Error(`Array index out of range: ${idx}`);
176
+ parent[idx] = value;
177
+ } else if (mode === "remove") {
178
+ if (idx < 0 || idx >= parent.length) throw new Error(`Array index out of range: ${idx}`);
179
+ parent.splice(idx, 1);
180
+ }
181
+ return root;
182
+ }
183
+ if (mode === "add" || mode === "replace") {
184
+ if (mode === "replace" && !(key in parent)) throw new Error(`Path not found for replace: /${parts.join("/")}`);
185
+ parent[key] = value;
186
+ } else if (mode === "remove") {
187
+ if (!(key in parent)) throw new Error(`Path not found for remove: /${parts.join("/")}`);
188
+ delete parent[key];
189
+ }
190
+ return root;
191
+ }
192
+
193
+ function deepClone(obj) {
194
+ return JSON.parse(JSON.stringify(obj));
195
+ }
196
+
197
+ function ensureDirForFile(file) {
198
+ fs.mkdirSync(path.dirname(file), { recursive: true });
199
+ }
200
+
201
+ function readStateFile() {
202
+ try {
203
+ if (!fs.existsSync(STATE_FILE)) return null;
204
+ return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
205
+ } catch (err) {
206
+ throw new Error(`Failed to read dashboard state file ${STATE_FILE}: ${err.message}`);
207
+ }
208
+ }
209
+
210
+ function writeStateFile(state) {
211
+ try {
212
+ ensureDirForFile(STATE_FILE);
213
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
214
+ return state;
215
+ } catch (err) {
216
+ throw new Error(`Failed to write dashboard state file ${STATE_FILE}: ${err.message}`);
217
+ }
218
+ }
219
+
220
+ function stripSqlComments(sql) {
221
+ return String(sql || "")
222
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
223
+ .replace(/--[^\n\r]*/g, " ")
224
+ .trim();
225
+ }
226
+
227
+ function validateSqlText(sql) {
228
+ const errors = [];
229
+ const warnings = [];
230
+ const raw = String(sql || "");
231
+ const text = stripSqlComments(raw);
232
+ if (!text) {
233
+ errors.push("SQL is empty");
234
+ return { valid: false, errors, warnings };
235
+ }
236
+ const lowered = text.toLowerCase();
237
+ if (!/^\s*(select|with)\b/.test(lowered)) {
238
+ errors.push("Only SELECT/WITH read-only SQL is allowed");
239
+ }
240
+ if (/;\s*\S/.test(text)) {
241
+ errors.push("Multiple SQL statements are not allowed");
242
+ }
243
+ if (/\b(insert|update|delete|drop|alter|truncate|create|replace|grant|revoke|call|exec|merge|load|outfile|infile)\b/i.test(text)) {
244
+ errors.push("Unsafe SQL verb detected; only read-only SELECT/WITH queries are allowed");
245
+ }
246
+ if (/\bfrom\s+([`\w.]+)/ig.test(text) || /\bjoin\s+([`\w.]+)/ig.test(text)) {
247
+ const refs = [...text.matchAll(/\b(?:from|join)\s+([`\w.]+)/ig)].map(m => m[1].replace(/`/g, ""));
248
+ for (const ref of refs) {
249
+ const table = ref.split(".").pop();
250
+ if (table && !table.startsWith("cdc_") && !["information_schema", "dual"].includes(table.toLowerCase())) {
251
+ warnings.push(`Table '${ref}' is outside the expected Jira cdc_* table set; confirm it is intentional`);
252
+ }
253
+ }
254
+ }
255
+ if (!/\$__time(Filter|Group|GroupAlias)\(/.test(text) && /\b(issue_created|issue_updated|resolutiondate|start_date|end_date)\b/i.test(text)) {
256
+ warnings.push("Time field is used without Grafana $__timeFilter/$__timeGroup macro; confirm time range behavior");
257
+ }
258
+ return { valid: errors.length === 0, errors, warnings };
259
+ }
260
+
261
+ function collectSqlTargets(dashboard) {
262
+ const out = [];
263
+ const panels = Array.isArray(dashboard && dashboard.panels) ? dashboard.panels : [];
264
+ for (const panel of panels) {
265
+ if (!Array.isArray(panel.targets)) continue;
266
+ for (const target of panel.targets) {
267
+ if (target && typeof target.rawSql === "string") {
268
+ out.push({ panelId: panel.id, panelTitle: panel.title || "", refId: target.refId || "", rawSql: target.rawSql });
269
+ }
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+
275
+ function replaceEnvPlaceholders(value) {
276
+ if (Array.isArray(value)) return value.map(replaceEnvPlaceholders);
277
+ if (value && typeof value === "object") {
278
+ const out = {};
279
+ for (const [k, v] of Object.entries(value)) out[k] = replaceEnvPlaceholders(v);
280
+ return out;
281
+ }
282
+ if (typeof value !== "string") return value;
283
+ return value
284
+ .replace(/\$\{GRAFANA_DATASOURCE_UID\}/g, DEFAULT_DATASOURCE_UID || "")
285
+ .replace(/__GRAFANA_DATASOURCE_UID__/g, DEFAULT_DATASOURCE_UID || "")
286
+ .replace(/\$\{GRAFANA_FOLDER_UID\}/g, DEFAULT_FOLDER_UID || "")
287
+ .replace(/__GRAFANA_FOLDER_UID__/g, DEFAULT_FOLDER_UID || "");
288
+ }
289
+
290
+ function ensurePanelIds(panels) {
291
+ if (!Array.isArray(panels)) return [];
292
+ const used = new Set();
293
+ let next = 1;
294
+ for (const panel of panels) {
295
+ if (!panel || typeof panel !== "object") continue;
296
+ if (Number.isInteger(panel.id) && panel.id > 0 && !used.has(panel.id)) {
297
+ used.add(panel.id);
298
+ continue;
299
+ }
300
+ while (used.has(next)) next++;
301
+ panel.id = next;
302
+ used.add(next);
303
+ next++;
304
+ }
305
+ return panels;
306
+ }
307
+
308
+ function collectDatasourceRefs(node, refs = []) {
309
+ if (Array.isArray(node)) {
310
+ node.forEach((x) => collectDatasourceRefs(x, refs));
311
+ } else if (node && typeof node === "object") {
312
+ if (node.datasource && typeof node.datasource === "object") refs.push(node.datasource);
313
+ Object.values(node).forEach((x) => collectDatasourceRefs(x, refs));
314
+ }
315
+ return refs;
316
+ }
317
+
318
+ function validateDashboardObject(dashboard) {
319
+ const errors = [];
320
+ const warnings = [];
321
+ const d = dashboard || {};
322
+ if (!d.title || typeof d.title !== "string") errors.push("dashboard.title is required");
323
+ if (!Array.isArray(d.panels)) errors.push("dashboard.panels must be an array");
324
+ if (!d.time || !d.time.from || !d.time.to) warnings.push("dashboard.time is missing; default now-30d to now will be used");
325
+ if (!DEFAULT_DATASOURCE_UID) warnings.push("GRAFANA_DATASOURCE_UID is not set; dashboard queries must explicitly include a datasource uid");
326
+
327
+ const panels = Array.isArray(d.panels) ? d.panels : [];
328
+ const panelIds = new Set();
329
+ for (const panel of panels) {
330
+ if (!panel || typeof panel !== "object") {
331
+ errors.push("panel must be an object");
332
+ continue;
333
+ }
334
+ if (!panel.title) warnings.push(`panel id ${panel.id || "unknown"} has no title`);
335
+ if (panel.id && panelIds.has(panel.id)) warnings.push(`duplicate panel id ${panel.id}; ids will be normalized`);
336
+ if (panel.id) panelIds.add(panel.id);
337
+ if (Array.isArray(panel.targets)) {
338
+ for (const target of panel.targets) {
339
+ if (target && target.rawSql && typeof target.rawSql === "string") {
340
+ const sqlCheck = validateSqlText(target.rawSql);
341
+ for (const e of sqlCheck.errors) errors.push(`unsafe SQL in panel '${panel.title || panel.id}' target '${target.refId || ""}': ${e}`);
342
+ for (const w of sqlCheck.warnings) warnings.push(`SQL warning in panel '${panel.title || panel.id}' target '${target.refId || ""}': ${w}`);
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ for (const ref of collectDatasourceRefs(d)) {
349
+ if (ref.uid === "${GRAFANA_DATASOURCE_UID}" || ref.uid === "__GRAFANA_DATASOURCE_UID__") {
350
+ warnings.push("datasource uid placeholder detected; it will be replaced from GRAFANA_DATASOURCE_UID");
351
+ }
352
+ if (DEFAULT_DATASOURCE_UID && ref.type === "mysql" && !ref.uid) {
353
+ warnings.push("mysql datasource object has no uid; default uid will be injected where possible");
354
+ }
355
+ }
356
+ return { valid: errors.length === 0, errors, warnings };
357
+ }
358
+
359
+ function applyOperations(root, operations) {
360
+ let doc = root;
361
+ for (const op of operations || []) {
362
+ if (!op || !op.op || typeof op.path !== "string") throw new Error(`Invalid operation: ${JSON.stringify(op)}`);
363
+ const parts = pointerParts(op.path);
364
+ if (op.op === "add") doc = setAtPath(doc, parts, deepClone(op.value), "add");
365
+ else if (op.op === "replace") doc = setAtPath(doc, parts, deepClone(op.value), "replace");
366
+ else if (op.op === "remove") doc = setAtPath(doc, parts, undefined, "remove");
367
+ else if (op.op === "test") {
368
+ const actual = getAtPath(doc, parts);
369
+ if (JSON.stringify(actual) !== JSON.stringify(op.value)) throw new Error(`JSON patch test failed at ${op.path}`);
370
+ } else {
371
+ throw new Error(`Unsupported operation '${op.op}'. Supported: add, replace, remove, test`);
372
+ }
373
+ }
374
+ return doc;
375
+ }
376
+
377
+ function queryJsonPath(obj, expression) {
378
+ if (!expression || expression === "$") return obj;
379
+ let expr = expression.trim();
380
+ if (!expr.startsWith("$")) throw new Error("JSONPath must start with $");
381
+ expr = expr.slice(1);
382
+ const tokens = [];
383
+ let i = 0;
384
+ while (i < expr.length) {
385
+ if (expr[i] === ".") {
386
+ i++;
387
+ let name = "";
388
+ while (i < expr.length && /[A-Za-z0-9_$-]/.test(expr[i])) name += expr[i++];
389
+ if (!name) throw new Error(`Invalid JSONPath near '${expr.slice(i)}'`);
390
+ tokens.push(name);
391
+ } else if (expr[i] === "[") {
392
+ const end = expr.indexOf("]", i);
393
+ if (end === -1) throw new Error("Unclosed JSONPath bracket");
394
+ let inner = expr.slice(i + 1, end).trim();
395
+ if ((inner.startsWith("'") && inner.endsWith("'")) || (inner.startsWith('"') && inner.endsWith('"'))) {
396
+ inner = inner.slice(1, -1);
397
+ }
398
+ tokens.push(inner);
399
+ i = end + 1;
400
+ } else {
401
+ throw new Error(`Unsupported JSONPath syntax near '${expr.slice(i)}'. Supported: $.a.b[0].c and $['a']`);
402
+ }
403
+ }
404
+ let cur = obj;
405
+ for (const t of tokens) {
406
+ if (cur === undefined || cur === null) return null;
407
+ if (Array.isArray(cur)) {
408
+ const idx = Number(t);
409
+ if (!Number.isInteger(idx)) throw new Error(`Invalid array index '${t}'`);
410
+ cur = cur[idx];
411
+ } else {
412
+ cur = cur[t];
413
+ }
414
+ }
415
+ return cur === undefined ? null : cur;
416
+ }
417
+
418
+ async function listDatasources() {
419
+ const data = await grafanaRequest("GET", "/api/datasources");
420
+ return data.map((d) => ({ id: d.id, uid: d.uid, name: d.name, type: d.type, access: d.access, isDefault: d.isDefault }));
421
+ }
422
+
423
+ async function getDatasource(args) {
424
+ if (args.uid) return await grafanaRequest("GET", `/api/datasources/uid/${encodeURIComponent(args.uid)}`);
425
+ if (args.name) return await grafanaRequest("GET", `/api/datasources/name/${encodeURIComponent(args.name)}`);
426
+ if (DEFAULT_DATASOURCE_UID) return await grafanaRequest("GET", `/api/datasources/uid/${encodeURIComponent(DEFAULT_DATASOURCE_UID)}`);
427
+ throw new Error("Provide uid or name, or set GRAFANA_DATASOURCE_UID");
428
+ }
429
+
430
+ async function searchFolders(args) {
431
+ const params = encodeQuery({ type: "dash-folder", query: args.query || "", limit: args.limit || 50 });
432
+ return await grafanaRequest("GET", `/api/search${params}`);
433
+ }
434
+
435
+ async function searchDashboards(args) {
436
+ const params = encodeQuery({
437
+ type: "dash-db",
438
+ query: args.query || "",
439
+ folderUIDs: args.folderUid || args.folderUIDs || DEFAULT_FOLDER_UID || undefined,
440
+ limit: args.limit || 50,
441
+ });
442
+ return await grafanaRequest("GET", `/api/search${params}`);
443
+ }
444
+
445
+ async function getDashboardByUid(args) {
446
+ if (!args.uid) throw new Error("uid is required");
447
+ return await grafanaRequest("GET", `/api/dashboards/uid/${encodeURIComponent(args.uid)}`);
448
+ }
449
+
450
+ async function getDashboardSummary(args) {
451
+ const payload = await getDashboardByUid(args);
452
+ return summarizeDashboard(payload);
453
+ }
454
+
455
+ async function getDashboardProperty(args) {
456
+ const payload = await getDashboardByUid(args);
457
+ const root = args.root === "response" ? payload : payload.dashboard;
458
+ return {
459
+ uid: payload.dashboard && payload.dashboard.uid,
460
+ title: payload.dashboard && payload.dashboard.title,
461
+ expression: args.expression,
462
+ value: queryJsonPath(root, args.expression || "$"),
463
+ };
464
+ }
465
+
466
+ function normalizeDashboardForWrite(dashboard, args) {
467
+ let d = deepClone(dashboard || {});
468
+ d = replaceEnvPlaceholders(d);
469
+ if (!d.title) throw new Error("dashboard.title is required");
470
+ if (!Array.isArray(d.panels)) d.panels = [];
471
+ d.panels = ensurePanelIds(d.panels);
472
+ d.time = d.time || { from: "now-30d", to: "now" };
473
+ d.timezone = d.timezone || "browser";
474
+ d.refresh = d.refresh === undefined ? "" : d.refresh;
475
+ d.schemaVersion = d.schemaVersion || 39;
476
+ d.version = args.resetVersion ? 0 : (d.version || 0);
477
+ if (DEFAULT_DATASOURCE_UID) {
478
+ // Best-effort datasource UID injection for MySQL panels/targets missing uid.
479
+ const refs = collectDatasourceRefs(d);
480
+ for (const ref of refs) {
481
+ if ((ref.type === "mysql" || !ref.type) && !ref.uid) ref.uid = DEFAULT_DATASOURCE_UID;
482
+ }
483
+ }
484
+ return d;
485
+ }
486
+
487
+ async function getConfig() {
488
+ return {
489
+ grafanaUrl: GRAFANA_URL || null,
490
+ hasToken: Boolean(GRAFANA_TOKEN),
491
+ defaultFolderUid: DEFAULT_FOLDER_UID || null,
492
+ defaultDatasourceUid: DEFAULT_DATASOURCE_UID || null,
493
+ serverVersion: VERSION,
494
+ stateFile: STATE_FILE,
495
+ };
496
+ }
497
+
498
+ async function validateDashboard(args) {
499
+ if (!args.dashboard || typeof args.dashboard !== "object") throw new Error("dashboard object is required");
500
+ const dashboard = normalizeDashboardForWrite(args.dashboard, { resetVersion: args.resetVersion });
501
+ const validation = validateDashboardObject(dashboard);
502
+ return { ...validation, dashboard };
503
+ }
504
+
505
+ async function updateDashboard(args) {
506
+ const overwrite = args.overwrite !== false;
507
+ const message = args.message || "Updated by Jira Grafana Dashboard MCP";
508
+ const folderUid = args.folderUid || args.folderUID || DEFAULT_FOLDER_UID || undefined;
509
+
510
+ let dashboard;
511
+ let mode;
512
+
513
+ if (args.uid && Array.isArray(args.operations)) {
514
+ mode = "patch";
515
+ const current = await getDashboardByUid({ uid: args.uid });
516
+ dashboard = deepClone(current.dashboard);
517
+ dashboard = applyOperations(dashboard, args.operations);
518
+ dashboard.version = current.dashboard.version;
519
+ if (!dashboard.uid) dashboard.uid = args.uid;
520
+ } else if (args.dashboard && typeof args.dashboard === "object") {
521
+ dashboard = args.dashboard;
522
+ mode = dashboard && dashboard.uid ? "replace" : "create_or_replace";
523
+ } else {
524
+ throw new Error("update_dashboard requires either { dashboard } or { uid, operations }");
525
+ }
526
+
527
+ dashboard = normalizeDashboardForWrite(dashboard, args);
528
+ const validation = validateDashboardObject(dashboard);
529
+ if (!validation.valid) {
530
+ throw new Error(`Dashboard validation failed: ${validation.errors.join("; ")}`);
531
+ }
532
+ const sqlTargets = collectSqlTargets(dashboard);
533
+ const summary = summarizeDashboard({ dashboard, meta: { folderUid } });
534
+ if (args.dryRun) {
535
+ return {
536
+ status: "dry_run",
537
+ mode,
538
+ wouldWrite: false,
539
+ folderUid: folderUid || null,
540
+ summary,
541
+ sqlTargetCount: sqlTargets.length,
542
+ warnings: validation.warnings,
543
+ };
544
+ }
545
+ const body = {
546
+ dashboard,
547
+ folderUid,
548
+ overwrite,
549
+ message,
550
+ };
551
+ const result = await grafanaRequest("POST", "/api/dashboards/db", body);
552
+ const uid = result.uid || dashboard.uid;
553
+ const url = result.url ? `${GRAFANA_URL}${result.url}` : dashboardUrl(uid, dashboard.title);
554
+ const state = writeStateFile({
555
+ uid,
556
+ title: dashboard.title,
557
+ url,
558
+ folderUid: folderUid || null,
559
+ projectKey: args.projectKey || "",
560
+ userRequest: args.userRequest || "",
561
+ updatedAt: new Date().toISOString(),
562
+ serverVersion: VERSION,
563
+ });
564
+ return {
565
+ status: result.status || "success",
566
+ mode,
567
+ uid,
568
+ id: result.id,
569
+ version: result.version,
570
+ slug: result.slug || slugify(dashboard.title),
571
+ url,
572
+ folderUid: folderUid || null,
573
+ message: result.message || message,
574
+ warnings: validation.warnings,
575
+ stateSaved: true,
576
+ stateFile: STATE_FILE,
577
+ lastDashboard: state,
578
+ };
579
+ }
580
+
581
+
582
+ async function getLastDashboard(args) {
583
+ const state = readStateFile();
584
+ if (!state) return { found: false, stateFile: STATE_FILE };
585
+ if (args && args.projectKey && state.projectKey && args.projectKey !== state.projectKey) {
586
+ return { found: false, stateFile: STATE_FILE, reason: `last dashboard projectKey is ${state.projectKey}, not ${args.projectKey}`, last: state };
587
+ }
588
+ return { found: true, stateFile: STATE_FILE, dashboard: state };
589
+ }
590
+
591
+ async function saveDashboardState(args) {
592
+ if (!args.uid) throw new Error("uid is required");
593
+ const now = new Date().toISOString();
594
+ const state = {
595
+ uid: args.uid,
596
+ title: args.title || args.dashboardTitle || "",
597
+ url: args.url || (args.uid ? dashboardUrl(args.uid, args.title || "dashboard") : ""),
598
+ folderUid: args.folderUid || DEFAULT_FOLDER_UID || "",
599
+ projectKey: args.projectKey || "",
600
+ userRequest: args.userRequest || "",
601
+ updatedAt: now,
602
+ serverVersion: VERSION,
603
+ };
604
+ return { saved: true, stateFile: STATE_FILE, dashboard: writeStateFile(state) };
605
+ }
606
+
607
+ async function clearDashboardState() {
608
+ if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
609
+ return { cleared: true, stateFile: STATE_FILE };
610
+ }
611
+
612
+ async function validateSql(args) {
613
+ if (!args.sql || typeof args.sql !== "string") throw new Error("sql string is required");
614
+ return validateSqlText(args.sql);
615
+ }
616
+
617
+ async function generateDeeplink(args) {
618
+ if (args.type === "folder") {
619
+ const uid = args.uid || args.folderUid || DEFAULT_FOLDER_UID;
620
+ if (!uid) throw new Error("folder uid is required");
621
+ return { url: folderUrl(uid, args.title || args.slug || uid) };
622
+ }
623
+ const uid = args.uid || args.dashboardUid;
624
+ if (!uid) throw new Error("dashboard uid is required");
625
+ return { url: dashboardUrl(uid, args.title || args.slug || "dashboard") };
626
+ }
627
+
628
+ async function healthcheck() {
629
+ const res = { ok: true, grafanaUrl: GRAFANA_URL, defaultFolderUid: DEFAULT_FOLDER_UID, defaultDatasourceUid: DEFAULT_DATASOURCE_UID };
630
+ const org = await grafanaRequest("GET", "/api/org");
631
+ res.org = org;
632
+ if (DEFAULT_DATASOURCE_UID) {
633
+ res.datasource = await getDatasource({ uid: DEFAULT_DATASOURCE_UID });
634
+ }
635
+ return res;
636
+ }
637
+
638
+ const tools = [
639
+ {
640
+ name: "get_config",
641
+ description: "Return non-secret Grafana MCP runtime config, including default datasource UID and folder UID.",
642
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
643
+ handler: getConfig,
644
+ },
645
+ {
646
+ name: "get_last_dashboard",
647
+ description: "Return the last dashboard written by update_dashboard, used to locate the previous dashboard during multi-turn iteration.",
648
+ inputSchema: { type: "object", properties: { projectKey: { type: "string" } }, additionalProperties: false },
649
+ handler: getLastDashboard,
650
+ },
651
+ {
652
+ name: "save_dashboard_state",
653
+ description: "Manually save the active dashboard UID/title/url for later iteration when update_dashboard was not the last action.",
654
+ inputSchema: {
655
+ type: "object",
656
+ required: ["uid"],
657
+ properties: { uid: { type: "string" }, title: { type: "string" }, dashboardTitle: { type: "string" }, url: { type: "string" }, folderUid: { type: "string" }, projectKey: { type: "string" }, userRequest: { type: "string" } },
658
+ additionalProperties: false,
659
+ },
660
+ handler: saveDashboardState,
661
+ },
662
+ {
663
+ name: "clear_dashboard_state",
664
+ description: "Clear the saved last-dashboard pointer.",
665
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
666
+ handler: clearDashboardState,
667
+ },
668
+ {
669
+ name: "validate_sql",
670
+ description: "Validate one SQL query before embedding it in a dashboard. Enforces read-only SELECT/WITH and warns about non-cdc tables and missing Grafana time macros.",
671
+ inputSchema: { type: "object", required: ["sql"], properties: { sql: { type: "string" } }, additionalProperties: false },
672
+ handler: validateSql,
673
+ },
674
+ {
675
+ name: "validate_dashboard",
676
+ description: "Validate and normalize a dashboard JSON before calling update_dashboard. It checks required fields, panel ids, datasource placeholders, and unsafe SQL verbs.",
677
+ inputSchema: {
678
+ type: "object",
679
+ required: ["dashboard"],
680
+ properties: { dashboard: { type: "object" }, resetVersion: { type: "boolean" } },
681
+ additionalProperties: false,
682
+ },
683
+ handler: validateDashboard,
684
+ },
685
+ {
686
+ name: "list_datasources",
687
+ description: "List Grafana datasources. Use this to discover the MySQL datasource UID before generating dashboard JSON.",
688
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
689
+ handler: listDatasources,
690
+ },
691
+ {
692
+ name: "get_datasource",
693
+ description: "Get a Grafana datasource by UID or name. If no argument is given, uses GRAFANA_DATASOURCE_UID.",
694
+ inputSchema: {
695
+ type: "object",
696
+ properties: { uid: { type: "string" }, name: { type: "string" } },
697
+ additionalProperties: false,
698
+ },
699
+ handler: getDatasource,
700
+ },
701
+ {
702
+ name: "search_folders",
703
+ description: "Search Grafana folders by query string.",
704
+ inputSchema: {
705
+ type: "object",
706
+ properties: { query: { type: "string" }, limit: { type: "number" } },
707
+ additionalProperties: false,
708
+ },
709
+ handler: searchFolders,
710
+ },
711
+ {
712
+ name: "search_dashboards",
713
+ description: "Search dashboards, optionally restricted to a folder UID.",
714
+ inputSchema: {
715
+ type: "object",
716
+ properties: { query: { type: "string" }, folderUid: { type: "string" }, limit: { type: "number" } },
717
+ additionalProperties: false,
718
+ },
719
+ handler: searchDashboards,
720
+ },
721
+ {
722
+ name: "get_dashboard_by_uid",
723
+ description: "Get full dashboard JSON by uid. Avoid this unless full JSON is needed.",
724
+ inputSchema: { type: "object", required: ["uid"], properties: { uid: { type: "string" } }, additionalProperties: false },
725
+ handler: getDashboardByUid,
726
+ },
727
+ {
728
+ name: "get_dashboard_summary",
729
+ description: "Get a compact summary of a dashboard without returning full JSON. Prefer this before dashboard edits.",
730
+ inputSchema: { type: "object", required: ["uid"], properties: { uid: { type: "string" } }, additionalProperties: false },
731
+ handler: getDashboardSummary,
732
+ },
733
+ {
734
+ name: "get_dashboard_property",
735
+ description: "Extract a dashboard property using a simple JSONPath expression. Expressions apply to dashboard root by default, e.g. $.panels[0].title.",
736
+ inputSchema: {
737
+ type: "object",
738
+ required: ["uid", "expression"],
739
+ properties: {
740
+ uid: { type: "string" },
741
+ expression: { type: "string" },
742
+ root: { type: "string", enum: ["dashboard", "response"] },
743
+ },
744
+ additionalProperties: false,
745
+ },
746
+ handler: getDashboardProperty,
747
+ },
748
+ {
749
+ name: "update_dashboard",
750
+ description: "Create/update a Grafana dashboard. Supports full dashboard JSON or patch-style update with uid plus operations. Patch operations are applied to the dashboard JSON root.",
751
+ inputSchema: {
752
+ type: "object",
753
+ properties: {
754
+ dashboard: { type: "object", description: "Full Grafana dashboard JSON for create or full replacement." },
755
+ uid: { type: "string", description: "Existing dashboard UID for patch updates." },
756
+ operations: {
757
+ type: "array",
758
+ description: "JSON Patch-like operations applied to dashboard JSON root. Supports add, replace, remove, test.",
759
+ items: {
760
+ type: "object",
761
+ required: ["op", "path"],
762
+ properties: {
763
+ op: { type: "string", enum: ["add", "replace", "remove", "test"] },
764
+ path: { type: "string" },
765
+ value: {},
766
+ },
767
+ },
768
+ },
769
+ folderUid: { type: "string", description: "Target Grafana folder UID. Defaults to GRAFANA_FOLDER_UID." },
770
+ overwrite: { type: "boolean", description: "Whether to overwrite existing dashboard. Defaults to true." },
771
+ message: { type: "string", description: "Dashboard version message." },
772
+ resetVersion: { type: "boolean", description: "Force dashboard.version=0 for full replacement. Defaults to false." },
773
+ dryRun: { type: "boolean", description: "Validate and preview the normalized dashboard without writing to Grafana." },
774
+ projectKey: { type: "string", description: "Optional Jira project key saved into last-dashboard state for future iteration." },
775
+ userRequest: { type: "string", description: "Optional user request summary saved into last-dashboard state." },
776
+ },
777
+ additionalProperties: false,
778
+ },
779
+ handler: updateDashboard,
780
+ },
781
+ {
782
+ name: "generate_deeplink",
783
+ description: "Generate a Grafana dashboard or folder URL from UID and title.",
784
+ inputSchema: {
785
+ type: "object",
786
+ properties: {
787
+ type: { type: "string", enum: ["dashboard", "folder"] },
788
+ uid: { type: "string" },
789
+ dashboardUid: { type: "string" },
790
+ folderUid: { type: "string" },
791
+ title: { type: "string" },
792
+ slug: { type: "string" },
793
+ },
794
+ additionalProperties: false,
795
+ },
796
+ handler: generateDeeplink,
797
+ },
798
+ ];
799
+
800
+ const toolMap = new Map(tools.map((t) => [t.name, t]));
801
+
802
+ function response(id, result) {
803
+ return { jsonrpc: "2.0", id, result };
804
+ }
805
+
806
+ function errorResponse(id, code, message, data) {
807
+ return { jsonrpc: "2.0", id, error: { code, message, data } };
808
+ }
809
+
810
+ async function handleMessage(msg) {
811
+ try {
812
+ if (!msg || msg.jsonrpc !== "2.0") return null;
813
+ const { id, method, params } = msg;
814
+
815
+ if (method === "initialize") {
816
+ return response(id, {
817
+ protocolVersion: (params && params.protocolVersion) || DEFAULT_PROTOCOL_VERSION,
818
+ capabilities: { tools: {} },
819
+ serverInfo: { name: "jira-grafana-dashboard-mcp", version: VERSION },
820
+ });
821
+ }
822
+
823
+ if (method === "notifications/initialized") return null;
824
+
825
+ if (method === "ping") return response(id, {});
826
+
827
+ if (method === "tools/list") {
828
+ return response(id, {
829
+ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
830
+ });
831
+ }
832
+
833
+ if (method === "tools/call") {
834
+ const name = params && params.name;
835
+ const args = (params && params.arguments) || {};
836
+ const tool = toolMap.get(name);
837
+ if (!tool) throw new Error(`Unknown tool: ${name}`);
838
+ const result = await tool.handler(args);
839
+ return response(id, {
840
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
841
+ isError: false,
842
+ });
843
+ }
844
+
845
+ return errorResponse(id, -32601, `Method not found: ${method}`);
846
+ } catch (err) {
847
+ return response(msg.id, {
848
+ content: [{ type: "text", text: `ERROR: ${err.message}` }],
849
+ isError: true,
850
+ });
851
+ }
852
+ }
853
+
854
+ function writeMessage(msg) {
855
+ if (!msg) return;
856
+ // MCP stdio transport uses newline-delimited JSON-RPC messages.
857
+ // Do not use LSP-style Content-Length framing here; Claude Code will wait forever.
858
+ process.stdout.write(JSON.stringify(msg) + "\n");
859
+ }
860
+
861
+ let buffer = Buffer.alloc(0);
862
+ function tryParseMessages() {
863
+ while (buffer.length > 0) {
864
+ const headerEnd = buffer.indexOf(Buffer.from("\r\n\r\n"));
865
+ if (headerEnd === -1) {
866
+ const nl = buffer.indexOf(Buffer.from("\n"));
867
+ // Fallback for newline-delimited JSON during manual testing.
868
+ if (nl !== -1 && buffer.slice(0, nl).toString("utf8").trim().startsWith("{")) {
869
+ const line = buffer.slice(0, nl).toString("utf8").trim();
870
+ buffer = buffer.slice(nl + 1);
871
+ handleMessage(JSON.parse(line)).then(writeMessage);
872
+ continue;
873
+ }
874
+ return;
875
+ }
876
+ const header = buffer.slice(0, headerEnd).toString("utf8");
877
+ const match = header.match(/Content-Length:\s*(\d+)/i);
878
+ if (!match) throw new Error(`Invalid MCP header: ${header}`);
879
+ const length = Number(match[1]);
880
+ const bodyStart = headerEnd + 4;
881
+ const bodyEnd = bodyStart + length;
882
+ if (buffer.length < bodyEnd) return;
883
+ const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
884
+ buffer = buffer.slice(bodyEnd);
885
+ handleMessage(JSON.parse(body)).then(writeMessage).catch((err) => writeMessage(errorResponse(null, -32603, err.message)));
886
+ }
887
+ }
888
+
889
+ async function main() {
890
+ if (process.argv.includes("--healthcheck")) {
891
+ try {
892
+ const result = await healthcheck();
893
+ console.log(JSON.stringify(result, null, 2));
894
+ process.exit(0);
895
+ } catch (err) {
896
+ console.error(err.message);
897
+ process.exit(1);
898
+ }
899
+ }
900
+
901
+ process.stdin.on("data", (chunk) => {
902
+ buffer = Buffer.concat([buffer, chunk]);
903
+ try { tryParseMessages(); }
904
+ catch (err) { writeMessage(errorResponse(null, -32700, err.message)); }
905
+ });
906
+ }
907
+
908
+ main().catch((err) => {
909
+ console.error(err);
910
+ process.exit(1);
911
+ });