salesforce-reports-mcp 0.3.0 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/README.md +88 -120
- package/dist/dashboard-files.js +136 -0
- package/dist/http-auth.js +310 -0
- package/dist/http.js +94 -0
- package/dist/index.js +59 -3
- package/package.json +3 -1
- package/skills/report-runner/SKILL.md +9 -0
- package/skills/report-runner/dashboard-loader.js +76 -0
- package/skills/report-runner/reference.md +36 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "salesforce-reports",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Explore, design and deploy Salesforce reports with Claude: three skills and an MCP server over the Reports and Dashboards REST API.",
|
|
5
5
|
"author": { "name": "nelsben" },
|
|
6
6
|
"homepage": "https://github.com/nelsben/salesforce-reports-mcp",
|
package/README.md
CHANGED
|
@@ -1,52 +1,30 @@
|
|
|
1
1
|
# salesforce-reports-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/nelsben/salesforce-reports-mcp/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Ask Salesforce a question in plain words. Get the answer, the chart and the caveats.**
|
|
6
|
+
|
|
7
|
+
Claude finds, builds, runs and saves Salesforce reports, and reads your dashboards, from a server on your own machine. Nothing is installed in Salesforce and no Salesforce MCP licence is needed. It works with the Salesforce login, API access and report permissions you already have, through the Salesforce CLI.
|
|
6
8
|
|
|
7
9
|
> **You:** I'm a seller. How is our pipeline looking this fiscal year? Is there anything I should worry about, and can you show it to me by owner too?
|
|
8
10
|
>
|
|
9
11
|
> **Claude:** $3.09M across 8 open opportunities, here by stage and by owner. Worries: one owner holds 91% of the dollars, one deal alone is 29% of the pipeline, nothing is closed won this fiscal year, and one deal has no amount. Salesforce applied its default probability filter. Nothing was saved; say the word and I will save it as a report.
|
|
10
12
|
>
|
|
11
|
-
> *Claude
|
|
13
|
+
> *A recorded Claude Code run through the local MCP server, on a small Developer Edition org ([rehearsal](docs/rehearsals/runner.md)).*
|
|
12
14
|
|
|
13
15
|

|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-

|
|
18
|
-
|
|
19
|
-
*Drawn by the [render test](docs/decisions/0014-render-tests-through-a-test-host.md)'s host page from anonymised test data.*
|
|
17
|
+
## The answers are in Salesforce. Most people cannot get them out.
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
**Reports are how a company answers questions about its customers and its pipeline, and most of the people who need those answers cannot build one.**
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
claude plugin marketplace add nelsben/salesforce-reports-mcp
|
|
27
|
-
claude plugin install salesforce-reports@salesforce-reports
|
|
28
|
-
```
|
|
21
|
+
A seller wants a number, not Report Builder. An analyst wants to try three versions of a report before saving one. An admin wants a report in another org or in git. Today each of those is a ticket, a wait, or a spreadsheet export. Here each is a sentence typed to Claude.
|
|
29
22
|
|
|
30
|
-
|
|
23
|
+
## Three jobs, done by asking
|
|
31
24
|
|
|
32
|
-
|
|
25
|
+
**The person with the question gets the answer. No one waits on the one colleague who knows Report Builder.**
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
## What this repo shows
|
|
37
|
-
|
|
38
|
-
| Claim | Where to look | Evidence you can check |
|
|
39
|
-
| --- | --- | --- |
|
|
40
|
-
| **1. MCP on Salesforce without Salesforce MCP**, plus two in-org doors for when a local server is not an option | [Three ways to connect](#three-ways-to-connect) | One tool contract and result shape on every door (16 tools locally, 15 in the org); a [parity check](docs/decisions/0010-verification-by-parity.md) runs the same calls through two doors against a real org and compares them |
|
|
41
|
-
| **2. A useful feature added to Salesforce itself**, packaged the way an admin expects | [A feature inside Salesforce](#a-feature-inside-salesforce) | Three package directories; layered Apex with a CI test that fails if the layers leak; 90 Apex tests at 99% coverage |
|
|
42
|
-
| **3. Security, authentication and integration** done deliberately, per door | [Security and identity](#security-and-identity) | A different identity per door, a per-user JWT named credential with no stored secret, writes behind a custom permission, and a test that keeps org values out of the repo |
|
|
43
|
-
| **4. Product and architecture judgment**, shown in the work | [How it was built](#how-it-was-built) | Personas and [experiences](docs/experiences.md), [7 probes](docs/probes/) before building, [19 decision records](docs/decisions/README.md), [live findings](docs/design.md), [7 rehearsals](docs/rehearsals/README.md) |
|
|
44
|
-
|
|
45
|
-
## The product
|
|
46
|
-
|
|
47
|
-
**The problem.** Reports are how a Salesforce org answers questions, and they are hard to use. A seller wants a number, not Report Builder. An analyst wants to try three versions of a report before saving one. An admin wants a report in another org or in git. As of October 2026, Salesforce's own Claude skills and its standard hosted MCP servers had no report tools, and the closest open-source servers could run or create a report, but none could change, clone or combine one, or run one that was never saved.
|
|
48
|
-
|
|
49
|
-
**Three experiences.** Each is a Claude skill, and Claude picks the right one from what you say.
|
|
27
|
+
Each is a Claude skill, and Claude picks the right one from what you say.
|
|
50
28
|
|
|
51
29
|
| Skill | For | What Claude does |
|
|
52
30
|
| --- | --- | --- |
|
|
@@ -58,120 +36,110 @@ Most of what a company knows about its customers and its pipeline sits in Salesf
|
|
|
58
36
|
>
|
|
59
37
|
> **Claude:** Preview, with 8 deals under two owners. Saved as a new report in your Private Reports; the original is untouched. Link.
|
|
60
38
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
**A report card in the chat.** The card at the top is an MCP App the local server carries with each run: the headline numbers, the filters, the groups with a bar beside each amount (or, for a matrix, a heat grid), the totals, a link to open it in Salesforce and, for an unsaved report, a button that asks Claude to save it. Claude's reply then leads with what the card cannot show instead of repeating it. The card loads nothing from the network, is styled with the Lightning Design System's own tokens so it reads as Salesforce ([decision 0018](docs/decisions/0018-cards-follow-the-lightning-design-system.md)), and every heat cell passes WCAG AA contrast in light and dark. Clients that show only text, such as Claude Code, get the same result as text ([decision 0013](docs/decisions/0013-report-view-as-an-mcp-app.md), [decision 0015](docs/decisions/0015-report-card-visuals.md)).
|
|
64
|
-
|
|
65
|
-
**Dashboards, read and refreshed.** Claude can find a dashboard, describe its components and filters, read the numbers Salesforce last cached, and refresh it when the data is missing or old. A refresh counts as a write, so read-only mode leaves it out, and no tool saves a dashboard ([decision 0016](docs/decisions/0016-dashboards-read-and-refresh-only.md)). In Claude Desktop the result draws as the dashboard card above, with every component as its own chart in the dashboard's layout ([decision 0017](docs/decisions/0017-dashboard-card.md)).
|
|
66
|
-
|
|
67
|
-
## Three ways to connect
|
|
68
|
-
|
|
69
|
-
The local server is the lead: it is the whole product for anyone on Claude Code or Claude Desktop, and it needs nothing from Salesforce beyond an API session. The same tools also reach Claude two more ways, for people who cannot run a local process: a seller on claude.ai, a Flow, an Agentforce agent. Those doors live inside the org as Apex. They differ in where the code runs, who hosts it and how the user signs in.
|
|
70
|
-
|
|
71
|
-
```mermaid
|
|
72
|
-
flowchart LR
|
|
73
|
-
subgraph Laptop["Your machine"]
|
|
74
|
-
CC["Claude Code / Desktop"]
|
|
75
|
-
LS["Local MCP server<br/>(Node)"]
|
|
76
|
-
CLI["sf CLI"]
|
|
77
|
-
end
|
|
78
|
-
subgraph Cloud["claude.ai"]
|
|
79
|
-
CAI["Claude"]
|
|
80
|
-
end
|
|
81
|
-
subgraph SF["Salesforce org"]
|
|
82
|
-
GW["Hosted MCP<br/>(Salesforce gateway)"]
|
|
83
|
-
REST["ReportsApi<br/>(Apex REST)"]
|
|
84
|
-
CORE["Reports service<br/>(Apex)"]
|
|
85
|
-
API["Reports REST API"]
|
|
86
|
-
end
|
|
87
|
-
CC -- "1. stdio" --> LS -- "REST" --> API
|
|
88
|
-
CC -- "2. skills + CLI" --> CLI -- "Apex REST" --> REST
|
|
89
|
-
CAI -- "3. MCP over OAuth" --> GW --> REST
|
|
90
|
-
REST --> CORE --> API
|
|
91
|
-
```
|
|
39
|
+
## Numbers you can repeat in a meeting
|
|
92
40
|
|
|
93
|
-
|
|
94
|
-
| --- | --- | --- | --- |
|
|
95
|
-
| **Direction** | Claude reaches into Salesforce | Salesforce offers a feature; Claude calls it | Salesforce serves Claude directly |
|
|
96
|
-
| **Runs** | Node on your machine | Apex in the org | Salesforce's MCP gateway, then the same Apex |
|
|
97
|
-
| **Installed in the org** | Nothing | `force-app` | `force-app`, `mcp-app`, optionally `callout-app` |
|
|
98
|
-
| **Signs in with** | Your `sf` CLI session or a token | Your own Salesforce session | OAuth with PKCE through an External Client App |
|
|
99
|
-
| **Clients** | Claude Code, Claude Desktop, any MCP client | Claude Code and any agent with a shell; Flow, Agentforce, any HTTP client | claude.ai and any remote MCP client, with nothing to host |
|
|
100
|
-
| **Tools** | All 16, including dashboards; delete only with a flag | 11, no delete | 11, no delete |
|
|
101
|
-
| **Choose it when** | You can run a process on your machine. No org change, no Salesforce MCP | An admin wants the feature in the org, used from scripts and agents | A team wants Claude in the browser, governed by the org |
|
|
41
|
+
**Every answer says what was counted, what was filtered and what was left out, so nobody presents a number they cannot explain.**
|
|
102
42
|
|
|
103
|
-
|
|
43
|
+
Every run returns the same compact shape: the filters that were applied, including the ones Salesforce adds on its own, the groups and totals, and notes that say when rows were cut or Salesforce ignored part of the request. The [experiences page](docs/experiences.md) has the personas and the facts that shaped them. Nothing is saved or changed unless you ask, and a read-only mode removes every write.
|
|
104
44
|
|
|
105
|
-
##
|
|
45
|
+
## The answer arrives as a picture
|
|
106
46
|
|
|
107
|
-
|
|
47
|
+
**In Claude Desktop the result draws as a card in the chat: the headline, the filters, the groups and a button to save it.**
|
|
108
48
|
|
|
109
|
-
|
|
110
|
-
| --- | --- | --- |
|
|
111
|
-
| `force-app` | The Apex service, its tests, two permission sets and a custom permission | Nothing else; complete on its own |
|
|
112
|
-
| `mcp-app` | The API Catalog entry, the MCP server definition and the External Client App Claude signs in through. No code. | `force-app` |
|
|
113
|
-
| `callout-app` | The per-user named credential that lets the hosted door call the org's own API as the user. No code. | `force-app`; used by the hosted door |
|
|
49
|
+
Salesforce dashboards draw the same way, every component as its own chart.
|
|
114
50
|
|
|
115
|
-
The
|
|
51
|
+

|
|
116
52
|
|
|
117
|
-
|
|
53
|
+
*Drawn by the [render test](docs/decisions/0014-render-tests-through-a-test-host.md)'s host page from anonymised test data.*
|
|
118
54
|
|
|
119
|
-
|
|
55
|
+
Claude can find a Salesforce dashboard, read the numbers Salesforce last cached and refresh it when the data is old.
|
|
120
56
|
|
|
121
|
-
|
|
122
|
-
| --- | --- | --- |
|
|
123
|
-
| Local | The user's `sf` CLI session, refetched on expiry; never written anywhere | Whatever the user can do. `--read-only` removes every write tool; `delete_report` exists only with `--allow-delete`. |
|
|
124
|
-
| REST | The caller's own Salesforce session | Reads with `Salesforce_Reports`. Saves only with `Salesforce_Reports_Write`, checked in the service, not by hiding tools ([decision 0005](docs/decisions/0005-saves-behind-a-custom-permission.md)). |
|
|
125
|
-
| Hosted MCP | OAuth with PKCE through the External Client App `SalesforceReportsMcp`, scopes `mcp_api` and `refresh_token` only | The same permission sets. For the calls that need the org's REST API, a per-user JWT named credential mints a token for the signed-in user. |
|
|
57
|
+
## Then keep it as a Claude dashboard
|
|
126
58
|
|
|
127
|
-
|
|
59
|
+
**Ask for a dashboard, and Claude turns the report it just ran into a page you can keep and share.**
|
|
128
60
|
|
|
129
|
-
|
|
61
|
+
In one test on Claude Desktop for Windows in November 2026, a single request ran a pipeline report and built a page with four headline tiles, a chart by stage, the detail table and a note on what was counted, in under three minutes. The page is made with [Claude Data](https://support.claude.com/en/articles/17454700-get-started-with-claude-dashboards), Claude's own dashboard feature, so your Claude plan has to include it.
|
|
130
62
|
|
|
131
|
-
|
|
132
|
-
2. A probe showed that a [per-user JWT named credential](docs/probes/v0.2-phase3c.md) lets the org mint a token for the running user from a certificate it made itself. No secret is stored, no user clicks anything, and the connector's own app keeps its narrow scopes ([decision 0012](docs/decisions/0012-per-user-jwt-named-credential.md)). `npm run callout:renew` renews the certificate.
|
|
63
|
+
The page is a copy of the numbers as of the run, not a live view. Check it against the answer in the chat before you share it.
|
|
133
64
|
|
|
134
|
-
|
|
65
|
+
## What you need, and what you do not
|
|
135
66
|
|
|
136
|
-
|
|
67
|
+
**No new licence and no install in Salesforce. A person who can already run reports and use the Salesforce CLI can start asking.**
|
|
137
68
|
|
|
138
|
-
|
|
69
|
+
You need:
|
|
139
70
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
| **Build** to one contract | [design.md](docs/design.md): the input and output shapes, and the live findings where Salesforce did not behave as documented |
|
|
145
|
-
| **Verify** against a real org | 256 unit and server tests, 24 render tests that draw the report and dashboard cards in a browser, 90 Apex tests, a live end-to-end check that leaves no report behind, and a parity check across doors; `npm run verify` runs all of it against one org and ends with one table of counts |
|
|
146
|
-
| **Rehearse** the experience | [7 rehearsals](docs/rehearsals/README.md): real Claude runs of each skill on each door, with what went wrong and what changed |
|
|
71
|
+
- Claude Code or Claude Desktop. The dashboard step also needs a Claude plan that includes [Claude Data](https://support.claude.com/en/articles/17454700-get-started-with-claude-dashboards).
|
|
72
|
+
- Node.js 20 or later.
|
|
73
|
+
- The [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli), signed in to your org.
|
|
74
|
+
- A Salesforce user with API access and the Run Reports permission, plus Create and Customize Reports to save a report.
|
|
147
75
|
|
|
148
|
-
|
|
76
|
+
If your company restricts API access or the Salesforce CLI, that is the question to take to your admin.
|
|
149
77
|
|
|
150
|
-
|
|
|
78
|
+
| It does not need | Because |
|
|
151
79
|
| --- | --- |
|
|
152
|
-
|
|
|
153
|
-
|
|
|
154
|
-
|
|
|
155
|
-
|
|
|
80
|
+
| A Salesforce MCP licence | The server talks to the Reports REST API that every org already has |
|
|
81
|
+
| Anything installed in the org | It runs on your machine |
|
|
82
|
+
| A separate integration user | It signs in as you, with the Salesforce CLI session you already have |
|
|
83
|
+
| New access rules | Every call runs as you, so sharing, folder access and field-level security apply as they do in Salesforce |
|
|
84
|
+
| A stored password or key | The session is read from the CLI when needed and never written anywhere |
|
|
156
85
|
|
|
157
|
-
|
|
86
|
+
## Run it on your own org
|
|
158
87
|
|
|
159
|
-
|
|
88
|
+
**Two commands, and Claude is working on your own data.**
|
|
89
|
+
|
|
90
|
+
Run them in Claude Code, with Node 20 and the Salesforce CLI signed in:
|
|
160
91
|
|
|
161
92
|
```
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
npm test
|
|
165
|
-
npm run test:render # draw the report card in Chromium and compare screenshots
|
|
166
|
-
npm run live -- --org <alias> --allow-delete # end-to-end run against an org you can write to
|
|
167
|
-
npm run capture -- --org <alias> # refresh the test fixtures (anonymised)
|
|
93
|
+
claude plugin marketplace add nelsben/salesforce-reports-mcp
|
|
94
|
+
claude plugin install salesforce-reports@salesforce-reports
|
|
168
95
|
```
|
|
169
96
|
|
|
170
|
-
|
|
97
|
+
That installs the three skills and the MCP server. Claude uses your Salesforce CLI default org. To pick another org, start Claude Code with `SF_ORG=<alias> claude`. To remove every tool that can save or change something, add `SF_REPORTS_READ_ONLY=1` the same way.
|
|
98
|
+
|
|
99
|
+
Then ask:
|
|
100
|
+
|
|
101
|
+
- "How is our open pipeline looking by stage, and what should I worry about?"
|
|
102
|
+
- "Start from my pipeline report, group it by owner and save it as a new report."
|
|
103
|
+
- "Build me a dashboard of that to share with my team."
|
|
104
|
+
|
|
105
|
+
For Claude Desktop, [docs/claude-desktop.md](docs/claude-desktop.md) is the full recipe: the config entry with the two paths that trip people up, how to check it works, the safety flags, and what each Claude surface, including mobile, can do with a local server. For claude.ai and your phone, [From claude.ai and your phone](docs/install.md#from-claudeai-and-your-phone) runs the same server over HTTP behind a tunnel. [docs/install.md](docs/install.md#the-local-mcp-server) has every option.
|
|
106
|
+
|
|
107
|
+
## Where your data goes
|
|
108
|
+
|
|
109
|
+
**Report data goes from your Salesforce org to your machine and into your Claude conversation. The server sends it nowhere else.**
|
|
110
|
+
|
|
111
|
+
- The server runs on your machine and calls only your own Salesforce org, as you. It has no analytics.
|
|
112
|
+
- What a report returns goes into your Claude conversation, under the terms of your Claude plan.
|
|
113
|
+
- Nothing is saved or changed in Salesforce unless you ask.
|
|
114
|
+
- A Claude dashboard is a copy of the numbers in your Claude account, private until you share it. Anyone you share it with sees those numbers, whatever their Salesforce access.
|
|
115
|
+
|
|
116
|
+
[Security and identity](docs/security.md#where-your-data-goes) has the detail.
|
|
117
|
+
|
|
118
|
+
## Limits
|
|
119
|
+
|
|
120
|
+
**Know these before a number goes on a slide.**
|
|
121
|
+
|
|
122
|
+
- A Claude dashboard is a copy as of the run, not a live view.
|
|
123
|
+
- The report card draws amounts with a dollar sign in every org, whatever the org's currency.
|
|
124
|
+
- Salesforce returns at most 2,000 detail rows per run and 100 columns per report. Every answer says when rows were cut.
|
|
125
|
+
- Joined reports are not supported.
|
|
126
|
+
- Salesforce allows 500 report runs per org per hour.
|
|
127
|
+
- Salesforce adds default filters to some report types. They are listed in every answer, so check them against your question.
|
|
128
|
+
|
|
129
|
+
[The reference](docs/reference.md#limits) has the full list.
|
|
130
|
+
|
|
131
|
+
## Go deeper
|
|
132
|
+
|
|
133
|
+
| Page | What it holds |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| [Field notes](docs/field-notes.md) | What else was built and tried: the four ways to connect, the feature inside Salesforce, how it was built and checked, and why |
|
|
136
|
+
| [Security and identity](docs/security.md) | Who each call runs as, what it can change, where your data goes, and how secrets are kept out |
|
|
137
|
+
| [Reference](docs/reference.md) | The tools, the result shape, the safety rails and the limits |
|
|
138
|
+
| [Decision records](docs/decisions/README.md) | Every design choice, with what it costs and what was turned down |
|
|
171
139
|
|
|
172
|
-
##
|
|
140
|
+
## Help
|
|
173
141
|
|
|
174
|
-
|
|
142
|
+
Questions and problems go to [GitHub Issues](https://github.com/nelsben/salesforce-reports-mcp/issues).
|
|
175
143
|
|
|
176
144
|
## License
|
|
177
145
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard-files command: runs report definitions over the local door, read-only, and writes one ReportResult
|
|
3
|
+
* JSON file per report, ready to attach to a Claude Dashboard. It is the `dashboard-files` subcommand of the server
|
|
4
|
+
* binary: `salesforce-reports-mcp dashboard-files --org <alias> --defs <file.json> --out <dir>`.
|
|
5
|
+
*
|
|
6
|
+
* The defs file is a JSON object mapping a short id to query_report arguments (the definition fields at the top
|
|
7
|
+
* level). The command never uses a default org and never prints a row value, a token or the instance URL. The last
|
|
8
|
+
* line is `dashboard-files: written=<n> failed=<m>`.
|
|
9
|
+
*
|
|
10
|
+
* Importing this module has no side effects. The command runs the reports through a child server: this package's own
|
|
11
|
+
* dist/index.js, read-only, on the local door.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
18
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
20
|
+
export const USAGE = "Usage: salesforce-reports-mcp dashboard-files --org <alias> --defs <file.json> --out <dir>";
|
|
21
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
22
|
+
/** Checks the defs object. Returns the entries in file order, or the first problem, naming the id. */
|
|
23
|
+
export function validateDefs(defs) {
|
|
24
|
+
if (typeof defs !== "object" || defs === null || Array.isArray(defs)) {
|
|
25
|
+
return { error: "The defs file must be a JSON object mapping an id to query_report arguments." };
|
|
26
|
+
}
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const [id, value] of Object.entries(defs)) {
|
|
29
|
+
if (!ID_PATTERN.test(id))
|
|
30
|
+
return { error: `Bad id "${id}": use letters, digits, dash and underscore only.` };
|
|
31
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
32
|
+
return { error: `The value for id "${id}" must be an object of query_report arguments.` };
|
|
33
|
+
}
|
|
34
|
+
entries.push([id, value]);
|
|
35
|
+
}
|
|
36
|
+
return { entries };
|
|
37
|
+
}
|
|
38
|
+
/** The last line of the command. */
|
|
39
|
+
export const summaryLine = (written, failed) => `dashboard-files: written=${written} failed=${failed}`;
|
|
40
|
+
function rowCount(result) {
|
|
41
|
+
const detail = result.detail;
|
|
42
|
+
if (detail && typeof detail.rowCount === "number")
|
|
43
|
+
return detail.rowCount;
|
|
44
|
+
const groups = result.groups;
|
|
45
|
+
return Array.isArray(groups?.rows) ? groups.rows.length : 0;
|
|
46
|
+
}
|
|
47
|
+
/** Runs each definition in order, writes one file per success and always closes the client. */
|
|
48
|
+
export async function runDashboardFiles(options) {
|
|
49
|
+
const { client, defs, outDir, writer, print } = options;
|
|
50
|
+
let written = 0;
|
|
51
|
+
let failed = 0;
|
|
52
|
+
try {
|
|
53
|
+
await writer.mkdir(outDir);
|
|
54
|
+
for (const [id, args] of defs) {
|
|
55
|
+
let answer;
|
|
56
|
+
try {
|
|
57
|
+
answer = (await client.callTool({ name: "query_report", arguments: args }));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
answer = undefined;
|
|
61
|
+
}
|
|
62
|
+
const result = answer?.structuredContent;
|
|
63
|
+
if (!answer || answer.isError || typeof result !== "object" || result === null) {
|
|
64
|
+
failed++;
|
|
65
|
+
print(`${id}: failed`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
await writer.writeFile(join(outDir, `${id}.json`), JSON.stringify(result, null, 2) + "\n");
|
|
69
|
+
written++;
|
|
70
|
+
const truncated = result.detail?.truncated === true;
|
|
71
|
+
print(`${id}: rows=${rowCount(result)} truncated=${truncated}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await client.close();
|
|
76
|
+
}
|
|
77
|
+
print(summaryLine(written, failed));
|
|
78
|
+
return { written, failed };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Runs the dashboard-files command on its arguments (everything after the subcommand name). Returns the exit code:
|
|
82
|
+
* 0 when every report was written, 1 when any failed or the server could not be reached, 2 on a usage error.
|
|
83
|
+
*/
|
|
84
|
+
export async function dashboardFilesCommand(argv) {
|
|
85
|
+
let values;
|
|
86
|
+
try {
|
|
87
|
+
values = parseArgs({
|
|
88
|
+
args: argv,
|
|
89
|
+
options: { org: { type: "string" }, defs: { type: "string" }, out: { type: "string" } },
|
|
90
|
+
strict: true,
|
|
91
|
+
}).values;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
console.error(USAGE);
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
if (!values.org || !values.defs || !values.out) {
|
|
98
|
+
console.error(USAGE);
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
let raw;
|
|
102
|
+
try {
|
|
103
|
+
raw = JSON.parse(readFileSync(values.defs, "utf8"));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
console.error("The defs file could not be read as JSON.");
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
const checked = validateDefs(raw);
|
|
110
|
+
if ("error" in checked) {
|
|
111
|
+
console.error(checked.error);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
const client = new Client({ name: "dashboard-files", version: "1.0.0" });
|
|
115
|
+
const transport = new StdioClientTransport({
|
|
116
|
+
command: process.execPath,
|
|
117
|
+
args: [fileURLToPath(new URL("./index.js", import.meta.url)), "--org", values.org, "--read-only"],
|
|
118
|
+
stderr: "ignore",
|
|
119
|
+
});
|
|
120
|
+
let failed = 1;
|
|
121
|
+
try {
|
|
122
|
+
await client.connect(transport);
|
|
123
|
+
({ failed } = await runDashboardFiles({
|
|
124
|
+
client,
|
|
125
|
+
defs: checked.entries,
|
|
126
|
+
outDir: resolve(values.out),
|
|
127
|
+
writer: { mkdir: async (dir) => void (await mkdir(dir, { recursive: true })), writeFile: (path, text) => writeFile(path, text) },
|
|
128
|
+
print: (line) => console.log(line),
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
console.error("dashboard-files: the server could not be started or reached.");
|
|
133
|
+
await client.close().catch(() => undefined);
|
|
134
|
+
}
|
|
135
|
+
return failed > 0 ? 1 : 0;
|
|
136
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// The HTTP door's own OAuth 2.1 server, for one owner. It issues tokens only for this server's /mcp,
|
|
2
|
+
// and only after the owner types the passcode from SF_REPORTS_HTTP_PASSCODE on the /authorize page.
|
|
3
|
+
// Everything lives in memory: a restart signs every client out. Importing this module does nothing.
|
|
4
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { InvalidClientMetadataError, InvalidGrantError, InvalidScopeError, InvalidTargetError, InvalidTokenError, } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
6
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
7
|
+
import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
8
|
+
export const PASSCODE_ENV = "SF_REPORTS_HTTP_PASSCODE";
|
|
9
|
+
export const MIN_PASSCODE_LENGTH = 16;
|
|
10
|
+
const ACCESS_TTL_S = 60 * 60;
|
|
11
|
+
const REFRESH_TTL_S = 30 * 24 * 60 * 60;
|
|
12
|
+
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
const MAX_WRONG_PASSCODES = 10;
|
|
14
|
+
const WRONG_PASSCODE_WINDOW_MS = 15 * 60 * 1000;
|
|
15
|
+
// Hosts that may use plain http, for a public URL and for a registered redirect URI.
|
|
16
|
+
const LOOPBACK_HTTP_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
17
|
+
/** The issuer URL: https, or http on localhost or 127.0.0.1, with no path, query or fragment. */
|
|
18
|
+
export function resolvePublicUrl(raw) {
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error("--public-url must be an absolute URL, such as https://reports.example.com.");
|
|
25
|
+
}
|
|
26
|
+
// The SDK allows a plain http issuer only on these two hosts.
|
|
27
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
28
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
29
|
+
throw new Error("--public-url must use https, unless its host is localhost or 127.0.0.1.");
|
|
30
|
+
}
|
|
31
|
+
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
32
|
+
throw new Error("--public-url must be an origin only, with no path, query or fragment, such as https://reports.example.com.");
|
|
33
|
+
}
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
/** Checks the passcode without ever putting it in the message. */
|
|
37
|
+
export function checkPasscode(passcode) {
|
|
38
|
+
if (!passcode)
|
|
39
|
+
throw new Error(`${PASSCODE_ENV} must be set when --public-url turns the OAuth layer on.`);
|
|
40
|
+
if (passcode.length < MIN_PASSCODE_LENGTH)
|
|
41
|
+
throw new Error(`${PASSCODE_ENV} must be at least ${MIN_PASSCODE_LENGTH} characters.`);
|
|
42
|
+
return passcode;
|
|
43
|
+
}
|
|
44
|
+
/** Validates the options before anything starts, so a bad start fails before Salesforce is touched. */
|
|
45
|
+
export function checkOAuthOptions(options) {
|
|
46
|
+
checkPasscode(options.passcode);
|
|
47
|
+
const publicUrl = resolvePublicUrl(options.publicUrl);
|
|
48
|
+
return { publicUrl, resource: new URL("/mcp", publicUrl) };
|
|
49
|
+
}
|
|
50
|
+
const sha256 = (value) => createHash("sha256").update(value, "utf8").digest();
|
|
51
|
+
const hashKey = (value) => sha256(value).toString("base64url");
|
|
52
|
+
const newSecret = () => randomBytes(32).toString("base64url");
|
|
53
|
+
// A resource compared as a string, without its fragment and one trailing slash, as the SDK's bearer check does.
|
|
54
|
+
function comparable(value) {
|
|
55
|
+
const text = String(value);
|
|
56
|
+
const hash = text.indexOf("#");
|
|
57
|
+
return (hash === -1 ? text : text.slice(0, hash)).replace(/\/$/, "");
|
|
58
|
+
}
|
|
59
|
+
function allowedRedirect(uri) {
|
|
60
|
+
let url;
|
|
61
|
+
try {
|
|
62
|
+
url = new URL(uri);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (url.hash)
|
|
68
|
+
return false;
|
|
69
|
+
return url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HTTP_HOSTS.includes(url.hostname));
|
|
70
|
+
}
|
|
71
|
+
const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
72
|
+
/** An in-memory OAuth server for one owner. Codes and tokens are kept only as SHA-256 hashes. */
|
|
73
|
+
export class OwnerOAuthProvider {
|
|
74
|
+
resource;
|
|
75
|
+
now;
|
|
76
|
+
passcodeHash;
|
|
77
|
+
clients = new Map();
|
|
78
|
+
codes = new Map();
|
|
79
|
+
grants = new Map();
|
|
80
|
+
wrongPasscodes = [];
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.resource = checkOAuthOptions(options).resource;
|
|
83
|
+
this.passcodeHash = sha256(options.passcode);
|
|
84
|
+
this.now = options.now ?? Date.now;
|
|
85
|
+
}
|
|
86
|
+
clientsStore = {
|
|
87
|
+
getClient: (clientId) => this.clients.get(clientId),
|
|
88
|
+
registerClient: (client) => {
|
|
89
|
+
const full = client;
|
|
90
|
+
if (full.redirect_uris.length === 0 || !full.redirect_uris.every(allowedRedirect)) {
|
|
91
|
+
throw new InvalidClientMetadataError("Each redirect URI must use https, or http on a loopback host.");
|
|
92
|
+
}
|
|
93
|
+
this.clients.set(full.client_id, full);
|
|
94
|
+
return full;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
async authorize(client, params, res) {
|
|
98
|
+
if (params.resource && comparable(params.resource) !== comparable(this.resource)) {
|
|
99
|
+
throw new InvalidTargetError(`This server issues tokens only for ${this.resource.href}.`);
|
|
100
|
+
}
|
|
101
|
+
const req = res.req;
|
|
102
|
+
if (req.method !== "POST") {
|
|
103
|
+
this.page(res, 200, client, params);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const now = this.now();
|
|
107
|
+
this.wrongPasscodes = this.wrongPasscodes.filter((t) => now - t < WRONG_PASSCODE_WINDOW_MS);
|
|
108
|
+
if (this.wrongPasscodes.length >= MAX_WRONG_PASSCODES) {
|
|
109
|
+
this.page(res, 429, client, params, "Too many wrong passcodes. Wait 15 minutes, then try again.");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const given = typeof req.body?.passcode === "string" ? req.body.passcode : "";
|
|
113
|
+
if (!timingSafeEqual(sha256(given), this.passcodeHash)) {
|
|
114
|
+
this.wrongPasscodes.push(now);
|
|
115
|
+
this.page(res, 403, client, params, "That passcode is not right.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.prune(now);
|
|
119
|
+
const code = newSecret();
|
|
120
|
+
this.codes.set(hashKey(code), {
|
|
121
|
+
clientId: client.client_id,
|
|
122
|
+
redirectUri: params.redirectUri,
|
|
123
|
+
challenge: params.codeChallenge,
|
|
124
|
+
resource: this.resource.href,
|
|
125
|
+
scopes: params.scopes ?? [],
|
|
126
|
+
expiresAt: now + CODE_TTL_MS,
|
|
127
|
+
challenged: false,
|
|
128
|
+
});
|
|
129
|
+
const target = new URL(params.redirectUri);
|
|
130
|
+
target.searchParams.set("code", code);
|
|
131
|
+
if (params.state !== undefined)
|
|
132
|
+
target.searchParams.set("state", params.state);
|
|
133
|
+
res.redirect(302, target.href);
|
|
134
|
+
}
|
|
135
|
+
// The token handler asks for the challenge, checks the verifier, then exchanges. Asking spends the code,
|
|
136
|
+
// so a wrong verifier burns it and a second try with the same code fails.
|
|
137
|
+
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
138
|
+
const key = hashKey(authorizationCode);
|
|
139
|
+
const entry = this.codes.get(key);
|
|
140
|
+
if (!entry || entry.challenged || entry.clientId !== client.client_id || entry.expiresAt <= this.now()) {
|
|
141
|
+
this.codes.delete(key);
|
|
142
|
+
throw new InvalidGrantError("The authorization code is invalid, used or expired.");
|
|
143
|
+
}
|
|
144
|
+
entry.challenged = true;
|
|
145
|
+
return entry.challenge;
|
|
146
|
+
}
|
|
147
|
+
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
|
|
148
|
+
const key = hashKey(authorizationCode);
|
|
149
|
+
const entry = this.codes.get(key);
|
|
150
|
+
this.codes.delete(key);
|
|
151
|
+
if (!entry || !entry.challenged || entry.clientId !== client.client_id || entry.expiresAt <= this.now()) {
|
|
152
|
+
throw new InvalidGrantError("The authorization code is invalid, used or expired.");
|
|
153
|
+
}
|
|
154
|
+
if (redirectUri !== undefined && redirectUri !== entry.redirectUri) {
|
|
155
|
+
throw new InvalidGrantError("redirect_uri does not match the authorization request.");
|
|
156
|
+
}
|
|
157
|
+
if (resource && comparable(resource) !== comparable(entry.resource)) {
|
|
158
|
+
throw new InvalidTargetError("resource does not match the authorization request.");
|
|
159
|
+
}
|
|
160
|
+
return this.issueTokens(client.client_id, new URL(entry.resource), entry.scopes);
|
|
161
|
+
}
|
|
162
|
+
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
163
|
+
const key = hashKey(refreshToken);
|
|
164
|
+
const grant = this.grants.get(key);
|
|
165
|
+
if (!grant || grant.kind !== "refresh" || grant.clientId !== client.client_id || grant.expiresAt <= this.now()) {
|
|
166
|
+
if (grant && grant.expiresAt <= this.now())
|
|
167
|
+
this.grants.delete(key);
|
|
168
|
+
throw new InvalidGrantError("The refresh token is invalid, used or expired.");
|
|
169
|
+
}
|
|
170
|
+
if (resource && comparable(resource) !== comparable(grant.resource)) {
|
|
171
|
+
throw new InvalidTargetError("resource does not match the refresh token.");
|
|
172
|
+
}
|
|
173
|
+
if (scopes && !scopes.every((s) => grant.scopes.includes(s))) {
|
|
174
|
+
throw new InvalidScopeError("A refresh cannot widen the scopes.");
|
|
175
|
+
}
|
|
176
|
+
// Rotate: the old refresh token stops working.
|
|
177
|
+
this.grants.delete(key);
|
|
178
|
+
return this.issueTokens(client.client_id, new URL(grant.resource), scopes ?? grant.scopes);
|
|
179
|
+
}
|
|
180
|
+
async verifyAccessToken(token) {
|
|
181
|
+
const key = hashKey(token);
|
|
182
|
+
const grant = this.grants.get(key);
|
|
183
|
+
if (!grant || grant.kind !== "access")
|
|
184
|
+
throw new InvalidTokenError("The access token is not valid.");
|
|
185
|
+
if (grant.expiresAt <= this.now()) {
|
|
186
|
+
this.grants.delete(key);
|
|
187
|
+
throw new InvalidTokenError("The access token has expired.");
|
|
188
|
+
}
|
|
189
|
+
if (comparable(grant.resource) !== comparable(this.resource)) {
|
|
190
|
+
throw new InvalidTokenError("The access token was not issued for this resource.");
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
token,
|
|
194
|
+
clientId: grant.clientId,
|
|
195
|
+
scopes: grant.scopes,
|
|
196
|
+
expiresAt: Math.floor(grant.expiresAt / 1000),
|
|
197
|
+
resource: new URL(grant.resource),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
async revokeToken(client, request) {
|
|
201
|
+
const key = hashKey(request.token);
|
|
202
|
+
if (this.grants.get(key)?.clientId === client.client_id)
|
|
203
|
+
this.grants.delete(key);
|
|
204
|
+
}
|
|
205
|
+
/** Issues an access and refresh token pair. Only the hashes are kept. */
|
|
206
|
+
issueTokens(clientId, resource, scopes) {
|
|
207
|
+
const now = this.now();
|
|
208
|
+
this.prune(now);
|
|
209
|
+
const accessToken = newSecret();
|
|
210
|
+
const refreshToken = newSecret();
|
|
211
|
+
this.grants.set(hashKey(accessToken), { kind: "access", clientId, resource: resource.href, scopes, expiresAt: now + ACCESS_TTL_S * 1000 });
|
|
212
|
+
this.grants.set(hashKey(refreshToken), { kind: "refresh", clientId, resource: resource.href, scopes, expiresAt: now + REFRESH_TTL_S * 1000 });
|
|
213
|
+
return {
|
|
214
|
+
access_token: accessToken,
|
|
215
|
+
token_type: "Bearer",
|
|
216
|
+
expires_in: ACCESS_TTL_S,
|
|
217
|
+
refresh_token: refreshToken,
|
|
218
|
+
...(scopes.length ? { scope: scopes.join(" ") } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
prune(now) {
|
|
222
|
+
for (const [key, code] of this.codes)
|
|
223
|
+
if (code.expiresAt <= now)
|
|
224
|
+
this.codes.delete(key);
|
|
225
|
+
for (const [key, grant] of this.grants)
|
|
226
|
+
if (grant.expiresAt <= now)
|
|
227
|
+
this.grants.delete(key);
|
|
228
|
+
}
|
|
229
|
+
// One self-contained page: no script, no external asset. The passcode is never written back into it.
|
|
230
|
+
page(res, status, client, params, error) {
|
|
231
|
+
const name = escapeHtml(client.client_name || client.client_id);
|
|
232
|
+
const returnHost = escapeHtml(new URL(params.redirectUri).host);
|
|
233
|
+
const hidden = {
|
|
234
|
+
response_type: "code",
|
|
235
|
+
client_id: client.client_id,
|
|
236
|
+
redirect_uri: params.redirectUri,
|
|
237
|
+
code_challenge: params.codeChallenge,
|
|
238
|
+
code_challenge_method: "S256",
|
|
239
|
+
state: params.state,
|
|
240
|
+
scope: params.scopes?.length ? params.scopes.join(" ") : undefined,
|
|
241
|
+
resource: params.resource?.href,
|
|
242
|
+
};
|
|
243
|
+
const fields = Object.entries(hidden)
|
|
244
|
+
.filter((entry) => entry[1] !== undefined)
|
|
245
|
+
.map(([k, v]) => `<input type="hidden" name="${k}" value="${escapeHtml(v)}">`)
|
|
246
|
+
.join("\n ");
|
|
247
|
+
const html = `<!doctype html>
|
|
248
|
+
<html lang="en">
|
|
249
|
+
<head>
|
|
250
|
+
<meta charset="utf-8">
|
|
251
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
252
|
+
<title>Sign in to Salesforce reports</title>
|
|
253
|
+
<style>
|
|
254
|
+
:root { color-scheme: light dark; }
|
|
255
|
+
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: Canvas; color: CanvasText; }
|
|
256
|
+
main { max-width: 28rem; margin: 3rem auto; padding: 0 1rem; }
|
|
257
|
+
h1 { font-size: 1.4rem; }
|
|
258
|
+
.error { color: #b3261e; font-weight: 600; }
|
|
259
|
+
label { display: block; margin: 1.25rem 0 .25rem; font-weight: 600; }
|
|
260
|
+
input[type=password] { box-sizing: border-box; width: 100%; padding: .6rem; font: inherit; }
|
|
261
|
+
button { margin-top: 1rem; padding: .6rem 1.2rem; font: inherit; cursor: pointer; }
|
|
262
|
+
.small { font-size: .875rem; opacity: .8; }
|
|
263
|
+
</style>
|
|
264
|
+
</head>
|
|
265
|
+
<body>
|
|
266
|
+
<main>
|
|
267
|
+
<h1>Allow ${name} to use Salesforce reports?</h1>
|
|
268
|
+
<p><strong>${name}</strong> asks to connect to this server. Approving lets it run this server's tools as the Salesforce user this server is signed in as.</p>
|
|
269
|
+
<p class="small">After you approve, you go back to ${returnHost}. Only approve a client you just added yourself.</p>
|
|
270
|
+
${error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : ""}
|
|
271
|
+
<form method="post" action="/authorize">
|
|
272
|
+
${fields}
|
|
273
|
+
<label for="passcode">Passcode</label>
|
|
274
|
+
<input id="passcode" name="passcode" type="password" autocomplete="current-password" required autofocus>
|
|
275
|
+
<button type="submit">Approve</button>
|
|
276
|
+
</form>
|
|
277
|
+
</main>
|
|
278
|
+
</body>
|
|
279
|
+
</html>
|
|
280
|
+
`;
|
|
281
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'");
|
|
282
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
283
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
284
|
+
res.status(status).type("html").send(html);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export function createOwnerAuth(options) {
|
|
288
|
+
const { publicUrl, resource } = checkOAuthOptions(options);
|
|
289
|
+
const provider = new OwnerOAuthProvider(options);
|
|
290
|
+
// Behind a tunnel every request arrives from loopback with an X-Forwarded-For header. The SDK's limits then
|
|
291
|
+
// count all callers together, which suits one owner; skip the check that would print a stack trace for it.
|
|
292
|
+
const rateLimit = { validate: { xForwardedForHeader: false } };
|
|
293
|
+
const router = mcpAuthRouter({
|
|
294
|
+
provider,
|
|
295
|
+
issuerUrl: publicUrl,
|
|
296
|
+
resourceServerUrl: resource,
|
|
297
|
+
resourceName: "Salesforce reports",
|
|
298
|
+
authorizationOptions: { rateLimit },
|
|
299
|
+
clientRegistrationOptions: { rateLimit },
|
|
300
|
+
tokenOptions: { rateLimit },
|
|
301
|
+
revocationOptions: { rateLimit },
|
|
302
|
+
});
|
|
303
|
+
const bearer = requireBearerAuth({
|
|
304
|
+
verifier: provider,
|
|
305
|
+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resource),
|
|
306
|
+
expectedResource: resource,
|
|
307
|
+
});
|
|
308
|
+
const allowedHosts = [...new Set([publicUrl.hostname, ...LOOPBACK_HTTP_HOSTS])];
|
|
309
|
+
return { provider, router, bearer, allowedHosts };
|
|
310
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { createOwnerAuth } from "./http-auth.js";
|
|
6
|
+
export const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"];
|
|
7
|
+
export function assertLoopbackHost(host) {
|
|
8
|
+
if (!LOOPBACK_HOSTS.includes(host)) {
|
|
9
|
+
throw new Error(`--host ${host} is not loopback. A non-loopback host needs the OAuth layer: set --public-url and SF_REPORTS_HTTP_PASSCODE.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// Express request and response types are not imported here; the SDK's own types are enough.
|
|
13
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
14
|
+
export async function startHttpServer(options) {
|
|
15
|
+
const auth = options.oauth ? createOwnerAuth(options.oauth) : undefined;
|
|
16
|
+
if (!auth)
|
|
17
|
+
assertLoopbackHost(options.host);
|
|
18
|
+
const app = createMcpExpressApp(auth ? { host: options.host, allowedHosts: auth.allowedHosts } : { host: options.host });
|
|
19
|
+
if (auth)
|
|
20
|
+
app.use(auth.router);
|
|
21
|
+
const guard = auth ? [auth.bearer] : [];
|
|
22
|
+
const sessions = new Map();
|
|
23
|
+
const reject = (res, status, message) => {
|
|
24
|
+
res.status(status).json({ jsonrpc: "2.0", error: { code: -32000, message }, id: null });
|
|
25
|
+
};
|
|
26
|
+
app.post("/mcp", ...guard, async (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
29
|
+
const existing = sessionId ? sessions.get(sessionId) : undefined;
|
|
30
|
+
if (existing) {
|
|
31
|
+
await existing.transport.handleRequest(req, res, req.body);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (sessionId || !isInitializeRequest(req.body)) {
|
|
35
|
+
reject(res, 400, "Bad Request: no valid session. Send an initialize request first.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const server = options.createServer();
|
|
39
|
+
const transport = new StreamableHTTPServerTransport({
|
|
40
|
+
sessionIdGenerator: () => randomUUID(),
|
|
41
|
+
onsessioninitialized: (id) => {
|
|
42
|
+
sessions.set(id, { transport, server });
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
transport.onclose = () => {
|
|
46
|
+
if (transport.sessionId)
|
|
47
|
+
sessions.delete(transport.sessionId);
|
|
48
|
+
};
|
|
49
|
+
await server.connect(transport);
|
|
50
|
+
await transport.handleRequest(req, res, req.body);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
if (!res.headersSent)
|
|
54
|
+
reject(res, 500, "Internal server error");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const bySession = async (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
60
|
+
const existing = sessionId ? sessions.get(sessionId) : undefined;
|
|
61
|
+
if (!existing) {
|
|
62
|
+
reject(res, 400, "Bad Request: unknown or missing session id.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await existing.transport.handleRequest(req, res);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
if (!res.headersSent)
|
|
69
|
+
reject(res, 500, "Internal server error");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
app.get("/mcp", ...guard, bySession);
|
|
73
|
+
app.delete("/mcp", ...guard, bySession);
|
|
74
|
+
const httpServer = await new Promise((resolve, reject2) => {
|
|
75
|
+
const s = app.listen(options.port, options.host, () => resolve(s));
|
|
76
|
+
s.on("error", reject2);
|
|
77
|
+
});
|
|
78
|
+
const port = httpServer.address().port;
|
|
79
|
+
let closing;
|
|
80
|
+
const close = () => {
|
|
81
|
+
closing ??= (async () => {
|
|
82
|
+
const open = [...sessions.values()];
|
|
83
|
+
sessions.clear();
|
|
84
|
+
await Promise.all(open.map(async ({ transport, server }) => {
|
|
85
|
+
await transport.close().catch(() => undefined);
|
|
86
|
+
await server.close().catch(() => undefined);
|
|
87
|
+
}));
|
|
88
|
+
httpServer.closeAllConnections();
|
|
89
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
90
|
+
})();
|
|
91
|
+
return closing;
|
|
92
|
+
};
|
|
93
|
+
return { port, host: options.host, close, oauth: auth?.provider };
|
|
94
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,10 +4,19 @@ import { parseArgs } from "node:util";
|
|
|
4
4
|
import { ReportsApi } from "./reports/api.js";
|
|
5
5
|
import { createClient, DEFAULT_API_VERSION } from "./salesforce/client.js";
|
|
6
6
|
import { resolveConnection } from "./salesforce/connection.js";
|
|
7
|
+
import { checkOAuthOptions, PASSCODE_ENV } from "./http-auth.js";
|
|
8
|
+
import { assertLoopbackHost, startHttpServer } from "./http.js";
|
|
7
9
|
import { buildServer } from "./server.js";
|
|
8
|
-
|
|
10
|
+
import { dashboardFilesCommand } from "./dashboard-files.js";
|
|
11
|
+
const HELP = `salesforce-reports-mcp: an MCP server for Salesforce reports (stdio, or Streamable HTTP with --http)
|
|
9
12
|
|
|
10
13
|
Usage: salesforce-reports-mcp [--org <alias>] [options]
|
|
14
|
+
salesforce-reports-mcp dashboard-files --org <alias> --defs <file.json> --out <dir>
|
|
15
|
+
|
|
16
|
+
dashboard-files Run report definitions read-only and write one result file per definition, for a Claude
|
|
17
|
+
Dashboard. Flags: --org <alias> (required), --defs <file.json> (a JSON object mapping an id
|
|
18
|
+
to query_report arguments), --out <dir> (required). Exit 0 when all reports were written,
|
|
19
|
+
1 when any failed, 2 on a usage error. It starts no server.
|
|
11
20
|
|
|
12
21
|
--org <alias> Authenticate through the Salesforce CLI (env SF_ORG). Default: the sf default org.
|
|
13
22
|
Or set SF_INSTANCE_URL and SF_ACCESS_TOKEN to use a token you already hold.
|
|
@@ -15,12 +24,22 @@ Usage: salesforce-reports-mcp [--org <alias>] [options]
|
|
|
15
24
|
--read-only Register no write tools (env SF_REPORTS_READ_ONLY=1).
|
|
16
25
|
--allow-delete Register delete_report (env SF_REPORTS_ALLOW_DELETE=1).
|
|
17
26
|
--max-rows <n> Default cap on detail rows per result (env SF_REPORTS_MAX_ROWS). Default 200, ceiling 2000.
|
|
27
|
+
--http <port> Serve Streamable HTTP at /mcp instead of stdio (env SF_REPORTS_HTTP_PORT). Port 0 picks a free port.
|
|
28
|
+
--host <host> Host for --http (env SF_REPORTS_HTTP_HOST). Default 127.0.0.1. Loopback only, unless --public-url is set.
|
|
29
|
+
--public-url <url> Turn on the OAuth layer for --http (env SF_REPORTS_PUBLIC_URL), for use behind a tunnel.
|
|
30
|
+
The URL clients reach this server at, https unless its host is loopback; /mcp then needs
|
|
31
|
+
a token this server issued. Needs ${PASSCODE_ENV} (env only, at least 16 characters):
|
|
32
|
+
the owner types it on the sign-in page to approve a client.
|
|
18
33
|
-h, --help Show this help.
|
|
19
34
|
`;
|
|
20
35
|
function flag(value, envName) {
|
|
21
36
|
return value ?? ["1", "true"].includes((process.env[envName] ?? "").toLowerCase());
|
|
22
37
|
}
|
|
23
38
|
async function main() {
|
|
39
|
+
if (process.argv[2] === "dashboard-files") {
|
|
40
|
+
process.exitCode = await dashboardFilesCommand(process.argv.slice(3));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
24
43
|
const { values } = parseArgs({
|
|
25
44
|
options: {
|
|
26
45
|
org: { type: "string" },
|
|
@@ -28,6 +47,9 @@ async function main() {
|
|
|
28
47
|
"read-only": { type: "boolean" },
|
|
29
48
|
"allow-delete": { type: "boolean" },
|
|
30
49
|
"max-rows": { type: "string" },
|
|
50
|
+
http: { type: "string" },
|
|
51
|
+
host: { type: "string" },
|
|
52
|
+
"public-url": { type: "string" },
|
|
31
53
|
help: { type: "boolean", short: "h" },
|
|
32
54
|
},
|
|
33
55
|
strict: true,
|
|
@@ -41,14 +63,48 @@ async function main() {
|
|
|
41
63
|
if (!Number.isInteger(maxRows) || maxRows < 1 || maxRows > 2000) {
|
|
42
64
|
throw new Error("--max-rows must be a whole number from 1 to 2000.");
|
|
43
65
|
}
|
|
66
|
+
const httpRaw = values.http ?? process.env.SF_REPORTS_HTTP_PORT;
|
|
67
|
+
const host = values.host ?? process.env.SF_REPORTS_HTTP_HOST ?? "127.0.0.1";
|
|
68
|
+
const publicUrl = values["public-url"] ?? process.env.SF_REPORTS_PUBLIC_URL;
|
|
69
|
+
let port;
|
|
70
|
+
let oauth;
|
|
71
|
+
if (httpRaw !== undefined && httpRaw !== "") {
|
|
72
|
+
port = Number(httpRaw);
|
|
73
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
74
|
+
throw new Error("--http must be a port from 0 to 65535.");
|
|
75
|
+
if (publicUrl) {
|
|
76
|
+
// The passcode comes from the environment only, never a flag.
|
|
77
|
+
oauth = { publicUrl, passcode: process.env[PASSCODE_ENV] ?? "" };
|
|
78
|
+
checkOAuthOptions(oauth);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
assertLoopbackHost(host);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (values["public-url"] !== undefined) {
|
|
85
|
+
throw new Error("--public-url needs --http.");
|
|
86
|
+
}
|
|
44
87
|
const connection = resolveConnection({ org: values.org });
|
|
45
88
|
const client = createClient(connection, { apiVersion: values["api-version"] ?? process.env.SF_API_VERSION });
|
|
46
|
-
const
|
|
89
|
+
const reports = new ReportsApi(client);
|
|
90
|
+
const make = () => buildServer(reports, {
|
|
47
91
|
readOnly: flag(values["read-only"], "SF_REPORTS_READ_ONLY"),
|
|
48
92
|
allowDelete: flag(values["allow-delete"], "SF_REPORTS_ALLOW_DELETE"),
|
|
49
93
|
maxRows,
|
|
50
94
|
});
|
|
51
|
-
|
|
95
|
+
if (port === undefined) {
|
|
96
|
+
await make().connect(new StdioServerTransport());
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const handle = await startHttpServer({ port, host, createServer: make, oauth });
|
|
100
|
+
const shutdown = () => {
|
|
101
|
+
void handle.close().then(() => process.exit(0));
|
|
102
|
+
};
|
|
103
|
+
process.once("SIGINT", shutdown);
|
|
104
|
+
process.once("SIGTERM", shutdown);
|
|
105
|
+
const local = `http://${host.includes(":") ? `[${host}]` : host}:${handle.port}/mcp`;
|
|
106
|
+
const signIn = handle.oauth ? ` (public at ${handle.oauth.resource.href}, OAuth sign-in on)` : "";
|
|
107
|
+
process.stderr.write(`salesforce-reports-mcp: listening on ${local}${signIn}\n`);
|
|
52
108
|
}
|
|
53
109
|
main().catch((error) => {
|
|
54
110
|
process.stderr.write(`salesforce-reports-mcp: ${error instanceof Error ? error.message : String(error)}\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "salesforce-reports-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "MCP server to find, define, run, save and combine Salesforce reports through the Reports and Dashboards REST API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"salesforce",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"parity": "tsx scripts/parity-check.ts",
|
|
41
41
|
"capture": "tsx scripts/capture-fixtures.ts",
|
|
42
42
|
"prepare": "npm run build",
|
|
43
|
+
"door4-check": "tsx scripts/door4-check.ts",
|
|
44
|
+
"dashboard-files": "node dist/index.js dashboard-files",
|
|
43
45
|
"mcp-login": "tsx scripts/mcp-login.ts",
|
|
44
46
|
"mcp-check": "tsx scripts/mcp-check.ts",
|
|
45
47
|
"mcp:generate": "tsx salesforce/scripts/generate-metadata.ts",
|
|
@@ -34,6 +34,15 @@ Reach for a dashboard tool instead of a report run when the user names a dashboa
|
|
|
34
34
|
3. **Read it** with `run_dashboard`. Lead with the two or three components that answer the question, each by its header, and say when the data was refreshed and whose data it shows (`runningUser`). Read `notes`.
|
|
35
35
|
4. **No data or old data.** When a note says a component has no data, or the numbers are more than a day old, say so and offer `refresh_dashboard` with the same filters. Do not refresh without the user's yes: a refresh reruns every source report and changes what other viewers see. If it answers that the dashboard was refreshed less than a minute ago, give the cached numbers and the time to try again; do not retry at once.
|
|
36
36
|
|
|
37
|
+
## A dashboard to keep or share
|
|
38
|
+
|
|
39
|
+
Where the host draws the report card, the card in chat comes first. When the person wants a view to keep, share or open again ("make this a dashboard", "can the team see this"), offer a Claude Dashboard, and build it only after their yes. Do not offer it unasked, and do not use it for a one-off answer.
|
|
40
|
+
|
|
41
|
+
- **On the local server**, build it from attached result files: run the reports, attach each `ReportResult` file as a dataset, and use the tested loader in `reference.md`. It is a copy as of the run, so the page must say it is a copy and give the run date. To update it, run the reports again. Design changes go through the dashboard's comments.
|
|
42
|
+
- **Where `run_report` comes from a connector** (the in-org hosted server, or the local server over HTTP on claude.ai), a live dashboard is possible with Claude's Dashboard artifacts. That path is untested from claude.ai.
|
|
43
|
+
|
|
44
|
+
The dashboard saves no Salesforce report.
|
|
45
|
+
|
|
37
46
|
## Never save
|
|
38
47
|
|
|
39
48
|
Do not call `create_report`, `update_report`, `clone_report` or `delete_report` from this skill. If the user wants a slice kept, or a report changed, say so plainly ("Want me to save this as a report?") and carry on only after they say yes.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
function reportResultToRows({ answer }) {
|
|
2
|
+
var fail = function (e) {
|
|
3
|
+
if (e && typeof e === "object") {
|
|
4
|
+
var parts = [e.message, e.code, e.hint].filter(function (x) { return typeof x === "string" && x; });
|
|
5
|
+
if (parts.length) return new Error(parts.join(" "));
|
|
6
|
+
}
|
|
7
|
+
return new Error(typeof e === "string" && e ? e : "The answer carried an error.");
|
|
8
|
+
};
|
|
9
|
+
var parse = function (text) {
|
|
10
|
+
try { return JSON.parse(text); } catch (e) { throw new Error("The answer is not a ReportResult: it is text that is not JSON."); }
|
|
11
|
+
};
|
|
12
|
+
var textOf = function (a) {
|
|
13
|
+
var c = a && a.content;
|
|
14
|
+
return Array.isArray(c) && c[0] && typeof c[0].text === "string" ? c[0].text : "";
|
|
15
|
+
};
|
|
16
|
+
var r = answer;
|
|
17
|
+
if (typeof r === "string") r = parse(r);
|
|
18
|
+
if (r && typeof r === "object" && r.isError) throw fail(textOf(r) || r.error);
|
|
19
|
+
if (r && typeof r === "object" && r.structuredContent && typeof r.structuredContent === "object") r = r.structuredContent;
|
|
20
|
+
else if (r && typeof r === "object" && !r.report && Array.isArray(r.content) && textOf(r)) r = parse(textOf(r));
|
|
21
|
+
if (r && typeof r === "object" && r.isError) throw fail(textOf(r) || r.error);
|
|
22
|
+
if (r && typeof r === "object" && r.error) throw fail(r.error);
|
|
23
|
+
if (!r || typeof r !== "object" || !r.report || (!r.groups && !r.detail)) {
|
|
24
|
+
throw new Error("The answer is not a ReportResult: it has no report and no groups or detail.");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var columns = [];
|
|
28
|
+
var rows = [];
|
|
29
|
+
var unique = function (heading, fallback) {
|
|
30
|
+
return heading && columns.indexOf(heading) < 0 ? heading : fallback;
|
|
31
|
+
};
|
|
32
|
+
if (r.groups) {
|
|
33
|
+
var dims = r.groups.dimensions || [];
|
|
34
|
+
var meas = r.groups.measures || [];
|
|
35
|
+
var all = dims.concat(meas);
|
|
36
|
+
var heads = [];
|
|
37
|
+
all.forEach(function (c) {
|
|
38
|
+
var h = unique(c.label, c.name);
|
|
39
|
+
heads.push(h);
|
|
40
|
+
columns.push(h);
|
|
41
|
+
});
|
|
42
|
+
(r.groups.rows || []).forEach(function (row) {
|
|
43
|
+
var o = {};
|
|
44
|
+
heads.forEach(function (h, i) { o[h] = row[i]; });
|
|
45
|
+
rows.push(o);
|
|
46
|
+
});
|
|
47
|
+
var tm = (r.totals && r.totals.measures) || [];
|
|
48
|
+
var tv = (r.totals && r.totals.values) || [];
|
|
49
|
+
var total = {};
|
|
50
|
+
heads.forEach(function (h, i) { total[h] = i === 0 ? "Total" : ""; });
|
|
51
|
+
tm.forEach(function (name, i) {
|
|
52
|
+
var at = -1;
|
|
53
|
+
meas.forEach(function (m, j) { if (m.name === name) at = dims.length + j; });
|
|
54
|
+
if (at >= 0) total[heads[at]] = tv[i];
|
|
55
|
+
});
|
|
56
|
+
rows.push(total);
|
|
57
|
+
} else {
|
|
58
|
+
var cols = r.detail.columns || [];
|
|
59
|
+
cols.forEach(function (c) { columns.push(c.name); });
|
|
60
|
+
(r.detail.rows || []).forEach(function (row) {
|
|
61
|
+
var o = {};
|
|
62
|
+
cols.forEach(function (c, i) { o[c.name] = row[i]; });
|
|
63
|
+
rows.push(o);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
var notes = Array.isArray(r.notes) ? r.notes : [];
|
|
68
|
+
var cut = !!(r.detail && r.detail.truncated === true) ||
|
|
69
|
+
notes.some(function (n) { return /\b(cut|truncated)\b|showing \d+ of \d+/i.test(String(n)); });
|
|
70
|
+
var meta = [];
|
|
71
|
+
if (r.report.url) meta.push({ label: "Report", value: String(r.report.url) });
|
|
72
|
+
(Array.isArray(r.filters) ? r.filters : []).forEach(function (f) { meta.push({ label: "Filter", value: String(f) }); });
|
|
73
|
+
notes.forEach(function (n) { meta.push({ label: "Note", value: String(n) }); });
|
|
74
|
+
return { columns: columns, rows: rows, cut: cut, meta: meta };
|
|
75
|
+
}
|
|
76
|
+
if (typeof module !== "undefined") module.exports = reportResultToRows;
|
|
@@ -40,6 +40,42 @@ Examples of slices, in the user's words:
|
|
|
40
40
|
|
|
41
41
|
The server fixes two Salesforce quirks and says so in `notes`: it adds an aggregated column to the detail columns, and drops a grouped column from them.
|
|
42
42
|
|
|
43
|
+
## Loader for a Claude Dashboard
|
|
44
|
+
|
|
45
|
+
On the local door a dashboard to keep is built from attached result files. It is a copy as of the run (decision [0022](../../docs/decisions/0022-dashboard-from-attached-result-files.md)). Seen to work on 2026-11-11, not documented behaviour: JSON file datasets, one per report, each read by the loader below.
|
|
46
|
+
|
|
47
|
+
**Make the files.** `npx -y salesforce-reports-mcp dashboard-files --org <alias> --defs <file.json> --out <dir>` runs report definitions read-only and writes one `ReportResult` file per definition. It is the subcommand of the installed server, so it needs no clone. Inside a clone of the repo, use `npm run dashboard-files -- --org <alias> --defs <file.json> --out <dir>` after `npm run build`. The defs file is a JSON object that maps a short id (letters, digits, dash and underscore) to `query_report` arguments, the definition fields at the top level. It prints `<id>: rows=<n> truncated=<bool>` for each file, or `<id>: failed`, and ends with `dashboard-files: written=<n> failed=<m>`. Exit codes: 0 when every report was written, 1 when any failed or the server could not be reached, 2 for a usage error. A single result already in the chat can be attached the same way, as a file.
|
|
48
|
+
|
|
49
|
+
**Reshape each file.** The loader turns a `ReportResult` into plain rows:
|
|
50
|
+
|
|
51
|
+
1. `groups.dimensions` and `groups.measures` are lists of objects. Their `name` values, dimensions first and then measures, name the cells of each row in `groups.rows`, in order. Zip each row with those names into one object per row, and use each `label` as the column heading.
|
|
52
|
+
2. `totals.measures` and `totals.values` are two lists of the same length. Zip them into one more row, labelled "Total".
|
|
53
|
+
3. A flat report has no `groups`: zip each row of `detail.rows` with the `name` of each entry in `detail.columns` instead, and say when `detail.truncated` is true.
|
|
54
|
+
4. `filters` stay words. The report's own filters are shown as a caption or filter note, never as controls that would change the run. A page-side control may filter only rows already in the files, and never over a dataset whose `cut` is true, because its totals would be wrong.
|
|
55
|
+
5. Carry `notes` and `report.url` through: show any note about cut rows, and keep the link to the report.
|
|
56
|
+
|
|
57
|
+
The tested source is [dashboard-loader.js](dashboard-loader.js) in this folder. It is one function, `reportResultToRows({ answer })`, that runs alone. It returns `{columns, rows, cut, meta}` and throws the server's own words when the answer is an error. Do not rewrite it by hand. Paste it whole inside a wrapper and register the wrapper as each dataset's loader. The dashboard reads rows from `data`, so the wrapper returns them there:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
function loadReport({ answer }) {
|
|
61
|
+
/* dashboard-loader.js pasted whole here */
|
|
62
|
+
var r = reportResultToRows({ answer: answer });
|
|
63
|
+
return { data: r.rows, columns: r.columns, cut: r.cut, meta: r.meta };
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Beyond the wrapper, follow the Dashboard type's own instructions.
|
|
68
|
+
|
|
69
|
+
**Say it is a copy.** The page states that it is a copy and gives the date of the runs. To bring it up to date, run the reports again and attach the new files. A dashboard page cannot reach the local server, so it cannot re-run a report. Ask for design changes through the dashboard's comments to Claude.
|
|
70
|
+
|
|
71
|
+
**Where `run_report` comes from a connector**, a live dataset is possible. Its source is a live call with nothing saved, and its `args` are the `query_report` input itself, the definition fields at the top level. This was not run from claude.ai and is untested.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
{kind: 'call', connector, tool: 'query_report', args: {reportType, columns, groupingsDown, aggregates, filters, ...}}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Keep the run's input in the dashboard (the `reportId` and any `overrides`) so each refresh asks the same question. Check the shape of a real result before writing the loader.
|
|
78
|
+
|
|
43
79
|
## Limits
|
|
44
80
|
|
|
45
81
|
- At most 2,000 detail rows and 100 columns per run, and 500 synchronous runs per org per hour. Prefer one grouped query over many small ones.
|