@cci-labs/mode-mcp 1.0.1

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,707 @@
1
+ import { z } from "zod";
2
+ import { trySaveFile, slugify } from "../file-utils.js";
3
+ import { truncateOutput } from "../truncate.js";
4
+ import { formatError, formatDate, formatBytes, markdownTable, parseCSVLine } from "../utils.js";
5
+ export function registerAnalyticsTools(server, client, config) {
6
+ // --- mode_list_reports ---
7
+ server.tool("mode_list_reports", "List reports in the Mode workspace. Can filter by space. Returns report names, tokens, and last run timestamps.", {
8
+ space_token: z.string().optional().describe("Filter reports by space/collection token"),
9
+ filter: z.string().optional().describe("Filter string (e.g. timestamp-based)"),
10
+ order: z.enum(["asc", "desc"]).optional().describe("Sort order"),
11
+ order_by: z.enum(["created_at", "updated_at"]).optional().describe("Sort field"),
12
+ }, async ({ space_token, filter, order, order_by }) => {
13
+ try {
14
+ const params = {};
15
+ if (filter)
16
+ params.filter = filter;
17
+ if (order)
18
+ params.order = order;
19
+ if (order_by)
20
+ params.order_by = order_by;
21
+ const path = space_token
22
+ ? `/spaces/${space_token}/reports`
23
+ : `/reports`;
24
+ let allReports = [];
25
+ if (!space_token) {
26
+ const spaceParams = { ...params, filter: params.filter ?? "all" };
27
+ const spacesResp = await client.get("/spaces", { params: spaceParams });
28
+ const spaces = client.getEmbedded(spacesResp, "spaces");
29
+ // Batch spaces 5 at a time for parallelism
30
+ for (let i = 0; i < spaces.length; i += 5) {
31
+ const batch = spaces.slice(i, i + 5);
32
+ const results = await Promise.allSettled(batch.map(async (space) => {
33
+ const spaceToken = space.token;
34
+ const spaceName = space.name;
35
+ const reportsResp = await client.get(`/spaces/${spaceToken}/reports`, { params });
36
+ const reports = client.getEmbedded(reportsResp, "reports");
37
+ return reports.map((r) => ({
38
+ token: r.token,
39
+ name: r.name,
40
+ description: r.description,
41
+ space: spaceName,
42
+ space_token: spaceToken,
43
+ created_at: r.created_at,
44
+ updated_at: r.updated_at,
45
+ last_successfully_run_at: r.last_successfully_run_at,
46
+ query_count: r.query_count,
47
+ is_archived: r.archived,
48
+ }));
49
+ }));
50
+ for (const result of results) {
51
+ if (result.status === "fulfilled") {
52
+ allReports.push(...result.value);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ else {
58
+ const resp = await client.get(path, { params });
59
+ allReports = client.getEmbedded(resp, "reports").map((r) => ({
60
+ token: r.token,
61
+ name: r.name,
62
+ description: r.description,
63
+ space: space_token,
64
+ created_at: r.created_at,
65
+ updated_at: r.updated_at,
66
+ last_successfully_run_at: r.last_successfully_run_at,
67
+ query_count: r.query_count,
68
+ is_archived: r.archived,
69
+ }));
70
+ }
71
+ if (allReports.length === 0) {
72
+ const msg = space_token
73
+ ? `No reports found in space ${space_token}.`
74
+ : "No reports found across any accessible spaces in this workspace.";
75
+ return { content: [{ type: "text", text: msg }] };
76
+ }
77
+ const text = truncateOutput(formatReportList(allReports) + "\n\nNext steps: Use mode_get_report with a report token for details, or mode_search_reports to filter by name.");
78
+ return { content: [{ type: "text", text }] };
79
+ }
80
+ catch (e) {
81
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
82
+ }
83
+ });
84
+ // --- mode_get_report ---
85
+ server.tool("mode_get_report", "Get details of a specific Mode report including its queries, data sources, and last run status.", {
86
+ report_token: z.string().describe("The report token (e.g. '578c18655469')"),
87
+ }, async ({ report_token }) => {
88
+ try {
89
+ const report = await client.get(`/reports/${report_token}`);
90
+ const webUrl = client.getLink(report, "web") ?? "";
91
+ const lines = [
92
+ `Report: ${report.name ?? "Untitled"}`,
93
+ `Token: ${report.token}`,
94
+ `Space: ${report.space_token ?? "N/A"}`,
95
+ `Queries: ${report.query_count ?? 0}`,
96
+ `Created: ${formatDate(report.created_at)}`,
97
+ `Last run: ${formatDate(report.last_successfully_run_at)}`,
98
+ `Last run token: ${report.last_successful_run_token ?? "N/A"}`,
99
+ `Archived: ${report.archived ? "yes" : "no"}`,
100
+ ];
101
+ if (report.description) {
102
+ lines.push(`Description: ${report.description}`);
103
+ }
104
+ if (webUrl) {
105
+ lines.push(`URL: ${webUrl}`);
106
+ }
107
+ return { content: [{ type: "text", text: truncateOutput(lines.join("\n")) }] };
108
+ }
109
+ catch (e) {
110
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
111
+ }
112
+ });
113
+ // --- mode_list_queries ---
114
+ server.tool("mode_list_queries", "List all queries within a Mode report, including their SQL content and data sources.", {
115
+ report_token: z.string().describe("The report token"),
116
+ }, async ({ report_token }) => {
117
+ try {
118
+ const resp = await client.get(`/reports/${report_token}/queries`);
119
+ const queries = client.getEmbedded(resp, "queries");
120
+ if (queries.length === 0) {
121
+ return { content: [{ type: "text", text: `No queries found in report ${report_token}. The report may be empty or still being set up.` }] };
122
+ }
123
+ const lines = [`Found ${queries.length} ${queries.length === 1 ? "query" : "queries"}:`, ""];
124
+ queries.forEach((q, i) => {
125
+ lines.push(`${i + 1}. ${q.name ?? "Untitled"} (token: ${q.token})`);
126
+ lines.push(` Data source ID: ${q.data_source_id ?? "N/A"}`);
127
+ lines.push(` Updated: ${formatDate(q.updated_at)}`);
128
+ const sql = q.raw_query;
129
+ if (sql) {
130
+ const preview = sql.length > 200 ? sql.slice(0, 200) + "..." : sql;
131
+ lines.push(` SQL: ${preview}`);
132
+ }
133
+ lines.push("");
134
+ });
135
+ return { content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }] };
136
+ }
137
+ catch (e) {
138
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
139
+ }
140
+ });
141
+ // --- mode_run_report ---
142
+ server.tool("mode_run_report", "Trigger a new run of a Mode report. Returns a run token for polling status. This is non-destructive — it does not modify the report. IMPORTANT: Call mode_get_report_parameters first to check if the report requires parameters — running with defaults rarely produces the desired results.", {
143
+ report_token: z.string().describe("The report token"),
144
+ parameters: z.record(z.string(), z.unknown()).optional().describe("Optional report parameters to pass to the run"),
145
+ }, async ({ report_token, parameters }) => {
146
+ try {
147
+ const body = {};
148
+ if (parameters) {
149
+ body.parameters = parameters;
150
+ }
151
+ const run = await client.post(`/reports/${report_token}/runs`, Object.keys(body).length > 0 ? body : undefined);
152
+ const lines = [
153
+ `Report run triggered.`,
154
+ ``,
155
+ `Run token: ${run.token}`,
156
+ `State: ${run.state}`,
157
+ `Created: ${formatDate(run.created_at)}`,
158
+ ``,
159
+ `Use mode_get_run_status to poll for completion, or mode_execute_and_fetch to wait for results automatically.`,
160
+ ];
161
+ return { content: [{ type: "text", text: lines.join("\n") }] };
162
+ }
163
+ catch (e) {
164
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
165
+ }
166
+ });
167
+ // --- mode_get_run_status ---
168
+ server.tool("mode_get_run_status", "Check whether a Mode report run has completed, is still running, or failed.", {
169
+ report_token: z.string().describe("The report token"),
170
+ run_token: z.string().describe("The run token returned by mode_run_report"),
171
+ }, async ({ report_token, run_token }) => {
172
+ try {
173
+ const run = await client.get(`/reports/${report_token}/runs/${run_token}`);
174
+ const lines = [
175
+ `Run: ${run.token}`,
176
+ `State: ${run.state}`,
177
+ `Created: ${formatDate(run.created_at)}`,
178
+ `Completed: ${formatDate(run.completed_at)}`,
179
+ ];
180
+ return { content: [{ type: "text", text: lines.join("\n") }] };
181
+ }
182
+ catch (e) {
183
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
184
+ }
185
+ });
186
+ // --- mode_get_query_results ---
187
+ server.tool("mode_get_query_results", "Download the results of a completed query run. Returns data as a markdown table and optionally saves a CSV file if output_dir is provided.", {
188
+ report_token: z.string().describe("The report token"),
189
+ run_token: z.string().describe("The run token"),
190
+ query_run_token: z.string().describe("The query run token"),
191
+ max_rows: z.number().optional().describe(`Maximum rows to return (default: ${100})`),
192
+ output_dir: z.string().optional().describe("Directory to save results as CSV. If omitted, results are returned inline only."),
193
+ filename: z.string().optional().describe("CSV filename (e.g. 'results.csv'). Defaults to 'query-results.csv'."),
194
+ }, async ({ report_token, run_token, query_run_token, max_rows, output_dir, filename }) => {
195
+ try {
196
+ const limit = max_rows ?? config.maxRowsDefault;
197
+ const csv = await client.get(`/reports/${report_token}/runs/${run_token}/query_runs/${query_run_token}/results/content.csv`, { accept: "text/csv" });
198
+ const cleanCsv = csv.replace(/\r\n/g, "\n").trim();
199
+ if (!cleanCsv) {
200
+ return { content: [{ type: "text", text: "Query returned no data. The response was empty." }] };
201
+ }
202
+ const csvLines = cleanCsv.split("\n");
203
+ if (csvLines.length <= 1) {
204
+ return { content: [{ type: "text", text: "Query returned no rows. The query completed successfully but produced an empty result set." }] };
205
+ }
206
+ const headers = parseCSVLine(csvLines[0]);
207
+ if (headers.every((h) => h.trim() === "")) {
208
+ return { content: [{ type: "text", text: "Query returned malformed CSV data (no valid column headers)." }], isError: true };
209
+ }
210
+ const totalRows = csvLines.length - 1;
211
+ const dataLines = csvLines.slice(1, limit + 1);
212
+ const rows = dataLines.map((line) => {
213
+ const values = parseCSVLine(line);
214
+ const row = {};
215
+ headers.forEach((h, i) => {
216
+ row[h] = values[i] ?? "";
217
+ });
218
+ return row;
219
+ });
220
+ const showing = rows.length < totalRows ? ` (showing ${rows.length})` : "";
221
+ const output = [
222
+ `Query run: ${query_run_token}`,
223
+ `Rows: ${totalRows} total${showing}`,
224
+ ``,
225
+ markdownTable(headers, rows),
226
+ ];
227
+ // Try saving CSV if output_dir provided
228
+ const outputFilename = filename ?? "query-results.csv";
229
+ const saveResult = await trySaveFile(cleanCsv, outputFilename, output_dir);
230
+ if (saveResult.saved) {
231
+ output.push("");
232
+ output.push(`Saved to: ${saveResult.path} (${formatBytes(saveResult.size_bytes)})`);
233
+ }
234
+ else if (output_dir) {
235
+ output.push("");
236
+ output.push(`Save failed: Could not write to ${output_dir}`);
237
+ }
238
+ return { content: [{ type: "text", text: truncateOutput(output.join("\n")) }] };
239
+ }
240
+ catch (e) {
241
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
242
+ }
243
+ });
244
+ // --- mode_execute_and_fetch ---
245
+ server.tool("mode_execute_and_fetch", "Run a Mode report, wait for completion, and return the query results — all in one call. This is the highest-value tool for getting data from Mode. Optionally saves results as CSV. IMPORTANT: Call mode_get_report_parameters first to check if the report requires parameters — running with defaults rarely produces the desired results.", {
246
+ report_token: z.string().describe("The report token"),
247
+ query_index: z.number().optional().describe("Which query's results to return (0-based, default: 0 for the first query)"),
248
+ parameters: z.record(z.string(), z.unknown()).optional().describe("Optional report parameters"),
249
+ max_rows: z.number().optional().describe("Maximum rows to return (default: 100)"),
250
+ timeout_seconds: z.number().optional().describe("Max seconds to wait for completion (default: 300). Increase for reports with heavy queries."),
251
+ output_dir: z.string().optional().describe("Directory to save results as CSV. If omitted, results are returned inline only."),
252
+ filename: z.string().optional().describe("CSV filename. Defaults to the report name slugified (e.g. 'runner-dashboard.csv')."),
253
+ }, async ({ report_token, query_index, parameters, max_rows, timeout_seconds, output_dir, filename }) => {
254
+ try {
255
+ const limit = max_rows ?? config.maxRowsDefault;
256
+ const timeoutMs = (timeout_seconds ?? 300) * 1000;
257
+ const qIdx = query_index ?? 0;
258
+ if (qIdx < 0) {
259
+ return {
260
+ content: [{ type: "text", text: "query_index must be >= 0." }],
261
+ isError: true,
262
+ };
263
+ }
264
+ // Get report name for default filename
265
+ const report = await client.get(`/reports/${report_token}`);
266
+ const reportName = report.name ?? "report";
267
+ const queryCount = report.query_count;
268
+ if (queryCount === 0) {
269
+ return {
270
+ content: [{ type: "text", text: `Report "${reportName}" has no queries. Add queries in the Mode editor before running.` }],
271
+ isError: true,
272
+ };
273
+ }
274
+ // 1. Trigger run
275
+ const body = {};
276
+ if (parameters)
277
+ body.parameters = parameters;
278
+ const run = await client.post(`/reports/${report_token}/runs`, Object.keys(body).length > 0 ? body : undefined);
279
+ const runToken = run.token;
280
+ // 2. Poll for completion
281
+ await client.pollUntilComplete(`/reports/${report_token}/runs/${runToken}`, timeoutMs);
282
+ // 3. Get query runs
283
+ const qrResp = await client.get(`/reports/${report_token}/runs/${runToken}/query_runs`);
284
+ const queryRuns = client.getEmbedded(qrResp, "query_runs");
285
+ if (queryRuns.length === 0) {
286
+ return {
287
+ content: [{ type: "text", text: "Report run completed but no query runs found." }],
288
+ isError: true,
289
+ };
290
+ }
291
+ if (qIdx >= queryRuns.length) {
292
+ return {
293
+ content: [
294
+ {
295
+ type: "text",
296
+ text: `query_index ${qIdx} is out of range. This report has ${queryRuns.length} queries (0-${queryRuns.length - 1}). Use mode_list_queries to see available queries.`,
297
+ },
298
+ ],
299
+ isError: true,
300
+ };
301
+ }
302
+ const queryRun = queryRuns[qIdx];
303
+ const qrToken = queryRun.token;
304
+ const queryName = queryRun.query_name ?? `Query ${qIdx}`;
305
+ // 4. Fetch results
306
+ const csv = await client.get(`/reports/${report_token}/runs/${runToken}/query_runs/${qrToken}/results/content.csv`, { accept: "text/csv" });
307
+ const cleanCsv = csv.replace(/\r\n/g, "\n").trim();
308
+ if (!cleanCsv) {
309
+ return {
310
+ content: [{ type: "text", text: `Report "${reportName}" query "${queryName}" returned no data. The response was empty.` }],
311
+ };
312
+ }
313
+ const csvLines = cleanCsv.split("\n");
314
+ const headers = parseCSVLine(csvLines[0] ?? "");
315
+ if (headers.every((h) => h.trim() === "")) {
316
+ return {
317
+ content: [{ type: "text", text: `Report "${reportName}" query "${queryName}" returned malformed CSV data (no valid column headers).` }],
318
+ isError: true,
319
+ };
320
+ }
321
+ const totalRows = csvLines.length - 1;
322
+ if (totalRows === 0) {
323
+ return {
324
+ content: [{
325
+ type: "text",
326
+ text: `Report: ${reportName}\nQuery: ${queryName}\nRun token: ${runToken}\n\nQuery returned no rows. The query completed successfully but produced an empty result set.`,
327
+ }],
328
+ };
329
+ }
330
+ const dataLines = csvLines.slice(1, limit + 1);
331
+ const rows = dataLines.map((line) => {
332
+ const values = parseCSVLine(line);
333
+ const row = {};
334
+ headers.forEach((h, i) => {
335
+ row[h] = values[i] ?? "";
336
+ });
337
+ return row;
338
+ });
339
+ const showing = rows.length < totalRows ? ` (showing ${rows.length})` : "";
340
+ const output = [
341
+ `Report: ${reportName}`,
342
+ `Query: ${queryName}`,
343
+ `Run token: ${runToken}`,
344
+ `Rows: ${totalRows} total${showing}`,
345
+ ``,
346
+ markdownTable(headers, rows),
347
+ ];
348
+ // Try saving CSV if output_dir provided
349
+ const outputFilename = filename ?? `${slugify(reportName)}.csv`;
350
+ const saveResult = await trySaveFile(cleanCsv, outputFilename, output_dir);
351
+ if (saveResult.saved) {
352
+ output.push("");
353
+ output.push(`Saved to: ${saveResult.path} (${formatBytes(saveResult.size_bytes)})`);
354
+ }
355
+ else if (output_dir) {
356
+ output.push("");
357
+ output.push(`Save failed: Could not write to ${output_dir}`);
358
+ }
359
+ return { content: [{ type: "text", text: truncateOutput(output.join("\n")) }] };
360
+ }
361
+ catch (e) {
362
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
363
+ }
364
+ });
365
+ // --- mode_list_report_runs ---
366
+ server.tool("mode_list_report_runs", "List past runs for a Mode report. Returns run tokens, states, and timestamps.", {
367
+ report_token: z.string().describe("The report token"),
368
+ }, async ({ report_token }) => {
369
+ try {
370
+ const resp = await client.get(`/reports/${report_token}/runs`);
371
+ const runs = client.getEmbedded(resp, "report_runs");
372
+ if (runs.length === 0) {
373
+ return {
374
+ content: [{ type: "text", text: "No runs found for this report. Use mode_run_report to trigger a new run." }],
375
+ };
376
+ }
377
+ const lines = [`Found ${runs.length} ${runs.length === 1 ? "run" : "runs"}:`, ""];
378
+ runs.forEach((r, i) => {
379
+ lines.push(`${i + 1}. Run ${r.token} — ${r.state}`);
380
+ lines.push(` Created: ${formatDate(r.created_at)}`);
381
+ if (r.completed_at) {
382
+ lines.push(` Completed: ${formatDate(r.completed_at)}`);
383
+ }
384
+ lines.push("");
385
+ });
386
+ lines.push("");
387
+ lines.push("Next steps: Use mode_get_query_results with a run token to fetch data, or mode_get_run_status to check a specific run.");
388
+ return { content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }] };
389
+ }
390
+ catch (e) {
391
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
392
+ }
393
+ });
394
+ // --- mode_get_query_run ---
395
+ server.tool("mode_get_query_run", "Get details on a specific query run, including state, error details if failed, timing, and data source.", {
396
+ report_token: z.string().describe("The report token"),
397
+ run_token: z.string().describe("The run token"),
398
+ query_run_token: z.string().describe("The query run token"),
399
+ }, async ({ report_token, run_token, query_run_token }) => {
400
+ try {
401
+ const qr = await client.get(`/reports/${report_token}/runs/${run_token}/query_runs/${query_run_token}`);
402
+ const lines = [
403
+ `Query run: ${qr.token}`,
404
+ `State: ${qr.state}`,
405
+ `Query name: ${qr.query_name ?? "N/A"}`,
406
+ `Data source ID: ${qr.data_source_id ?? "N/A"}`,
407
+ `Created: ${formatDate(qr.created_at)}`,
408
+ `Completed: ${formatDate(qr.completed_at)}`,
409
+ ];
410
+ if (qr.raw_source) {
411
+ const sql = String(qr.raw_source);
412
+ const preview = sql.length > 300 ? sql.slice(0, 300) + "..." : sql;
413
+ lines.push(`SQL: ${preview}`);
414
+ }
415
+ if (qr.state === "failed") {
416
+ if (qr.error_message)
417
+ lines.push(`Error: ${qr.error_message}`);
418
+ if (qr.error_id)
419
+ lines.push(`Error ID: ${qr.error_id}`);
420
+ lines.push("");
421
+ lines.push("Use mode_list_queries to check the SQL, or mode_run_report to retry.");
422
+ }
423
+ return { content: [{ type: "text", text: truncateOutput(lines.join("\n")) }] };
424
+ }
425
+ catch (e) {
426
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
427
+ }
428
+ });
429
+ // --- mode_search_reports ---
430
+ server.tool("mode_search_reports", "Search reports by name (case-insensitive substring match). Optionally filter to a specific space.", {
431
+ query: z.string().describe("Search string to match against report names"),
432
+ space_token: z.string().optional().describe("Limit search to a specific space token"),
433
+ }, async ({ query, space_token }) => {
434
+ try {
435
+ const searchLower = query.toLowerCase();
436
+ const allReports = [];
437
+ if (space_token) {
438
+ const reportsResp = await client.get(`/spaces/${space_token}/reports`);
439
+ const reports = client.getEmbedded(reportsResp, "reports");
440
+ for (const r of reports) {
441
+ allReports.push({
442
+ token: r.token,
443
+ name: r.name,
444
+ description: r.description,
445
+ space: space_token,
446
+ space_token,
447
+ last_successfully_run_at: r.last_successfully_run_at,
448
+ query_count: r.query_count,
449
+ is_archived: r.archived,
450
+ });
451
+ }
452
+ }
453
+ else {
454
+ const spacesResp = await client.get("/spaces", { params: { filter: "all" } });
455
+ const spaces = client.getEmbedded(spacesResp, "spaces");
456
+ // Batch spaces 5 at a time for parallelism
457
+ for (let i = 0; i < spaces.length; i += 5) {
458
+ const batch = spaces.slice(i, i + 5);
459
+ const results = await Promise.allSettled(batch.map(async (space) => {
460
+ const sToken = space.token;
461
+ const sName = space.name;
462
+ const reportsResp = await client.get(`/spaces/${sToken}/reports`);
463
+ const reports = client.getEmbedded(reportsResp, "reports");
464
+ return reports.map((r) => ({
465
+ token: r.token,
466
+ name: r.name,
467
+ description: r.description,
468
+ space: sName,
469
+ space_token: sToken,
470
+ last_successfully_run_at: r.last_successfully_run_at,
471
+ query_count: r.query_count,
472
+ is_archived: r.archived,
473
+ }));
474
+ }));
475
+ for (const result of results) {
476
+ if (result.status === "fulfilled") {
477
+ allReports.push(...result.value);
478
+ }
479
+ }
480
+ }
481
+ }
482
+ const matches = allReports.filter((r) => {
483
+ const name = String(r.name ?? "").toLowerCase();
484
+ return name.includes(searchLower);
485
+ });
486
+ if (matches.length === 0) {
487
+ return {
488
+ content: [{
489
+ type: "text",
490
+ text: `No reports found matching "${query}". Use mode_list_reports to see all available reports.`,
491
+ }],
492
+ };
493
+ }
494
+ const text = truncateOutput(formatReportList(matches, `Matching "${query}"`) + "\n\nNext steps: Use mode_get_report with a report token to see details, then mode_execute_and_fetch to run it.");
495
+ return { content: [{ type: "text", text }] };
496
+ }
497
+ catch (e) {
498
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
499
+ }
500
+ });
501
+ // --- mode_get_report_parameters ---
502
+ server.tool("mode_get_report_parameters", "Discover what parameters a report accepts before running it. Returns parameter names, types, default values, and available options. IMPORTANT: Always call this before mode_execute_and_fetch or mode_run_report to check if the report requires parameters — reports run with defaults rarely produce the desired results.", {
503
+ report_token: z.string().describe("The report token"),
504
+ }, async ({ report_token }) => {
505
+ try {
506
+ // Get report details
507
+ const report = await client.get(`/reports/${report_token}`);
508
+ const reportName = report.name ?? "Untitled";
509
+ const lastRunToken = report.last_successful_run_token;
510
+ if (!lastRunToken) {
511
+ // No runs yet — try to discover params from SQL
512
+ const queriesResp = await client.get(`/reports/${report_token}/queries`);
513
+ const queries = client.getEmbedded(queriesResp, "queries");
514
+ const allParams = [];
515
+ for (const q of queries) {
516
+ const sql = q.raw_query ?? "";
517
+ const matches = sql.match(/\{\{(\w+)\}\}/g);
518
+ if (matches) {
519
+ for (const m of matches) {
520
+ const name = m.replace(/\{\{|\}\}/g, "").trim();
521
+ if (!allParams.includes(name))
522
+ allParams.push(name);
523
+ }
524
+ }
525
+ }
526
+ if (allParams.length === 0) {
527
+ return {
528
+ content: [{
529
+ type: "text",
530
+ text: `Report: ${reportName}\n\nThis report has no parameters. It can be run without any additional input.`,
531
+ }],
532
+ };
533
+ }
534
+ const lines = [
535
+ `Report: ${reportName}`,
536
+ "",
537
+ `Found ${allParams.length} ${allParams.length === 1 ? "parameter" : "parameters"} in query SQL (no prior runs to get defaults from):`,
538
+ "",
539
+ ];
540
+ allParams.forEach((p, i) => {
541
+ lines.push(`${i + 1}. ${p} (discovered from SQL template)`);
542
+ });
543
+ lines.push("");
544
+ lines.push("Run the report with mode_run_report or mode_execute_and_fetch, passing these as parameters.");
545
+ return { content: [{ type: "text", text: truncateOutput(lines.join("\n")) }] };
546
+ }
547
+ // Get form fields from the last successful run
548
+ const run = await client.get(`/reports/${report_token}/runs/${lastRunToken}`);
549
+ const formFields = run.form_fields ?? [];
550
+ const parameters = run.parameters ?? {};
551
+ if (formFields.length === 0 && Object.keys(parameters).length === 0) {
552
+ return {
553
+ content: [{
554
+ type: "text",
555
+ text: `Report: ${reportName}\n\nThis report has no parameters. It can be run without any additional input.`,
556
+ }],
557
+ };
558
+ }
559
+ const lines = [
560
+ `Report: ${reportName}`,
561
+ "",
562
+ ];
563
+ if (formFields.length > 0) {
564
+ lines.push(`${formFields.length} ${formFields.length === 1 ? "parameter" : "parameters"}:`);
565
+ lines.push("");
566
+ for (const field of formFields) {
567
+ const name = field.name;
568
+ const label = field.human_name ?? field.label ?? name;
569
+ const type = field.type ?? "text";
570
+ const defaultVal = field.default;
571
+ const currentVal = field.value;
572
+ const options = field.options ?? [];
573
+ lines.push(` ${label}`);
574
+ lines.push(` Name: ${name}`);
575
+ lines.push(` Type: ${type}`);
576
+ if (defaultVal !== undefined && defaultVal !== null) {
577
+ lines.push(` Default: ${defaultVal}`);
578
+ }
579
+ if (currentVal !== undefined && currentVal !== null) {
580
+ lines.push(` Last used: ${currentVal}`);
581
+ }
582
+ if (options.length > 0) {
583
+ const optionStrs = options.slice(0, 20).map((o) => Array.isArray(o) ? String(o[1] ?? o[0]) : String(o));
584
+ lines.push(` Options: ${optionStrs.join(", ")}${options.length > 20 ? ` ... (${options.length} total)` : ""}`);
585
+ }
586
+ lines.push("");
587
+ }
588
+ }
589
+ else {
590
+ // No form fields but has parameters — show from the parameters object
591
+ const paramKeys = Object.keys(parameters);
592
+ lines.push(`${paramKeys.length} ${paramKeys.length === 1 ? "parameter" : "parameters"}:`);
593
+ lines.push("");
594
+ for (const key of paramKeys) {
595
+ lines.push(` ${key}`);
596
+ lines.push(` Last used: ${parameters[key]}`);
597
+ lines.push("");
598
+ }
599
+ }
600
+ lines.push("Pass these as the `parameters` argument to mode_execute_and_fetch or mode_run_report.");
601
+ lines.push(`Example: {"${formFields[0]?.name ?? Object.keys(parameters)[0] ?? "param"}": "your_value"}`);
602
+ return { content: [{ type: "text", text: truncateOutput(lines.join("\n")) }] };
603
+ }
604
+ catch (e) {
605
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
606
+ }
607
+ });
608
+ // --- mode_list_report_filters ---
609
+ server.tool("mode_list_report_filters", "List filters (interactive parameters) configured on a Mode report. Shows filter names, types, and available options.", {
610
+ report_token: z.string().describe("The report token"),
611
+ }, async ({ report_token }) => {
612
+ try {
613
+ const resp = await client.get(`/reports/${report_token}/filters`);
614
+ const filters = client.getEmbedded(resp, "report_filters");
615
+ if (filters.length === 0) {
616
+ return {
617
+ content: [{ type: "text", text: `No filters found for report ${report_token}. The report does not have interactive filter controls.` }],
618
+ };
619
+ }
620
+ const lines = [`Found ${filters.length} ${filters.length === 1 ? "filter" : "filters"}:`, ""];
621
+ filters.forEach((f, i) => {
622
+ const name = f.name ?? "Untitled";
623
+ const token = f.token ?? "unknown";
624
+ lines.push(`${i + 1}. ${name} (token: ${token})`);
625
+ if (f.filter_type)
626
+ lines.push(` Filter type: ${f.filter_type}`);
627
+ if (f.control_type)
628
+ lines.push(` Control type: ${f.control_type}`);
629
+ if (f.data_type)
630
+ lines.push(` Data type: ${f.data_type}`);
631
+ const options = f.options;
632
+ if (options && options.length > 0) {
633
+ const preview = options.slice(0, 10).map(String).join(", ");
634
+ lines.push(` Options: ${preview}${options.length > 10 ? ` ... (${options.length} total)` : ""}`);
635
+ }
636
+ lines.push("");
637
+ });
638
+ return { content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }] };
639
+ }
640
+ catch (e) {
641
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
642
+ }
643
+ });
644
+ // --- mode_list_report_charts ---
645
+ server.tool("mode_list_report_charts", "List charts (visualizations) in a specific query of a Mode report. Shows chart types and tokens.", {
646
+ report_token: z.string().describe("The report token"),
647
+ query_token: z.string().describe("The query token"),
648
+ }, async ({ report_token, query_token }) => {
649
+ try {
650
+ const resp = await client.get(`/reports/${report_token}/queries/${query_token}/charts`);
651
+ const charts = client.getEmbedded(resp, "charts");
652
+ if (charts.length === 0) {
653
+ return {
654
+ content: [{ type: "text", text: `No charts found for query ${query_token} in report ${report_token}.` }],
655
+ };
656
+ }
657
+ const lines = [`Found ${charts.length} ${charts.length === 1 ? "chart" : "charts"}:`, ""];
658
+ charts.forEach((c, i) => {
659
+ const token = c.token ?? "unknown";
660
+ lines.push(`${i + 1}. Chart ${token}`);
661
+ const view = c.view;
662
+ if (view?.selectedChart) {
663
+ lines.push(` Type: ${view.selectedChart}`);
664
+ }
665
+ if (c.created_at)
666
+ lines.push(` Created: ${formatDate(c.created_at)}`);
667
+ lines.push("");
668
+ });
669
+ return { content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }] };
670
+ }
671
+ catch (e) {
672
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
673
+ }
674
+ });
675
+ }
676
+ /** Shared formatter for report lists */
677
+ function formatReportList(reports, label) {
678
+ const header = label
679
+ ? `Found ${reports.length} ${reports.length === 1 ? "report" : "reports"} ${label}:`
680
+ : `Found ${reports.length} ${reports.length === 1 ? "report" : "reports"}:`;
681
+ const lines = [header, ""];
682
+ reports.forEach((r, i) => {
683
+ const name = r.name ?? "Untitled";
684
+ const token = r.token ?? "unknown";
685
+ lines.push(`${i + 1}. ${name} (token: ${token})`);
686
+ const details = [];
687
+ if (r.space)
688
+ details.push(`Space: ${r.space}`);
689
+ if (r.query_count !== undefined && r.query_count !== null)
690
+ details.push(`Queries: ${r.query_count}`);
691
+ if (r.last_successfully_run_at)
692
+ details.push(`Last run: ${formatDate(r.last_successfully_run_at)}`);
693
+ if (r.is_archived)
694
+ details.push("ARCHIVED");
695
+ if (details.length > 0) {
696
+ lines.push(` ${details.join(" | ")}`);
697
+ }
698
+ if (r.description) {
699
+ const desc = String(r.description);
700
+ if (desc.length > 0) {
701
+ lines.push(` ${desc.length > 120 ? desc.slice(0, 120) + "..." : desc}`);
702
+ }
703
+ }
704
+ lines.push("");
705
+ });
706
+ return lines.join("\n").trimEnd();
707
+ }