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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesforce-reports",
3
- "version": "0.3.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
- Claude finds, builds, runs and saves Salesforce reports, and reads your dashboards, from an MCP server on your own machine. Nothing is installed in Salesforce, no Salesforce MCP licence is needed, and no admin has to approve anything. You sign in with the Salesforce CLI you already use.
3
+ [![ci](https://github.com/nelsben/salesforce-reports-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/nelsben/salesforce-reports-mcp/actions/workflows/ci.yml)
4
4
 
5
- This repo is working software, and a demonstration of how to put MCP in front of Salesforce without Salesforce's own MCP. The same tools are also packaged as a feature inside the org, for people who cannot run a local server, and every design choice is written down with what it costs. Built by [Ben Nelsen](https://github.com/nelsben).
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 Desktop, through the local MCP server, signed in with the sf CLI ([rehearsal](docs/rehearsals/runner.md)).*
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
  ![The report card for an unsaved opportunity report grouped by stage, drawn in light, then dark, then at phone width](docs/assets/report-view.gif)
14
16
 
15
- In Claude Desktop the run also draws as a card: the headline numbers, the filters Salesforce applied, the groups with a bar beside each amount, and a button that asks Claude to save it. Dashboards draw the same way, every component as its own chart.
16
-
17
- ![The dashboard card for a pipeline dashboard: a metric, a gauge, a donut, bar, line, table, funnel and column charts](docs/assets/dashboard-card.png)
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
- ## Run it on your own org
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
- Two commands in Claude Code, with Node 20 and the Salesforce CLI signed in:
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
- That installs the three skills and the MCP server. 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. [docs/install.md](docs/install.md#the-local-mcp-server) has every option.
23
+ ## Three jobs, done by asking
31
24
 
32
- ## Why I built this
25
+ **The person with the question gets the answer. No one waits on the one colleague who knows Report Builder.**
33
26
 
34
- Most of what a company knows about its customers and its pipeline sits in Salesforce reports, and most of the people who need those answers cannot build a report. Assistants like Claude are becoming the way people ask, and the hard part is not the model: it is where the code runs, whose identity each call uses, and what it is allowed to change. I built this to work through those questions end to end for one real job, in the open, with every decision written down. The first answer is the local server, which needs nothing from Salesforce but an API session. The in-org packages show what it takes when that is not enough.
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
- **Answers you can trust.** 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.
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
- | | 1. Local MCP server | 2. In-org REST door with skills | 3. Hosted MCP door |
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
- Each door gets the same tool names, the same input and the same result ([decision 0001](docs/decisions/0001-one-contract-three-doors.md)). The skills bind to tool names, not to a door, so one skill works over all three. [docs/architecture.md](docs/architecture.md) has a numbered sequence diagram for each door, with every step explained.
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
- ## A feature inside Salesforce
45
+ ## The answer arrives as a picture
106
46
 
107
- The in-org doors are the same tools, rebuilt in Apex so they run where a local server cannot. They are a Salesforce feature an admin installs, not a script. They ship as three package directories in [salesforce/](salesforce/README.md) ([decision 0009](docs/decisions/0009-two-shipping-units.md)):
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
- | Package | Holds | Needs |
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 Apex is layered as ports and adapters ([decision 0003](docs/decisions/0003-one-apex-class-behind-both-in-org-doors.md)). One service does the work; the REST class is only an adapter, and [a test](test/apex-layers.test.ts) fails CI if the core ever names it or sets an HTTP status. Reads use `Reports.ReportManager` inside the org; only what Apex cannot do (list report types, run an unsaved report, save) calls the org's own REST API ([decision 0004](docs/decisions/0004-reportmanager-first-callout-for-the-rest.md)). There is no delete in the org ([decision 0006](docs/decisions/0006-no-delete-in-the-org.md)).
51
+ ![The dashboard card for a pipeline dashboard: a metric, a gauge, a donut, bar, line, table, funnel and column charts](docs/assets/dashboard-card.png)
116
52
 
117
- ## Security and identity
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
- Each door signs in differently, and each is least privilege by default.
55
+ Claude can find a Salesforce dashboard, read the numbers Salesforce last cached and refresh it when the data is old.
120
56
 
121
- | Door | Identity | What it can do |
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
- Sharing, folder access and field-level security apply on every door, because every call runs as the user.
59
+ **Ask for a dashboard, and Claude turns the report it just ran into a page you can keep and share.**
128
60
 
129
- **The hard part, and how it was solved.** Under a hosted MCP sign-in, Apex has no session, and asking for one throws an exception no `catch` block can stop. The first version leaked a raw platform error to claude.ai. The fix came in two steps, each with a decision record:
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
- 1. The hosted door got its own route, so the code knows which door called before it asks for a session, and answers a clear `CALLOUT_SESSION` instead ([decision 0007](docs/decisions/0007-callout-identity-under-hosted-mcp.md)).
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
- No token, consumer key, org address or certificate reaches the repo, logs or error messages. The tracked metadata holds placeholders that setup scripts fill at deploy time, and a test enforces it.
65
+ ## What you need, and what you do not
135
66
 
136
- ## How it was built
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
- The method was the same for every feature: probe the platform, decide in writing, build, then check against a real org.
69
+ You need:
139
70
 
140
- | Step | What it produced |
141
- | --- | --- |
142
- | **Probe** before building | [7 probe write-ups](docs/probes/): can a hosted MCP server wrap Apex, can Apex call its own org's API, how big a result fits in the heap, which identity works under a hosted sign-in, how dashboards refresh and filter |
143
- | **Decide** in writing | [19 decision records](docs/decisions/README.md), each with what it costs and what was turned down |
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
- ## Get started
76
+ If your company restricts API access or the Salesforce CLI, that is the question to take to your admin.
149
77
 
150
- | You want | Start here |
78
+ | It does not need | Because |
151
79
  | --- | --- |
152
- | Claude Desktop on your own org, nothing installed in Salesforce | [Claude Desktop guide](docs/claude-desktop.md) |
153
- | Claude Code or another MCP client, the same way | [Local MCP server](docs/install.md#the-local-mcp-server) |
154
- | The feature inside your org, called from the CLI, scripts or agents | [The REST door](docs/install.md#the-rest-door) |
155
- | Claude in the browser, signed in through Salesforce | [The MCP door](docs/install.md#the-mcp-door), then [the callout tools](docs/install.md#the-callout-tools-on-the-mcp-door) |
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
- The tools, the result shape, the safety rails and the limits are in [docs/reference.md](docs/reference.md).
86
+ ## Run it on your own org
158
87
 
159
- ## Develop
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
- npm install
163
- npm run build
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
- `npm run live` creates several private reports named `zz-live-check-*`, deletes them, and fails if any remain. Use a sandbox or scratch org.
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
- ## Roadmap
140
+ ## Help
173
141
 
174
- - Joined reports, report charts, and subscriptions.
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
- const HELP = `salesforce-reports-mcp: an MCP server for Salesforce reports (stdio)
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 server = buildServer(new ReportsApi(client), {
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
- await server.connect(new StdioServerTransport());
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.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.