@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.
- package/LICENSE +21 -0
- package/README.md +925 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +27 -0
- package/dist/file-utils.d.ts +7 -0
- package/dist/file-utils.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +29 -0
- package/dist/mode-client.d.ts +38 -0
- package/dist/mode-client.js +227 -0
- package/dist/tools/analytics.d.ts +4 -0
- package/dist/tools/analytics.js +707 -0
- package/dist/tools/datasets.d.ts +4 -0
- package/dist/tools/datasets.js +154 -0
- package/dist/tools/distribution.d.ts +4 -0
- package/dist/tools/distribution.js +175 -0
- package/dist/tools/management.d.ts +4 -0
- package/dist/tools/management.js +263 -0
- package/dist/truncate.d.ts +6 -0
- package/dist/truncate.js +16 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +68 -0
- package/package.json +59 -0
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ModeClient } from "../mode-client.js";
|
|
3
|
+
import { ModeConfig } from "../config.js";
|
|
4
|
+
export declare function registerDatasetTools(server: McpServer, client: ModeClient, _config: ModeConfig): void;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { truncateOutput } from "../truncate.js";
|
|
3
|
+
import { formatError, formatDate } from "../utils.js";
|
|
4
|
+
export function registerDatasetTools(server, client, _config) {
|
|
5
|
+
// --- mode_list_datasets ---
|
|
6
|
+
server.tool("mode_list_datasets", "List datasets in a Mode space. Datasets are curated tables/views that analysts can query without writing SQL.", {
|
|
7
|
+
space_token: z.string().describe("The space token to list datasets from"),
|
|
8
|
+
filter: z.string().optional().describe("Filter string"),
|
|
9
|
+
order: z.enum(["asc", "desc"]).optional().describe("Sort order"),
|
|
10
|
+
order_by: z.string().optional().describe("Sort field"),
|
|
11
|
+
}, async ({ space_token, filter, order, order_by }) => {
|
|
12
|
+
try {
|
|
13
|
+
const params = {};
|
|
14
|
+
if (filter)
|
|
15
|
+
params.filter = filter;
|
|
16
|
+
if (order)
|
|
17
|
+
params.order = order;
|
|
18
|
+
if (order_by)
|
|
19
|
+
params.order_by = order_by;
|
|
20
|
+
const resp = await client.get(`/spaces/${space_token}/datasets`, { params });
|
|
21
|
+
const datasets = client.getEmbedded(resp, "datasets");
|
|
22
|
+
if (datasets.length === 0) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text", text: `No datasets found in space ${space_token}.` }],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const lines = [`Found ${datasets.length} ${datasets.length === 1 ? "dataset" : "datasets"}:`, ""];
|
|
28
|
+
datasets.forEach((ds, i) => {
|
|
29
|
+
const name = ds.name ?? "Untitled";
|
|
30
|
+
const token = ds.token ?? "unknown";
|
|
31
|
+
lines.push(`${i + 1}. ${name} (token: ${token})`);
|
|
32
|
+
const details = [];
|
|
33
|
+
if (ds.data_source_id)
|
|
34
|
+
details.push(`Data source ID: ${ds.data_source_id}`);
|
|
35
|
+
if (ds.created_at)
|
|
36
|
+
details.push(`Created: ${formatDate(ds.created_at)}`);
|
|
37
|
+
if (details.length > 0) {
|
|
38
|
+
lines.push(` ${details.join(" | ")}`);
|
|
39
|
+
}
|
|
40
|
+
if (ds.description) {
|
|
41
|
+
const desc = String(ds.description);
|
|
42
|
+
if (desc.length > 0) {
|
|
43
|
+
lines.push(` ${desc.length > 120 ? desc.slice(0, 120) + "..." : desc}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
lines.push("");
|
|
47
|
+
});
|
|
48
|
+
lines.push("Next steps: Use mode_get_dataset with a dataset token to see fields and descriptions, or mode_list_dataset_reports to find reports using a dataset.");
|
|
49
|
+
return { content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }] };
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// --- mode_get_dataset ---
|
|
56
|
+
server.tool("mode_get_dataset", "Get details of a specific dataset including its fields and field descriptions. Fields are enriched with descriptions when available.", {
|
|
57
|
+
dataset_token: z.string().describe("The dataset token"),
|
|
58
|
+
include_field_descriptions: z.boolean().optional().describe("Include field descriptions (default: true)"),
|
|
59
|
+
}, async ({ dataset_token, include_field_descriptions }) => {
|
|
60
|
+
try {
|
|
61
|
+
const includeDescs = include_field_descriptions !== false;
|
|
62
|
+
const [dataset, fieldsResp] = await Promise.all([
|
|
63
|
+
client.get(`/datasets/${dataset_token}`),
|
|
64
|
+
client.get(`/datasets/${dataset_token}/fields`),
|
|
65
|
+
]);
|
|
66
|
+
const fields = client.getEmbedded(fieldsResp, "fields");
|
|
67
|
+
let descriptionMap;
|
|
68
|
+
if (includeDescs) {
|
|
69
|
+
try {
|
|
70
|
+
const descsResp = await client.get(`/datasets/${dataset_token}/field_descriptions`);
|
|
71
|
+
const descs = client.getEmbedded(descsResp, "dataset_field_descriptions");
|
|
72
|
+
descriptionMap = new Map();
|
|
73
|
+
for (const d of descs) {
|
|
74
|
+
const fieldName = d.field_name;
|
|
75
|
+
const desc = d.description;
|
|
76
|
+
if (fieldName && desc) {
|
|
77
|
+
descriptionMap.set(fieldName, desc);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Field descriptions may not be available — continue without them
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const lines = [
|
|
86
|
+
`Dataset: ${dataset.name ?? "Untitled"}`,
|
|
87
|
+
`Token: ${dataset.token}`,
|
|
88
|
+
];
|
|
89
|
+
if (dataset.description)
|
|
90
|
+
lines.push(`Description: ${dataset.description}`);
|
|
91
|
+
if (dataset.data_source_id)
|
|
92
|
+
lines.push(`Data source ID: ${dataset.data_source_id}`);
|
|
93
|
+
lines.push(`Created: ${formatDate(dataset.created_at)}`);
|
|
94
|
+
lines.push(`Updated: ${formatDate(dataset.updated_at)}`);
|
|
95
|
+
lines.push("");
|
|
96
|
+
if (fields.length === 0) {
|
|
97
|
+
lines.push("No fields found.");
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push(`${fields.length} ${fields.length === 1 ? "field" : "fields"}:`);
|
|
101
|
+
lines.push("");
|
|
102
|
+
for (const f of fields) {
|
|
103
|
+
const name = f.name ?? "unknown";
|
|
104
|
+
const type = f.type ?? "unknown";
|
|
105
|
+
let line = ` ${name} (${type})`;
|
|
106
|
+
if (descriptionMap?.has(name)) {
|
|
107
|
+
line += ` — ${descriptionMap.get(name)}`;
|
|
108
|
+
}
|
|
109
|
+
lines.push(line);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
lines.push("");
|
|
113
|
+
lines.push("Next steps: Use mode_list_dataset_reports to find reports that use this dataset.");
|
|
114
|
+
return { content: [{ type: "text", text: truncateOutput(lines.join("\n")) }] };
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// --- mode_list_dataset_reports ---
|
|
121
|
+
server.tool("mode_list_dataset_reports", "List reports that use a specific dataset. Useful for understanding dataset usage and finding relevant analyses.", {
|
|
122
|
+
dataset_token: z.string().describe("The dataset token"),
|
|
123
|
+
}, async ({ dataset_token }) => {
|
|
124
|
+
try {
|
|
125
|
+
const resp = await client.get(`/datasets/${dataset_token}/reports`);
|
|
126
|
+
const reports = client.getEmbedded(resp, "reports");
|
|
127
|
+
if (reports.length === 0) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: "text", text: `No reports found using dataset ${dataset_token}.` }],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const lines = [`Found ${reports.length} ${reports.length === 1 ? "report" : "reports"} using this dataset:`, ""];
|
|
133
|
+
reports.forEach((r, i) => {
|
|
134
|
+
const name = r.name ?? "Untitled";
|
|
135
|
+
const token = r.token ?? "unknown";
|
|
136
|
+
lines.push(`${i + 1}. ${name} (token: ${token})`);
|
|
137
|
+
const details = [];
|
|
138
|
+
if (r.last_successfully_run_at)
|
|
139
|
+
details.push(`Last run: ${formatDate(r.last_successfully_run_at)}`);
|
|
140
|
+
if (r.query_count !== undefined && r.query_count !== null)
|
|
141
|
+
details.push(`Queries: ${r.query_count}`);
|
|
142
|
+
if (details.length > 0) {
|
|
143
|
+
lines.push(` ${details.join(" | ")}`);
|
|
144
|
+
}
|
|
145
|
+
lines.push("");
|
|
146
|
+
});
|
|
147
|
+
lines.push("Next steps: Use mode_get_report with a report token to see details, then mode_execute_and_fetch to run it.");
|
|
148
|
+
return { content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }] };
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ModeClient } from "../mode-client.js";
|
|
3
|
+
import { ModeConfig } from "../config.js";
|
|
4
|
+
export declare function registerDistributionTools(server: McpServer, client: ModeClient, config: ModeConfig): void;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { trySaveFile, slugify } from "../file-utils.js";
|
|
3
|
+
import { truncateOutput } from "../truncate.js";
|
|
4
|
+
import { formatError, formatBytes } from "../utils.js";
|
|
5
|
+
export function registerDistributionTools(server, client, config) {
|
|
6
|
+
// --- mode_export_report ---
|
|
7
|
+
server.tool("mode_export_report", "Export a report as PDF or CSV. IMPORTANT: Before exporting, call mode_get_report_parameters to check if the report requires parameters. If it does, run it first with mode_run_report (passing the right parameters) and use the resulting run_token here — otherwise the export will use the last run's parameters which may not be what the user wants.", {
|
|
8
|
+
report_token: z.string().describe("The report token"),
|
|
9
|
+
format: z.enum(["pdf", "csv"]).describe("Export format: 'pdf' for a rendered report with charts, 'csv' for raw query data"),
|
|
10
|
+
run_token: z.string().optional().describe("The run token. If omitted, uses the last successful run."),
|
|
11
|
+
query_index: z.number().optional().describe("For CSV export: which query's results to export (0-based, default: 0). Ignored for PDF."),
|
|
12
|
+
filename: z.string().optional().describe("Output filename (e.g. 'my-report.pdf' or 'data.csv'). Defaults to the report name with appropriate extension."),
|
|
13
|
+
output_dir: z.string().optional().describe("Directory to save the file. If omitted or not writable, returns a download URL (PDF) or inline data (CSV) instead."),
|
|
14
|
+
}, async ({ report_token, format, run_token, query_index, filename, output_dir }) => {
|
|
15
|
+
try {
|
|
16
|
+
// Get report details for name and fallback run token
|
|
17
|
+
const report = await client.get(`/reports/${report_token}`);
|
|
18
|
+
const reportName = report.name ?? "report";
|
|
19
|
+
const safeName = slugify(reportName);
|
|
20
|
+
let resolvedRunToken = run_token;
|
|
21
|
+
if (!resolvedRunToken) {
|
|
22
|
+
resolvedRunToken = report.last_successful_run_token;
|
|
23
|
+
if (!resolvedRunToken) {
|
|
24
|
+
return {
|
|
25
|
+
content: [{
|
|
26
|
+
type: "text",
|
|
27
|
+
text: "No successful runs found for this report. Run the report first with mode_run_report (check mode_get_report_parameters for required parameters).",
|
|
28
|
+
}],
|
|
29
|
+
isError: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (format === "pdf") {
|
|
34
|
+
return await exportPdf(client, config, report_token, resolvedRunToken, reportName, safeName, filename, output_dir);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
return await exportCsv(client, config, report_token, resolvedRunToken, reportName, safeName, query_index ?? 0, filename, output_dir);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function exportPdf(client, config, reportToken, runToken, reportName, safeName, filename, outputDir) {
|
|
46
|
+
// Trigger PDF generation with POST, then poll with GET
|
|
47
|
+
const exportPath = `/reports/${reportToken}/exports/runs/${runToken}/pdf.pdf`;
|
|
48
|
+
await client.post(exportPath);
|
|
49
|
+
// Poll until completed — progressive backoff
|
|
50
|
+
const deadline = Date.now() + config.pollTimeoutMs;
|
|
51
|
+
let exportStatus;
|
|
52
|
+
let pollInterval = config.pollIntervalMs;
|
|
53
|
+
const maxPollInterval = 15_000;
|
|
54
|
+
while (true) {
|
|
55
|
+
exportStatus = await client.get(exportPath);
|
|
56
|
+
const state = exportStatus.state;
|
|
57
|
+
if (state === "completed")
|
|
58
|
+
break;
|
|
59
|
+
if (state === "failed") {
|
|
60
|
+
const reason = exportStatus.error ?? "The report run may not support PDF export.";
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: `PDF export failed: ${reason}` }],
|
|
63
|
+
isError: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (state === "new" || state === "requested") {
|
|
67
|
+
// Still generating, continue polling
|
|
68
|
+
}
|
|
69
|
+
else if (!state || exportStatus.id === "not_found") {
|
|
70
|
+
return {
|
|
71
|
+
content: [{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: "PDF export is not available for this run. Try running the report again with mode_run_report first, then export the new run.",
|
|
74
|
+
}],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (Date.now() > deadline) {
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: `PDF export timed out after ${config.pollTimeoutMs / 1000}s. Try again later.` }],
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const waitMs = Math.min(pollInterval, deadline - Date.now());
|
|
85
|
+
if (waitMs <= 0)
|
|
86
|
+
break;
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
88
|
+
pollInterval = Math.min(pollInterval * 1.5, maxPollInterval);
|
|
89
|
+
}
|
|
90
|
+
// Build download URL
|
|
91
|
+
const downloadHref = client.getLink(exportStatus, "download");
|
|
92
|
+
const downloadPath = downloadHref
|
|
93
|
+
?? `/api/${config.workspace}/reports/${reportToken}/exports/runs/${runToken}/pdf/download.pdf`;
|
|
94
|
+
const downloadUrl = `${config.baseUrl}${downloadPath}`;
|
|
95
|
+
const outputFilename = filename ?? `${safeName}.pdf`;
|
|
96
|
+
// Download and try to save locally
|
|
97
|
+
const pdfBuffer = await client.request(downloadUrl, { accept: "application/pdf" });
|
|
98
|
+
const buf = Buffer.from(pdfBuffer);
|
|
99
|
+
const saveResult = await trySaveFile(buf, outputFilename, outputDir);
|
|
100
|
+
const lines = [`PDF export ready: ${reportName}`, ""];
|
|
101
|
+
if (saveResult.saved) {
|
|
102
|
+
lines.push(`Saved to: ${saveResult.path} (${formatBytes(saveResult.size_bytes)})`);
|
|
103
|
+
lines.push("");
|
|
104
|
+
lines.push(`Download URL: ${downloadUrl}`);
|
|
105
|
+
}
|
|
106
|
+
else if (outputDir) {
|
|
107
|
+
lines.push(`Save failed: Could not write to ${outputDir}`);
|
|
108
|
+
lines.push("");
|
|
109
|
+
lines.push(`Download URL: ${downloadUrl}`);
|
|
110
|
+
lines.push(`Download command: curl -L -u "$MODE_API_TOKEN:$MODE_API_SECRET" -o "${outputFilename}" "${downloadUrl}"`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
lines.push(`Download URL: ${downloadUrl}`);
|
|
114
|
+
lines.push(`Download command: curl -L -u "$MODE_API_TOKEN:$MODE_API_SECRET" -o "${outputFilename}" "${downloadUrl}"`);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text", text: truncateOutput(lines.join("\n")) }],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
async function exportCsv(client, _config, reportToken, runToken, reportName, safeName, queryIndex, filename, outputDir) {
|
|
121
|
+
// Get query runs for this report run
|
|
122
|
+
const qrResp = await client.get(`/reports/${reportToken}/runs/${runToken}/query_runs`);
|
|
123
|
+
const queryRuns = client.getEmbedded(qrResp, "query_runs");
|
|
124
|
+
if (queryRuns.length === 0) {
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: "No query runs found for this report run." }],
|
|
127
|
+
isError: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (queryIndex < 0 || queryIndex >= queryRuns.length) {
|
|
131
|
+
return {
|
|
132
|
+
content: [{
|
|
133
|
+
type: "text",
|
|
134
|
+
text: `query_index ${queryIndex} is out of range. This report has ${queryRuns.length} queries (0-${queryRuns.length - 1}). Use mode_list_queries to see available queries.`,
|
|
135
|
+
}],
|
|
136
|
+
isError: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const queryRun = queryRuns[queryIndex];
|
|
140
|
+
const qrToken = queryRun.token;
|
|
141
|
+
const queryName = queryRun.query_name ?? `Query ${queryIndex}`;
|
|
142
|
+
// Fetch CSV data
|
|
143
|
+
const csv = await client.get(`/reports/${reportToken}/runs/${runToken}/query_runs/${qrToken}/results/content.csv`, { accept: "text/csv" });
|
|
144
|
+
const cleanCsv = csv.replace(/\r\n/g, "\n").trim();
|
|
145
|
+
if (!cleanCsv) {
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text: `Query "${queryName}" returned no data.` }],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const rowCount = cleanCsv.split("\n").length - 1;
|
|
151
|
+
const outputFilename = filename ?? `${safeName}.csv`;
|
|
152
|
+
const saveResult = await trySaveFile(cleanCsv, outputFilename, outputDir);
|
|
153
|
+
const lines = [`CSV export ready: ${reportName}`, `Query: ${queryName}`, `Rows: ${rowCount}`, ""];
|
|
154
|
+
if (saveResult.saved) {
|
|
155
|
+
lines.push(`Saved to: ${saveResult.path} (${formatBytes(saveResult.size_bytes)})`);
|
|
156
|
+
}
|
|
157
|
+
else if (outputDir) {
|
|
158
|
+
lines.push(`Save failed: Could not write to ${outputDir}`);
|
|
159
|
+
lines.push(`The CSV data (${rowCount} rows) is too large to display inline. Use mode_get_query_results to view a subset.`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// No output_dir — show a preview if small enough, otherwise suggest saving
|
|
163
|
+
if (cleanCsv.length < 5000) {
|
|
164
|
+
lines.push("```csv");
|
|
165
|
+
lines.push(cleanCsv);
|
|
166
|
+
lines.push("```");
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
lines.push(`The CSV is ${formatBytes(cleanCsv.length)} — provide an output_dir to save it to disk, or use mode_get_query_results to view a subset.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: "text", text: truncateOutput(lines.join("\n")) }],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ModeClient } from "../mode-client.js";
|
|
3
|
+
import { ModeConfig } from "../config.js";
|
|
4
|
+
export declare function registerManagementTools(server: McpServer, client: ModeClient, _config: ModeConfig): void;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { truncateOutput } from "../truncate.js";
|
|
3
|
+
import { formatError, formatDate } from "../utils.js";
|
|
4
|
+
export function registerManagementTools(server, client, _config) {
|
|
5
|
+
// --- mode_list_spaces ---
|
|
6
|
+
server.tool("mode_list_spaces", "List all spaces (collections) in the Mode workspace. Spaces organize reports into groups.", {
|
|
7
|
+
filter: z.string().optional().describe("Filter: 'all' (default) or 'custom' (non-personal spaces only)"),
|
|
8
|
+
}, async ({ filter }) => {
|
|
9
|
+
try {
|
|
10
|
+
const params = { filter: filter ?? "all" };
|
|
11
|
+
const resp = await client.get("/spaces", { params });
|
|
12
|
+
const spaces = client.getEmbedded(resp, "spaces");
|
|
13
|
+
if (spaces.length === 0) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: "No spaces found in this workspace. The workspace may be empty or your API token may not have access to any spaces." }],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const lines = [`Found ${spaces.length} ${spaces.length === 1 ? "space" : "spaces"}:`, ""];
|
|
19
|
+
spaces.forEach((s, i) => {
|
|
20
|
+
const name = s.name ?? "Untitled";
|
|
21
|
+
const token = s.token ?? "unknown";
|
|
22
|
+
const spaceType = s.space_type ?? "unknown";
|
|
23
|
+
const restricted = s.restricted ? " (restricted)" : "";
|
|
24
|
+
lines.push(`${i + 1}. ${name} (token: ${token}) — ${spaceType}${restricted}`);
|
|
25
|
+
});
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push("Next steps: Use mode_list_reports with a space_token to see reports in a space.");
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: truncateOutput(lines.join("\n")) }],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
// --- mode_list_data_sources ---
|
|
37
|
+
server.tool("mode_list_data_sources", "List all connected data sources (databases) in the Mode workspace with connection details and status.", {}, async () => {
|
|
38
|
+
try {
|
|
39
|
+
const resp = await client.get("/data_sources");
|
|
40
|
+
const sources = client.getEmbedded(resp, "data_sources");
|
|
41
|
+
if (sources.length === 0) {
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text", text: "No data sources found. The workspace may not have any databases connected." }],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const lines = [`Found ${sources.length} data ${sources.length === 1 ? "source" : "sources"}:`, ""];
|
|
47
|
+
sources.forEach((ds, i) => {
|
|
48
|
+
const name = ds.name ?? "Untitled";
|
|
49
|
+
const token = ds.token ?? "unknown";
|
|
50
|
+
lines.push(`${i + 1}. ${name} (token: ${token})`);
|
|
51
|
+
const details = [];
|
|
52
|
+
if (ds.adapter)
|
|
53
|
+
details.push(`Adapter: ${ds.adapter}`);
|
|
54
|
+
if (ds.host)
|
|
55
|
+
details.push(`Host: ${ds.host}`);
|
|
56
|
+
if (ds.database)
|
|
57
|
+
details.push(`Database: ${ds.database}`);
|
|
58
|
+
if (details.length > 0) {
|
|
59
|
+
lines.push(` ${details.join(" | ")}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push("");
|
|
62
|
+
});
|
|
63
|
+
lines.push("");
|
|
64
|
+
lines.push("Next steps: Use mode_get_data_source with a token for detailed connection info.");
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
// --- mode_list_definitions ---
|
|
74
|
+
server.tool("mode_list_definitions", "List saved SQL definitions (reusable snippets/macros) in the Mode workspace.", {}, async () => {
|
|
75
|
+
try {
|
|
76
|
+
const resp = await client.get("/definitions");
|
|
77
|
+
const definitions = client.getEmbedded(resp, "definitions");
|
|
78
|
+
if (definitions.length === 0) {
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: "No SQL definitions found in this workspace." }],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const lines = [`Found ${definitions.length} ${definitions.length === 1 ? "definition" : "definitions"}:`, ""];
|
|
84
|
+
definitions.forEach((d, i) => {
|
|
85
|
+
const name = d.name ?? "Untitled";
|
|
86
|
+
const token = d.token ?? "unknown";
|
|
87
|
+
lines.push(`${i + 1}. ${name} (token: ${token})`);
|
|
88
|
+
if (d.description) {
|
|
89
|
+
lines.push(` ${d.description}`);
|
|
90
|
+
}
|
|
91
|
+
const details = [];
|
|
92
|
+
if (d.data_source_id)
|
|
93
|
+
details.push(`Data source ID: ${d.data_source_id}`);
|
|
94
|
+
if (d.updated_at) {
|
|
95
|
+
details.push(`Updated: ${formatDate(d.updated_at)}`);
|
|
96
|
+
}
|
|
97
|
+
if (details.length > 0) {
|
|
98
|
+
lines.push(` ${details.join(" | ")}`);
|
|
99
|
+
}
|
|
100
|
+
if (d.source) {
|
|
101
|
+
const src = String(d.source);
|
|
102
|
+
const preview = src.length > 200 ? src.slice(0, 200) + "..." : src;
|
|
103
|
+
lines.push(` SQL: ${preview}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push("");
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// --- mode_get_data_source ---
|
|
116
|
+
server.tool("mode_get_data_source", "Get detailed information about a specific data source (database connection), including adapter, host, port, SSL, and creation date.", {
|
|
117
|
+
data_source_token: z.string().describe("The data source token"),
|
|
118
|
+
}, async ({ data_source_token }) => {
|
|
119
|
+
try {
|
|
120
|
+
const ds = await client.get(`/data_sources/${data_source_token}`);
|
|
121
|
+
const lines = [
|
|
122
|
+
`Data source: ${ds.name ?? "Untitled"}`,
|
|
123
|
+
`Token: ${ds.token}`,
|
|
124
|
+
];
|
|
125
|
+
if (ds.adapter)
|
|
126
|
+
lines.push(`Adapter: ${ds.adapter}`);
|
|
127
|
+
if (ds.host)
|
|
128
|
+
lines.push(`Host: ${ds.host}`);
|
|
129
|
+
if (ds.port)
|
|
130
|
+
lines.push(`Port: ${ds.port}`);
|
|
131
|
+
if (ds.database)
|
|
132
|
+
lines.push(`Database: ${ds.database}`);
|
|
133
|
+
if (ds.ssl !== undefined)
|
|
134
|
+
lines.push(`SSL: ${ds.ssl ? "yes" : "no"}`);
|
|
135
|
+
if (ds.provider)
|
|
136
|
+
lines.push(`Provider: ${ds.provider}`);
|
|
137
|
+
lines.push(`Created: ${formatDate(ds.created_at)}`);
|
|
138
|
+
return { content: [{ type: "text", text: truncateOutput(lines.join("\n")) }] };
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
// --- mode_list_report_schedules ---
|
|
145
|
+
server.tool("mode_list_report_schedules", "List schedules configured for a Mode report. Shows when reports are set to run automatically.", {
|
|
146
|
+
report_token: z.string().describe("The report token"),
|
|
147
|
+
}, async ({ report_token }) => {
|
|
148
|
+
try {
|
|
149
|
+
const resp = await client.get(`/reports/${report_token}/schedules`);
|
|
150
|
+
const schedules = client.getEmbedded(resp, "report_schedules");
|
|
151
|
+
if (schedules.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text", text: `No schedules found for report ${report_token}. The report is not set to run automatically.` }],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const lines = [`Found ${schedules.length} ${schedules.length === 1 ? "schedule" : "schedules"}:`, ""];
|
|
157
|
+
schedules.forEach((s, i) => {
|
|
158
|
+
const name = s.name ?? `Schedule ${i + 1}`;
|
|
159
|
+
const token = s.token ?? "unknown";
|
|
160
|
+
lines.push(`${i + 1}. ${name} (token: ${token})`);
|
|
161
|
+
if (s.next_scheduled_run)
|
|
162
|
+
lines.push(` Next run: ${formatDate(s.next_scheduled_run)}`);
|
|
163
|
+
if (s.last_run_at)
|
|
164
|
+
lines.push(` Last run: ${formatDate(s.last_run_at)}`);
|
|
165
|
+
if (s.subscriber_count !== undefined)
|
|
166
|
+
lines.push(` Subscribers: ${s.subscriber_count}`);
|
|
167
|
+
lines.push("");
|
|
168
|
+
});
|
|
169
|
+
return { content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }] };
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
// --- mode_list_report_subscriptions ---
|
|
176
|
+
server.tool("mode_list_report_subscriptions", "List email subscriptions for a Mode report. Shows who receives scheduled report deliveries and in what format.", {
|
|
177
|
+
report_token: z.string().describe("The report token"),
|
|
178
|
+
}, async ({ report_token }) => {
|
|
179
|
+
try {
|
|
180
|
+
const resp = await client.get(`/reports/${report_token}/subscriptions`);
|
|
181
|
+
const subs = client.getEmbedded(resp, "report_subscriptions");
|
|
182
|
+
if (subs.length === 0) {
|
|
183
|
+
return {
|
|
184
|
+
content: [{ type: "text", text: `No subscriptions found for report ${report_token}.` }],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const lines = [`Found ${subs.length} ${subs.length === 1 ? "subscription" : "subscriptions"}:`, ""];
|
|
188
|
+
subs.forEach((sub, i) => {
|
|
189
|
+
const token = sub.token ?? "unknown";
|
|
190
|
+
lines.push(`${i + 1}. Subscription ${token}`);
|
|
191
|
+
const details = [];
|
|
192
|
+
if (sub.csv !== undefined)
|
|
193
|
+
details.push(`CSV: ${sub.csv ? "yes" : "no"}`);
|
|
194
|
+
if (sub.pdf !== undefined)
|
|
195
|
+
details.push(`PDF: ${sub.pdf ? "yes" : "no"}`);
|
|
196
|
+
if (sub.data_previews !== undefined)
|
|
197
|
+
details.push(`Data previews: ${sub.data_previews ? "yes" : "no"}`);
|
|
198
|
+
if (details.length > 0) {
|
|
199
|
+
lines.push(` Delivery: ${details.join(" | ")}`);
|
|
200
|
+
}
|
|
201
|
+
if (sub.user) {
|
|
202
|
+
const user = sub.user;
|
|
203
|
+
lines.push(` User: ${user.name ?? user.username ?? "unknown"}`);
|
|
204
|
+
}
|
|
205
|
+
lines.push("");
|
|
206
|
+
});
|
|
207
|
+
return { content: [{ type: "text", text: truncateOutput(lines.join("\n").trimEnd()) }] };
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
// --- mode_get_audit_logs ---
|
|
214
|
+
server.tool("mode_get_audit_logs", "Retrieve audit logs for the workspace. Shows who did what and when. Requires admin permissions.", {
|
|
215
|
+
start_timestamp: z.string().describe("Start timestamp (ISO 8601, e.g. '2024-01-01T00:00:00Z')"),
|
|
216
|
+
end_timestamp: z.string().describe("End timestamp (ISO 8601, e.g. '2024-01-31T23:59:59Z')"),
|
|
217
|
+
action: z.string().optional().describe("Filter by action type"),
|
|
218
|
+
entity_type: z.string().optional().describe("Filter by entity type"),
|
|
219
|
+
username: z.string().optional().describe("Filter by username"),
|
|
220
|
+
}, async ({ start_timestamp, end_timestamp, action, entity_type, username }) => {
|
|
221
|
+
try {
|
|
222
|
+
const params = {
|
|
223
|
+
start_timestamp: start_timestamp,
|
|
224
|
+
end_timestamp: end_timestamp,
|
|
225
|
+
};
|
|
226
|
+
if (action)
|
|
227
|
+
params.action = action;
|
|
228
|
+
if (entity_type)
|
|
229
|
+
params.entity_type = entity_type;
|
|
230
|
+
if (username)
|
|
231
|
+
params.username = username;
|
|
232
|
+
const resp = await client.get("/audit_logs", { params });
|
|
233
|
+
// Audit logs may not follow standard HAL+JSON — try embedded first, then fallback
|
|
234
|
+
let events = client.getEmbedded(resp, "audit_events");
|
|
235
|
+
if (events.length === 0 && Array.isArray(resp.audit_logs)) {
|
|
236
|
+
events = resp.audit_logs;
|
|
237
|
+
}
|
|
238
|
+
if (events.length === 0) {
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text", text: "No audit log events found for the specified time range and filters." }],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const lines = [`Found ${events.length} audit ${events.length === 1 ? "event" : "events"}:`, ""];
|
|
244
|
+
events.forEach((ev, i) => {
|
|
245
|
+
const eventAction = ev.action ?? "unknown";
|
|
246
|
+
const eventUser = ev.username ?? ev.user ?? "unknown";
|
|
247
|
+
const eventEntity = ev.entity_type ?? "";
|
|
248
|
+
const eventTime = formatDate(ev.timestamp ?? ev.created_at);
|
|
249
|
+
lines.push(`${i + 1}. [${eventTime}] ${eventUser} — ${eventAction}${eventEntity ? ` on ${eventEntity}` : ""}`);
|
|
250
|
+
if (ev.entity_name)
|
|
251
|
+
lines.push(` Entity: ${ev.entity_name}`);
|
|
252
|
+
if (ev.details) {
|
|
253
|
+
const details = String(ev.details);
|
|
254
|
+
lines.push(` Details: ${details.length > 120 ? details.slice(0, 120) + "..." : details}`);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
return { content: [{ type: "text", text: truncateOutput(lines.join("\n")) }] };
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
return { content: [{ type: "text", text: formatError(e) }], isError: true };
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output truncation to prevent overwhelming the LLM context window.
|
|
3
|
+
* Keeps the TAIL of the output (most recent data is most relevant).
|
|
4
|
+
*/
|
|
5
|
+
export declare function setMaxOutputLength(maxLength: number): void;
|
|
6
|
+
export declare function truncateOutput(text: string, maxLength?: number): string;
|
package/dist/truncate.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output truncation to prevent overwhelming the LLM context window.
|
|
3
|
+
* Keeps the TAIL of the output (most recent data is most relevant).
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_MAX_LENGTH = 50_000;
|
|
6
|
+
let configuredMaxLength;
|
|
7
|
+
export function setMaxOutputLength(maxLength) {
|
|
8
|
+
configuredMaxLength = maxLength;
|
|
9
|
+
}
|
|
10
|
+
export function truncateOutput(text, maxLength) {
|
|
11
|
+
const limit = maxLength ?? configuredMaxLength ?? DEFAULT_MAX_LENGTH;
|
|
12
|
+
if (text.length <= limit)
|
|
13
|
+
return text;
|
|
14
|
+
const warning = `[Truncated: showing last ${limit} of ${text.length} characters]\n\n`;
|
|
15
|
+
return warning + text.slice(-limit);
|
|
16
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function formatError(e: unknown): string;
|
|
2
|
+
export declare function formatDate(d: unknown): string;
|
|
3
|
+
export declare function formatBytes(bytes: number): string;
|
|
4
|
+
/** Build a markdown table from headers and rows */
|
|
5
|
+
export declare function markdownTable(headers: string[], rows: Record<string, string>[]): string;
|
|
6
|
+
export declare function parseCSVLine(line: string): string[];
|