salesforce-reports-mcp 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +17 -0
- package/LICENSE +21 -0
- package/README.md +177 -2
- package/dist/format.js +49 -0
- package/dist/index.js +56 -0
- package/dist/reports/api.js +105 -0
- package/dist/reports/combine.js +116 -0
- package/dist/reports/dashboards.js +308 -0
- package/dist/reports/definition.js +186 -0
- package/dist/reports/shape.js +156 -0
- package/dist/reports/summarize.js +181 -0
- package/dist/salesforce/client.js +64 -0
- package/dist/salesforce/connection.js +117 -0
- package/dist/server.js +717 -0
- package/dist/widget/dashboard.html +946 -0
- package/dist/widget/report.html +938 -0
- package/dist/widget/view.js +72 -0
- package/package.json +64 -4
- package/skills/report-deploy/SKILL.md +25 -0
- package/skills/report-designer/SKILL.md +45 -0
- package/skills/report-designer/reference.md +47 -0
- package/skills/report-designer/transport.md +92 -0
- package/skills/report-runner/SKILL.md +53 -0
- package/skills/report-runner/reference.md +47 -0
- package/skills/report-runner/transport.md +92 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "salesforce-reports",
|
|
3
|
+
"owner": { "name": "nelsben" },
|
|
4
|
+
"description": "Salesforce reports for Claude.",
|
|
5
|
+
"plugins": [
|
|
6
|
+
{
|
|
7
|
+
"name": "salesforce-reports",
|
|
8
|
+
"source": "./",
|
|
9
|
+
"description": "Explore, design and deploy Salesforce reports with Claude.",
|
|
10
|
+
"category": "productivity"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "salesforce-reports",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Explore, design and deploy Salesforce reports with Claude: three skills and an MCP server over the Reports and Dashboards REST API.",
|
|
5
|
+
"author": { "name": "nelsben" },
|
|
6
|
+
"homepage": "https://github.com/nelsben/salesforce-reports-mcp",
|
|
7
|
+
"repository": "https://github.com/nelsben/salesforce-reports-mcp",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": ["salesforce", "reports", "mcp", "analytics"],
|
|
10
|
+
"skills": "./skills",
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"salesforce-reports": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "salesforce-reports-mcp"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nelsben
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,178 @@
|
|
|
1
|
-
#
|
|
1
|
+
# salesforce-reports-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
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).
|
|
6
|
+
|
|
7
|
+
> **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
|
+
>
|
|
9
|
+
> **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
|
+
>
|
|
11
|
+
> *Claude Desktop, through the local MCP server, signed in with the sf CLI ([rehearsal](docs/rehearsals/runner.md)).*
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
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
|
+

|
|
18
|
+
|
|
19
|
+
*Drawn by the [render test](docs/decisions/0014-render-tests-through-a-test-host.md)'s host page from anonymised test data.*
|
|
20
|
+
|
|
21
|
+
## Run it on your own org
|
|
22
|
+
|
|
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
|
+
```
|
|
29
|
+
|
|
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.
|
|
31
|
+
|
|
32
|
+
## Why I built this
|
|
33
|
+
|
|
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.
|
|
50
|
+
|
|
51
|
+
| Skill | For | What Claude does |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| [report-runner](skills/report-runner/SKILL.md) | A seller or manager who wants an answer | Finds the report, says in plain words what it counts, runs it, leads with the answer, offers the next slice. Never saves. |
|
|
54
|
+
| [report-designer](skills/report-designer/SKILL.md) | Someone who wants a report changed or made | Drafts the report in your words, previews it on real data, changes it until you like it, saves it in one step when you ask. |
|
|
55
|
+
| [report-deploy](skills/report-deploy/SKILL.md) | An admin moving a report between orgs or into git | Retrieves the report's metadata, checks it with a dry run, deploys only after you say yes. |
|
|
56
|
+
|
|
57
|
+
> **You:** Start from my Pipeline by Stage report. Add Next Step and group it by owner, then save it as Pipeline by Owner.
|
|
58
|
+
>
|
|
59
|
+
> **Claude:** Preview, with 8 deals under two owners. Saved as a new report in your Private Reports; the original is untouched. Link.
|
|
60
|
+
|
|
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
|
+
```
|
|
92
|
+
|
|
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 |
|
|
102
|
+
|
|
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.
|
|
104
|
+
|
|
105
|
+
## A feature inside Salesforce
|
|
106
|
+
|
|
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)):
|
|
108
|
+
|
|
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 |
|
|
114
|
+
|
|
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)).
|
|
116
|
+
|
|
117
|
+
## Security and identity
|
|
118
|
+
|
|
119
|
+
Each door signs in differently, and each is least privilege by default.
|
|
120
|
+
|
|
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. |
|
|
126
|
+
|
|
127
|
+
Sharing, folder access and field-level security apply on every door, because every call runs as the user.
|
|
128
|
+
|
|
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:
|
|
130
|
+
|
|
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.
|
|
133
|
+
|
|
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.
|
|
135
|
+
|
|
136
|
+
## How it was built
|
|
137
|
+
|
|
138
|
+
The method was the same for every feature: probe the platform, decide in writing, build, then check against a real org.
|
|
139
|
+
|
|
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 |
|
|
147
|
+
|
|
148
|
+
## Get started
|
|
149
|
+
|
|
150
|
+
| You want | Start here |
|
|
151
|
+
| --- | --- |
|
|
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) |
|
|
156
|
+
|
|
157
|
+
The tools, the result shape, the safety rails and the limits are in [docs/reference.md](docs/reference.md).
|
|
158
|
+
|
|
159
|
+
## Develop
|
|
160
|
+
|
|
161
|
+
```
|
|
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)
|
|
168
|
+
```
|
|
169
|
+
|
|
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.
|
|
171
|
+
|
|
172
|
+
## Roadmap
|
|
173
|
+
|
|
174
|
+
- Joined reports, report charts, and subscriptions.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT.
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CombineError } from "./reports/combine.js";
|
|
2
|
+
import { ConnectionError } from "./salesforce/connection.js";
|
|
3
|
+
import { SalesforceError } from "./salesforce/client.js";
|
|
4
|
+
export function ok(value) {
|
|
5
|
+
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* ok() for a tool that carries the report view: the same text result, plus the value as
|
|
9
|
+
* structuredContent for a host that draws MCP Apps. Hosts without UI read the text, as before.
|
|
10
|
+
*/
|
|
11
|
+
export function okView(value) {
|
|
12
|
+
return { ...ok(value), structuredContent: value };
|
|
13
|
+
}
|
|
14
|
+
export function fail(error) {
|
|
15
|
+
const body = describeError(error);
|
|
16
|
+
return { isError: true, content: [{ type: "text", text: JSON.stringify({ error: body }) }] };
|
|
17
|
+
}
|
|
18
|
+
function describeError(error) {
|
|
19
|
+
if (error instanceof SalesforceError) {
|
|
20
|
+
return { code: error.code, message: error.message, hint: hintFor(error) };
|
|
21
|
+
}
|
|
22
|
+
if (error instanceof ConnectionError) {
|
|
23
|
+
return { code: "CONNECTION", message: error.message, hint: "Check the --org alias, or log in with `sf org login web`." };
|
|
24
|
+
}
|
|
25
|
+
if (error instanceof CombineError || error instanceof InputError) {
|
|
26
|
+
return { code: "INVALID_INPUT", message: error.message };
|
|
27
|
+
}
|
|
28
|
+
return { code: "UNEXPECTED", message: error instanceof Error ? error.message : "Unexpected error." };
|
|
29
|
+
}
|
|
30
|
+
function hintFor(error) {
|
|
31
|
+
const text = `${error.code} ${error.message}`;
|
|
32
|
+
if (error.status === 401)
|
|
33
|
+
return "Log in again with `sf org login web`, or pass a fresh token.";
|
|
34
|
+
if (error.status === 404 || /NOT_FOUND|INVALID_ID/i.test(error.code)) {
|
|
35
|
+
return "Check the id with search_reports (reports) or list_report_types (report types).";
|
|
36
|
+
}
|
|
37
|
+
if (/column|field|grouping|aggregate|filter|operator/i.test(text)) {
|
|
38
|
+
return "Check column API names, filter operators and aggregates with describe_report_type, or the report's own columns with describe_report.";
|
|
39
|
+
}
|
|
40
|
+
if (/REQUEST_LIMIT_EXCEEDED|limit/i.test(text)) {
|
|
41
|
+
return "Salesforce allows 500 synchronous report runs per org per hour. Wait, or reduce the number of runs.";
|
|
42
|
+
}
|
|
43
|
+
if (/INSUFFICIENT_ACCESS|FORBIDDEN|permission/i.test(text)) {
|
|
44
|
+
return "The authenticated user lacks access. They need the Run Reports permission, and for saving, Create and Customize Reports.";
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
export class InputError extends Error {
|
|
49
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { ReportsApi } from "./reports/api.js";
|
|
5
|
+
import { createClient, DEFAULT_API_VERSION } from "./salesforce/client.js";
|
|
6
|
+
import { resolveConnection } from "./salesforce/connection.js";
|
|
7
|
+
import { buildServer } from "./server.js";
|
|
8
|
+
const HELP = `salesforce-reports-mcp: an MCP server for Salesforce reports (stdio)
|
|
9
|
+
|
|
10
|
+
Usage: salesforce-reports-mcp [--org <alias>] [options]
|
|
11
|
+
|
|
12
|
+
--org <alias> Authenticate through the Salesforce CLI (env SF_ORG). Default: the sf default org.
|
|
13
|
+
Or set SF_INSTANCE_URL and SF_ACCESS_TOKEN to use a token you already hold.
|
|
14
|
+
--api-version <v> REST API version (env SF_API_VERSION). Default ${DEFAULT_API_VERSION}.
|
|
15
|
+
--read-only Register no write tools (env SF_REPORTS_READ_ONLY=1).
|
|
16
|
+
--allow-delete Register delete_report (env SF_REPORTS_ALLOW_DELETE=1).
|
|
17
|
+
--max-rows <n> Default cap on detail rows per result (env SF_REPORTS_MAX_ROWS). Default 200, ceiling 2000.
|
|
18
|
+
-h, --help Show this help.
|
|
19
|
+
`;
|
|
20
|
+
function flag(value, envName) {
|
|
21
|
+
return value ?? ["1", "true"].includes((process.env[envName] ?? "").toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
async function main() {
|
|
24
|
+
const { values } = parseArgs({
|
|
25
|
+
options: {
|
|
26
|
+
org: { type: "string" },
|
|
27
|
+
"api-version": { type: "string" },
|
|
28
|
+
"read-only": { type: "boolean" },
|
|
29
|
+
"allow-delete": { type: "boolean" },
|
|
30
|
+
"max-rows": { type: "string" },
|
|
31
|
+
help: { type: "boolean", short: "h" },
|
|
32
|
+
},
|
|
33
|
+
strict: true,
|
|
34
|
+
});
|
|
35
|
+
if (values.help) {
|
|
36
|
+
process.stderr.write(HELP);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const maxRowsRaw = values["max-rows"] ?? process.env.SF_REPORTS_MAX_ROWS;
|
|
40
|
+
const maxRows = maxRowsRaw ? Number.parseInt(maxRowsRaw, 10) : 200;
|
|
41
|
+
if (!Number.isInteger(maxRows) || maxRows < 1 || maxRows > 2000) {
|
|
42
|
+
throw new Error("--max-rows must be a whole number from 1 to 2000.");
|
|
43
|
+
}
|
|
44
|
+
const connection = resolveConnection({ org: values.org });
|
|
45
|
+
const client = createClient(connection, { apiVersion: values["api-version"] ?? process.env.SF_API_VERSION });
|
|
46
|
+
const server = buildServer(new ReportsApi(client), {
|
|
47
|
+
readOnly: flag(values["read-only"], "SF_REPORTS_READ_ONLY"),
|
|
48
|
+
allowDelete: flag(values["allow-delete"], "SF_REPORTS_ALLOW_DELETE"),
|
|
49
|
+
maxRows,
|
|
50
|
+
});
|
|
51
|
+
await server.connect(new StdioServerTransport());
|
|
52
|
+
}
|
|
53
|
+
main().catch((error) => {
|
|
54
|
+
process.stderr.write(`salesforce-reports-mcp: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { SalesforceError } from "../salesforce/client.js";
|
|
2
|
+
const enc = encodeURIComponent;
|
|
3
|
+
export function soqlString(value) {
|
|
4
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
5
|
+
}
|
|
6
|
+
/** For a `LIKE` clause: escape the quote and backslash, and the two wildcards. */
|
|
7
|
+
export function soqlLike(value) {
|
|
8
|
+
return soqlString(value).replace(/([%_])/g, "\\$1");
|
|
9
|
+
}
|
|
10
|
+
export class ReportsApi {
|
|
11
|
+
api;
|
|
12
|
+
sleep;
|
|
13
|
+
constructor(api, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
|
14
|
+
this.api = api;
|
|
15
|
+
this.sleep = sleep;
|
|
16
|
+
}
|
|
17
|
+
instanceUrl() {
|
|
18
|
+
return this.api.instanceUrl();
|
|
19
|
+
}
|
|
20
|
+
reportTypes() {
|
|
21
|
+
return this.api.request("GET", "/analytics/reportTypes");
|
|
22
|
+
}
|
|
23
|
+
describeReportType(type) {
|
|
24
|
+
return this.api.request("GET", `/analytics/reportTypes/${enc(type)}`);
|
|
25
|
+
}
|
|
26
|
+
describeReport(id) {
|
|
27
|
+
return this.api.request("GET", `/analytics/reports/${enc(id)}/describe`);
|
|
28
|
+
}
|
|
29
|
+
async run(id, options) {
|
|
30
|
+
const path = `/analytics/reports/${enc(id)}?includeDetails=${options.includeDetails}`;
|
|
31
|
+
try {
|
|
32
|
+
const raw = options.overrides
|
|
33
|
+
? await this.api.request("POST", path, { reportMetadata: options.overrides })
|
|
34
|
+
: await this.api.request("GET", path);
|
|
35
|
+
return { raw, async: false };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (!isTimeout(error))
|
|
39
|
+
throw error;
|
|
40
|
+
return { raw: await this.runAsync(id, options.overrides), async: true };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
query(metadata, includeDetails) {
|
|
44
|
+
return this.api.request("POST", `/analytics/reports/query?includeDetails=${includeDetails}`, { reportMetadata: metadata });
|
|
45
|
+
}
|
|
46
|
+
create(metadata) {
|
|
47
|
+
return this.api.request("POST", "/analytics/reports", { reportMetadata: metadata });
|
|
48
|
+
}
|
|
49
|
+
patch(id, metadata) {
|
|
50
|
+
return this.api.request("PATCH", `/analytics/reports/${enc(id)}`, { reportMetadata: metadata });
|
|
51
|
+
}
|
|
52
|
+
clone(id, metadata) {
|
|
53
|
+
return this.api.request("POST", `/analytics/reports?cloneId=${enc(id)}`, { reportMetadata: metadata });
|
|
54
|
+
}
|
|
55
|
+
async remove(id) {
|
|
56
|
+
await this.api.request("DELETE", `/analytics/reports/${enc(id)}`);
|
|
57
|
+
}
|
|
58
|
+
async soql(query) {
|
|
59
|
+
const out = await this.api.request("GET", `/query?q=${enc(query)}`);
|
|
60
|
+
return out.records;
|
|
61
|
+
}
|
|
62
|
+
// ---- dashboards. `filters` is a query string from resolveFilters, such as "?filter1=0IC…", or "". ----
|
|
63
|
+
describeDashboard(id) {
|
|
64
|
+
return this.api.request("GET", `/analytics/dashboards/${enc(id)}/describe`);
|
|
65
|
+
}
|
|
66
|
+
/** The cached results. Never refreshes. */
|
|
67
|
+
dashboardResults(id, filters = "") {
|
|
68
|
+
return this.api.request("GET", `/analytics/dashboards/${enc(id)}${filters}`);
|
|
69
|
+
}
|
|
70
|
+
dashboardStatus(id, filters = "") {
|
|
71
|
+
return this.api.request("GET", `/analytics/dashboards/${enc(id)}/status${filters}`);
|
|
72
|
+
}
|
|
73
|
+
/** Starts a refresh. The filter goes on the query string: a body is accepted and ignored. */
|
|
74
|
+
async refreshDashboard(id, filters = "") {
|
|
75
|
+
await this.api.request("PUT", `/analytics/dashboards/${enc(id)}${filters}`);
|
|
76
|
+
}
|
|
77
|
+
/** Waits between polls. Tests pass a sleep that returns at once. */
|
|
78
|
+
pause(ms) {
|
|
79
|
+
return this.sleep(ms);
|
|
80
|
+
}
|
|
81
|
+
async runAsync(id, overrides) {
|
|
82
|
+
const started = await this.api.request("POST", `/analytics/reports/${enc(id)}/instances?includeDetails=true`, overrides ? { reportMetadata: overrides } : undefined);
|
|
83
|
+
const instanceId = started.id ?? started.attributes?.id;
|
|
84
|
+
if (!instanceId)
|
|
85
|
+
throw new SalesforceError(500, "NO_INSTANCE", "Salesforce did not return an async report instance.");
|
|
86
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
87
|
+
await this.sleep(Math.min(1000 + attempt * 500, 4000));
|
|
88
|
+
const instance = await this.api.request("GET", `/analytics/reports/${enc(id)}/instances/${enc(instanceId)}`);
|
|
89
|
+
const status = instance.attributes?.status ?? instance.status;
|
|
90
|
+
if (status === "Success")
|
|
91
|
+
return instance;
|
|
92
|
+
if (status === "Error")
|
|
93
|
+
throw new SalesforceError(500, "REPORT_RUN_FAILED", "The async report run failed in Salesforce.");
|
|
94
|
+
}
|
|
95
|
+
throw new SalesforceError(504, "REPORT_RUN_PENDING", `The async report run (${instanceId}) did not finish in time. Try again, or narrow the filters.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function isTimeout(error) {
|
|
99
|
+
if (!(error instanceof SalesforceError))
|
|
100
|
+
return false;
|
|
101
|
+
return (error.status === 408 ||
|
|
102
|
+
error.status === 504 ||
|
|
103
|
+
/TIMEOUT|TOO_LONG|ASYNC/i.test(error.code) ||
|
|
104
|
+
/timed out|time out|asynchronous/i.test(error.message));
|
|
105
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export class CombineError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export function combineResults(mode, inputs, options = {}) {
|
|
4
|
+
if (inputs.length < 2 || inputs.length > 5)
|
|
5
|
+
throw new CombineError("combine_reports takes 2 to 5 inputs.");
|
|
6
|
+
const aliases = inputs.map((i) => i.alias);
|
|
7
|
+
if (new Set(aliases).size !== aliases.length)
|
|
8
|
+
throw new CombineError("Each input needs its own alias.");
|
|
9
|
+
const base = {
|
|
10
|
+
report: {
|
|
11
|
+
id: null,
|
|
12
|
+
name: `combined: ${aliases.join(", ")}`,
|
|
13
|
+
format: mode === "join" ? "SUMMARY" : "TABULAR",
|
|
14
|
+
reportType: "combined",
|
|
15
|
+
},
|
|
16
|
+
filters: inputs.flatMap((i) => i.result.filters.map((f) => `${i.alias}: ${f}`)),
|
|
17
|
+
notes: inputs.flatMap((i) => i.result.notes.map((n) => `${i.alias}: ${n}`)),
|
|
18
|
+
};
|
|
19
|
+
const body = mode === "join" ? join(inputs, options.joinOn) : stack(inputs);
|
|
20
|
+
return { ...base, ...body };
|
|
21
|
+
}
|
|
22
|
+
function stack(inputs) {
|
|
23
|
+
if (inputs.every((i) => i.result.groups)) {
|
|
24
|
+
const dims = unionBy(inputs.flatMap((i) => i.result.groups.dimensions), (d) => d.name);
|
|
25
|
+
const measures = unionBy(inputs.flatMap((i) => i.result.groups.measures), (m) => m.name);
|
|
26
|
+
const rows = [];
|
|
27
|
+
for (const { alias, result } of inputs) {
|
|
28
|
+
const g = result.groups;
|
|
29
|
+
const dimIdx = g.dimensions.map((d) => d.name);
|
|
30
|
+
const measureIdx = g.measures.map((m) => m.name);
|
|
31
|
+
for (const row of g.rows) {
|
|
32
|
+
rows.push([
|
|
33
|
+
alias,
|
|
34
|
+
...dims.map((d) => pick(row, dimIdx.indexOf(d.name))),
|
|
35
|
+
...measures.map((m) => pick(row, measureIdx.indexOf(m.name) < 0 ? -1 : dimIdx.length + measureIdx.indexOf(m.name))),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { groups: { dimensions: [{ name: "source", label: "Source", axis: "down" }, ...dims], measures, rows } };
|
|
40
|
+
}
|
|
41
|
+
if (inputs.every((i) => i.result.detail)) {
|
|
42
|
+
const columns = unionBy(inputs.flatMap((i) => i.result.detail.columns), (c) => c.name);
|
|
43
|
+
const rows = [];
|
|
44
|
+
for (const { alias, result } of inputs) {
|
|
45
|
+
const d = result.detail;
|
|
46
|
+
const idx = d.columns.map((c) => c.name);
|
|
47
|
+
for (const row of d.rows)
|
|
48
|
+
rows.push([alias, ...columns.map((c) => pick(row, idx.indexOf(c.name)))]);
|
|
49
|
+
}
|
|
50
|
+
const all = [{ name: "source", label: "Source", dataType: "string" }, ...columns];
|
|
51
|
+
return { detail: { columns: all, rows, rowCount: rows.length, truncated: inputs.some((i) => i.result.detail.truncated) } };
|
|
52
|
+
}
|
|
53
|
+
throw new CombineError("To stack, every input must return grouped rows, or every input must return detail rows. Group all of them the same way, or run all of them with detail.");
|
|
54
|
+
}
|
|
55
|
+
function join(inputs, joinOn) {
|
|
56
|
+
const plans = inputs.map(({ alias, result }) => {
|
|
57
|
+
const g = result.groups;
|
|
58
|
+
if (!g)
|
|
59
|
+
throw new CombineError(`Input "${alias}" has no groupings. Join needs grouped reports; use stack for flat tables.`);
|
|
60
|
+
const downs = g.dimensions.filter((d) => d.axis === "down");
|
|
61
|
+
const keys = joinOn ?? (downs[0] ? [downs[0].name] : []);
|
|
62
|
+
if (!keys.length)
|
|
63
|
+
throw new CombineError(`Input "${alias}" has no down grouping to join on.`);
|
|
64
|
+
const idx = keys.map((k) => g.dimensions.findIndex((d) => d.name === k));
|
|
65
|
+
const missing = keys.filter((_, n) => idx[n] < 0);
|
|
66
|
+
if (missing.length) {
|
|
67
|
+
throw new CombineError(`Input "${alias}" is not grouped by ${missing.join(", ")}. It has: ${g.dimensions.map((d) => d.name).join(", ")}.`);
|
|
68
|
+
}
|
|
69
|
+
if (g.dimensions.length > keys.length) {
|
|
70
|
+
throw new CombineError(`Input "${alias}" is grouped by more than the join key (${g.dimensions.map((d) => d.name).join(", ")}). Set joinOn to all of its groupings, or remove the extra groupings.`);
|
|
71
|
+
}
|
|
72
|
+
return { alias, g, idx };
|
|
73
|
+
});
|
|
74
|
+
const first = plans[0];
|
|
75
|
+
const keyDims = first.idx.map((i) => ({ ...first.g.dimensions[i], axis: "down" }));
|
|
76
|
+
const measures = plans.flatMap((p) => p.g.measures.map((m) => ({ name: `${p.alias}:${m.name}`, label: `${p.alias}: ${m.label}`, dataType: m.dataType })));
|
|
77
|
+
const table = new Map();
|
|
78
|
+
for (const p of plans) {
|
|
79
|
+
for (const row of p.g.rows) {
|
|
80
|
+
const labels = p.idx.map((i) => row[i] ?? null);
|
|
81
|
+
const key = labels.map((l) => String(l ?? "").trim().toLowerCase()).join("\u0000");
|
|
82
|
+
let entry = table.get(key);
|
|
83
|
+
if (!entry) {
|
|
84
|
+
entry = { labels, values: new Map() };
|
|
85
|
+
table.set(key, entry);
|
|
86
|
+
}
|
|
87
|
+
entry.values.set(p.alias, row.slice(p.g.dimensions.length));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const rows = [];
|
|
91
|
+
for (const entry of table.values()) {
|
|
92
|
+
const out = [...entry.labels];
|
|
93
|
+
for (const p of plans) {
|
|
94
|
+
const values = entry.values.get(p.alias);
|
|
95
|
+
for (let m = 0; m < p.g.measures.length; m++)
|
|
96
|
+
out.push(values?.[m] ?? null);
|
|
97
|
+
}
|
|
98
|
+
rows.push(out);
|
|
99
|
+
}
|
|
100
|
+
return { groups: { dimensions: keyDims, measures, rows } };
|
|
101
|
+
}
|
|
102
|
+
function pick(row, index) {
|
|
103
|
+
return index < 0 ? null : (row[index] ?? null);
|
|
104
|
+
}
|
|
105
|
+
function unionBy(items, key) {
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
const k = key(item);
|
|
110
|
+
if (!seen.has(k)) {
|
|
111
|
+
seen.add(k);
|
|
112
|
+
out.push(item);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|