salesforce-reports-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -4,19 +4,88 @@ import { z } from "zod";
4
4
  import { fail, InputError, ok, okView } from "./format.js";
5
5
  import { soqlLike, soqlString } from "./reports/api.js";
6
6
  import { combineResults } from "./reports/combine.js";
7
+ import { COMPONENT_ROWS, dashboardListSoql, dashboardUrl, latestRefresh, refreshAndWait, resolveFilters, shapeDashboard, summarizeDashboard, } from "./reports/dashboards.js";
7
8
  import { aggregateColumns, definitionShape, ReportDefinitionSchema, toReportMetadata } from "./reports/definition.js";
8
9
  import { HARD_ROW_CEILING, shapeResult } from "./reports/shape.js";
9
10
  import { reportUrl, standardFilterNotes, summarizeReport, summarizeReportType, summarizeReportTypes } from "./reports/summarize.js";
10
- import { REPORT_VIEW_URI, reportViewHtml } from "./widget/view.js";
11
+ import { DASHBOARD_VIEW_URI, dashboardViewHtml, REPORT_VIEW_URI, reportViewHtml } from "./widget/view.js";
11
12
  /**
12
13
  * The last sentence of the run_report and query_report descriptions on the local door, where a host may draw
13
14
  * the report view. The hosted door draws no card, so doorDescription (salesforce/scripts/door-tools.ts) strips it.
14
15
  */
15
- export const VIEW_HINT = " Hosts that draw MCP Apps, such as Claude Desktop, show this result as a report card above your reply; there, answer in words and do not repeat the table.";
16
+ export const VIEW_HINT = " Hosts that draw MCP Apps, such as Claude Desktop, show this result as a report card above your reply. There, lead with the one finding the card cannot show (concentration, change, risk, the next question), keep the reply short, and never repeat the card's rows, totals or a table.";
17
+ /**
18
+ * The last sentence of the run_dashboard and refresh_dashboard descriptions, where a host may draw the dashboard
19
+ * card. The dashboard tools are on the local door only, so no door strips it.
20
+ */
21
+ export const DASHBOARD_VIEW_HINT = " Hosts that draw MCP Apps, such as Claude Desktop, show this result as a dashboard card above your reply, with every component drawn as its chart. There, lead with the one finding the card cannot show (what moved, what is missing or stale, a risk, the next question), keep the reply short, and never repeat the card's numbers, components or a table.";
22
+ /**
23
+ * Add to each shaped component what the raw dashboard carries and the card needs: the source report, the first
24
+ * measure's label and type (a metric or gauge result has no groups to carry them), a metric's label and a gauge's
25
+ * range. Components are joined by id, never by index.
26
+ */
27
+ export function dashboardView(raw, shaped, baseUrl) {
28
+ const meta = new Map((raw.dashboardMetadata?.components ?? []).map((c) => [c.id, c]));
29
+ const data = new Map((raw.componentData ?? []).map((d) => [d.componentId, d]));
30
+ const components = shaped.components.map((c) => {
31
+ const m = meta.get(c.id) ?? {};
32
+ const props = m.properties ?? {};
33
+ const visual = props.visualizationProperties ?? {};
34
+ const reportId = m.reportId ?? c.result?.report.id ?? null;
35
+ const name = (props.aggregates ?? [])[0]?.name;
36
+ const info = name ? data.get(c.id)?.reportResult?.reportExtendedMetadata?.aggregateColumnInfo?.[name] : undefined;
37
+ const view = {
38
+ ...c,
39
+ sourceReport: { id: reportId, url: reportUrl(baseUrl, reportId) },
40
+ measure: name ? { name, label: info?.label ?? (name === "RowCount" ? "Record Count" : name), dataType: info?.dataType ?? (name === "RowCount" ? "int" : "number") } : null,
41
+ };
42
+ if (typeof visual.metricLabel === "string" && visual.metricLabel)
43
+ view.metricLabel = visual.metricLabel;
44
+ if (typeof visual.colorPalette === "string" && visual.colorPalette)
45
+ view.colorPalette = visual.colorPalette;
46
+ if (c.visualizationType === "Gauge") {
47
+ const breaks = ((visual.breakPoints ?? [])[0]?.breaks ?? []);
48
+ const bounds = breaks.flatMap((b) => [b.lowerBound, b.upperBound]).filter((n) => typeof n === "number");
49
+ if (bounds.length >= 2) {
50
+ const min = Math.min(...bounds);
51
+ const max = Math.max(...bounds);
52
+ const inner = breaks.slice(1).map((b) => b.lowerBound).filter((n) => typeof n === "number" && n > min && n < max);
53
+ const colors = breaks.map((b) => (typeof b.color === "string" && /^[0-9a-f]{6}$/i.test(b.color) ? `#${b.color.toLowerCase()}` : "")).filter(Boolean);
54
+ if (max > min)
55
+ view.gauge = { min, max, breakpoints: inner, colors: colors.length === inner.length + 1 ? colors : [] };
56
+ }
57
+ }
58
+ return view;
59
+ });
60
+ const palette = raw.dashboardMetadata?.colorPalette;
61
+ return { ...shaped, components, ...(typeof palette === "string" && palette ? { colorPalette: palette } : {}) };
62
+ }
16
63
  const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
17
64
  const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
18
65
  const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true };
19
66
  const reportId = z.string().regex(/^00O[A-Za-z0-9]{12,15}$/, "A report id starts with 00O.").describe("Report id, 15 or 18 characters starting with 00O. Find one with search_reports.");
67
+ const dashboardId = z
68
+ .string()
69
+ .regex(/^01Z[A-Za-z0-9]{12,15}$/, "A dashboard id starts with 01Z.")
70
+ .describe("Dashboard id, 15 or 18 characters starting with 01Z. Find one with list_dashboards.");
71
+ const dashboardInputs = {
72
+ dashboardId,
73
+ filters: z
74
+ .array(z.object({
75
+ name: z.string().min(1).describe('The dashboard filter\'s name, as describe_dashboard lists it, for example "Stage".'),
76
+ option: z.string().min(1).describe('One of that filter\'s option labels, for example "Prospecting". An option id (0IC...) works too.'),
77
+ }))
78
+ .max(3)
79
+ .optional()
80
+ .describe("Dashboard filter choices, one option per filter. Omit for the unfiltered dashboard. Salesforce keeps a separate cache for each filter option."),
81
+ maxRows: z
82
+ .number()
83
+ .int()
84
+ .min(1)
85
+ .max(HARD_ROW_CEILING)
86
+ .optional()
87
+ .describe(`Cap on detail rows per component. Default ${COMPONENT_ROWS}. Most components are charts over summary reports and return groups, not detail rows.`),
88
+ };
20
89
  const resultControls = {
21
90
  detail: z
22
91
  .boolean()
@@ -32,7 +101,7 @@ const resultControls = {
32
101
  };
33
102
  export function buildServer(reports, options) {
34
103
  const server = new McpServer({ name: "salesforce-reports", version: options.version ?? "0.1.0" }, {
35
- instructions: "Find, define, run, save and combine Salesforce reports. To answer a data question without saving anything, use query_report. To re-run an existing report, use search_reports then run_report. Save a report (create_report, optionally with fromReportId to start from a saved one) only when the user asks for one.",
104
+ instructions: "Find, define, run, save and combine Salesforce reports, and read dashboards. To answer a data question without saving anything, use query_report. To re-run an existing report, use search_reports then run_report. For a dashboard, use list_dashboards then run_dashboard, and refresh_dashboard only when its data is missing or old. Save a report (create_report, optionally with fromReportId to start from a saved one) only when the user asks for one.",
36
105
  });
37
106
  async function baseUrl() {
38
107
  try {
@@ -303,7 +372,92 @@ export function buildServer(reports, options) {
303
372
  return fail(error);
304
373
  }
305
374
  });
375
+ // ---- dashboards: read tools ----
376
+ server.registerTool("list_dashboards", {
377
+ title: "List dashboards",
378
+ description: "Find Salesforce dashboards by title or developer name, optionally within a folder. Returns up to `limit` matches (default 25), most recently modified first, each with its id, title, developer name, folder, when its data was last refreshed (null if never) and a Lightning URL; a note says when more match. Use it first when the user names a dashboard. Next, describe_dashboard to see what it shows, or run_dashboard for its numbers. For saved reports, use search_reports.",
379
+ inputSchema: {
380
+ query: z.string().optional().describe("Text to find in the dashboard title or developer name. Omit to list the most recently modified dashboards."),
381
+ folder: z.string().optional().describe('Part of a folder name, for example "Sales".'),
382
+ limit: z.number().int().min(1).max(50).optional().describe("Maximum matches. Default 25."),
383
+ },
384
+ annotations: READ,
385
+ }, async ({ query, folder, limit }) => {
386
+ try {
387
+ const cap = limit ?? 25;
388
+ const rows = await reports.soql(dashboardListSoql({ query, folder, limit: cap }));
389
+ const kept = rows.slice(0, cap);
390
+ const base = await baseUrl();
391
+ // The SOQL field DashboardResultRefreshedDate stays null after a REST refresh, so the date comes from /status.
392
+ const refreshed = [];
393
+ for (let i = 0; i < kept.length; i += 5) {
394
+ refreshed.push(...(await Promise.all(kept.slice(i, i + 5).map(async (r) => {
395
+ try {
396
+ return latestRefresh(await reports.dashboardStatus(r.Id));
397
+ }
398
+ catch {
399
+ return null;
400
+ }
401
+ }))));
402
+ }
403
+ const notes = [];
404
+ if (rows.length > cap)
405
+ notes.push(`Showing the first ${cap} matches; more dashboards match. Narrow query or folder, or raise limit (up to 50).`);
406
+ if (refreshed.some((d) => d === null))
407
+ notes.push("lastRefreshed is null for a dashboard with no data yet: run_dashboard will say so, and refresh_dashboard fills it.");
408
+ return ok({
409
+ count: kept.length,
410
+ dashboards: kept.map((r, i) => ({
411
+ id: r.Id,
412
+ title: r.Title,
413
+ developerName: r.DeveloperName,
414
+ folder: r.FolderName,
415
+ folderId: r.FolderId,
416
+ description: r.Description,
417
+ runsAs: r.Type,
418
+ lastRefreshed: refreshed[i] ?? null,
419
+ lastModifiedDate: r.LastModifiedDate,
420
+ url: dashboardUrl(base, r.Id),
421
+ })),
422
+ notes,
423
+ });
424
+ }
425
+ catch (error) {
426
+ return fail(error);
427
+ }
428
+ });
429
+ server.registerTool("describe_dashboard", {
430
+ title: "Describe a dashboard",
431
+ description: "Show what a dashboard contains without reading its data: its components in layout order (top to bottom, then left to right), each with its visualization type, header, title, source report id and name, grouping and measure, plus the dashboard's filters with their option labels and whose data it shows (the running user). Use it before run_dashboard to learn the filter names and options, or to find the saved report behind a chart and run it with run_report. Different from describe_report, which describes one saved report.",
432
+ inputSchema: { dashboardId },
433
+ annotations: READ,
434
+ }, async ({ dashboardId: id }) => {
435
+ try {
436
+ const describe = await reports.describeDashboard(id);
437
+ return ok(summarizeDashboard(describe, { baseUrl: await baseUrl(), reportNames: await reportNames(describe) }));
438
+ }
439
+ catch (error) {
440
+ return fail(error);
441
+ }
442
+ });
443
+ // run_dashboard and refresh_dashboard carry the dashboard card: every component drawn as its chart.
444
+ const DASHBOARD_VIEW = { ui: { resourceUri: DASHBOARD_VIEW_URI } };
445
+ registerAppTool(server, "run_dashboard", {
446
+ title: "Read a dashboard",
447
+ description: "Read a dashboard's numbers as Salesforce last refreshed them: per component its header, visualization type, status (data, no data, error) and a compact report result (groups and totals, the same shape run_report returns), plus the filters in words, whose data it shows and when it was refreshed. It never refreshes: a component with no data says so in notes, and notes give the data's age when it is older than a day; then offer refresh_dashboard. `filters` pick a dashboard filter option by name and label (see describe_dashboard). Use it when the user asks about a dashboard, or wants the set of figures a dashboard already gathers. Different from run_report, which runs one saved report live and can change its filters, groupings and dates for the run." + DASHBOARD_VIEW_HINT,
448
+ inputSchema: dashboardInputs,
449
+ annotations: READ,
450
+ _meta: DASHBOARD_VIEW,
451
+ }, async (args) => {
452
+ try {
453
+ return okView(await readDashboard(args));
454
+ }
455
+ catch (error) {
456
+ return fail(error);
457
+ }
458
+ });
306
459
  registerAppResource(server, "Report view", REPORT_VIEW_URI, { description: "Draws a run_report or query_report result as a report card: title, filters, group table, totals, detail rows and notes." }, async () => ({ contents: [{ uri: REPORT_VIEW_URI, mimeType: RESOURCE_MIME_TYPE, text: reportViewHtml() }] }));
460
+ registerAppResource(server, "Dashboard view", DASHBOARD_VIEW_URI, { description: "Draws a run_dashboard or refresh_dashboard result as a dashboard card: header, filters, refresh time, and every component as its chart in layout order." }, async () => ({ contents: [{ uri: DASHBOARD_VIEW_URI, mimeType: RESOURCE_MIME_TYPE, text: dashboardViewHtml() }] }));
307
461
  if (options.readOnly)
308
462
  return server;
309
463
  // ---- write tools ----
@@ -418,6 +572,20 @@ export function buildServer(reports, options) {
418
572
  return fail(error);
419
573
  }
420
574
  });
575
+ registerAppTool(server, "refresh_dashboard", {
576
+ title: "Refresh a dashboard",
577
+ description: "Refresh a dashboard in Salesforce, so every component reruns its source report now, then return the same result as run_dashboard. It waits up to 60 seconds for the refresh to finish. Salesforce refreshes a dashboard at most once a minute: a second refresh inside a minute returns the cached results with a note saying when to try again. Each filter option has its own cache, so pass the filters you want to read. Use it when run_dashboard says a component has no data or the data is old, or when the user asks for current numbers. It saves nothing, but it runs every source report and changes what other viewers see, so it counts as a write. For one figure, run_report on the source report is lighter." + DASHBOARD_VIEW_HINT,
578
+ inputSchema: dashboardInputs,
579
+ annotations: WRITE,
580
+ _meta: DASHBOARD_VIEW,
581
+ }, async (args) => {
582
+ try {
583
+ return okView(await readDashboard(args, { refresh: true }));
584
+ }
585
+ catch (error) {
586
+ return fail(error);
587
+ }
588
+ });
421
589
  if (options.allowDelete) {
422
590
  server.registerTool("delete_report", {
423
591
  title: "Delete a report",
@@ -434,6 +602,45 @@ export function buildServer(reports, options) {
434
602
  }
435
603
  });
436
604
  }
605
+ /**
606
+ * run_dashboard, and refresh_dashboard with `refresh`. Filters given by name and label are checked against
607
+ * describe before any data call, so a bad label costs no refresh.
608
+ */
609
+ async function readDashboard(input, mode = {}) {
610
+ const id = input.dashboardId;
611
+ const resolved = input.filters?.length ? resolveFilters(await reports.describeDashboard(id), input.filters) : undefined;
612
+ const notes = mode.refresh ? await refreshAndWait(reports, id, resolved?.query ?? "", () => new Date()) : [];
613
+ const results = await reports.dashboardResults(id, resolved?.query ?? "");
614
+ const filters = resolved ?? resolveFilters(results.dashboardMetadata ?? {}, undefined);
615
+ const base = await baseUrl();
616
+ const shaped = shapeDashboard(results, {
617
+ baseUrl: base,
618
+ maxRows: Math.min(input.maxRows ?? COMPONENT_ROWS, HARD_ROW_CEILING),
619
+ filterWords: filters.words,
620
+ appliedFilters: filters.applied,
621
+ now: new Date(),
622
+ });
623
+ shaped.notes.unshift(...notes);
624
+ return dashboardView(results, shaped, base);
625
+ }
626
+ /** The names of a dashboard's source reports, by id. Empty when the lookup fails. */
627
+ async function reportNames(describe) {
628
+ const ids = [...new Set((describe.components ?? []).map((c) => c.reportId).filter((x) => Boolean(x)))];
629
+ const names = new Map();
630
+ if (!ids.length)
631
+ return names;
632
+ try {
633
+ const rows = await reports.soql(`SELECT Id, Name FROM Report WHERE Id IN (${ids.map((x) => `'${soqlString(x)}'`).join(", ")})`);
634
+ for (const r of rows) {
635
+ names.set(r.Id, r.Name);
636
+ names.set(String(r.Id).slice(0, 15), r.Name);
637
+ }
638
+ }
639
+ catch {
640
+ /* names stay null: the ids still work with run_report */
641
+ }
642
+ return names;
643
+ }
437
644
  async function orgPublicFolder() {
438
645
  try {
439
646
  const org = await reports.soql("SELECT Id FROM Organization");