salesforce-reports-mcp 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +87 -105
- package/dist/dashboard-files.js +136 -0
- package/dist/http-auth.js +310 -0
- package/dist/http.js +94 -0
- package/dist/index.js +59 -3
- package/dist/reports/api.js +19 -0
- package/dist/reports/dashboards.js +308 -0
- package/dist/server.js +210 -3
- package/dist/widget/dashboard.html +946 -0
- package/dist/widget/report.html +722 -63
- package/dist/widget/view.js +32 -10
- package/package.json +6 -1
- package/skills/report-designer/transport.md +6 -2
- package/skills/report-runner/SKILL.md +20 -1
- package/skills/report-runner/dashboard-loader.js +76 -0
- package/skills/report-runner/reference.md +36 -0
- package/skills/report-runner/transport.md +6 -2
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// The HTTP door's own OAuth 2.1 server, for one owner. It issues tokens only for this server's /mcp,
|
|
2
|
+
// and only after the owner types the passcode from SF_REPORTS_HTTP_PASSCODE on the /authorize page.
|
|
3
|
+
// Everything lives in memory: a restart signs every client out. Importing this module does nothing.
|
|
4
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { InvalidClientMetadataError, InvalidGrantError, InvalidScopeError, InvalidTargetError, InvalidTokenError, } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
6
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
7
|
+
import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
8
|
+
export const PASSCODE_ENV = "SF_REPORTS_HTTP_PASSCODE";
|
|
9
|
+
export const MIN_PASSCODE_LENGTH = 16;
|
|
10
|
+
const ACCESS_TTL_S = 60 * 60;
|
|
11
|
+
const REFRESH_TTL_S = 30 * 24 * 60 * 60;
|
|
12
|
+
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
const MAX_WRONG_PASSCODES = 10;
|
|
14
|
+
const WRONG_PASSCODE_WINDOW_MS = 15 * 60 * 1000;
|
|
15
|
+
// Hosts that may use plain http, for a public URL and for a registered redirect URI.
|
|
16
|
+
const LOOPBACK_HTTP_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
17
|
+
/** The issuer URL: https, or http on localhost or 127.0.0.1, with no path, query or fragment. */
|
|
18
|
+
export function resolvePublicUrl(raw) {
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error("--public-url must be an absolute URL, such as https://reports.example.com.");
|
|
25
|
+
}
|
|
26
|
+
// The SDK allows a plain http issuer only on these two hosts.
|
|
27
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
28
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
29
|
+
throw new Error("--public-url must use https, unless its host is localhost or 127.0.0.1.");
|
|
30
|
+
}
|
|
31
|
+
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
32
|
+
throw new Error("--public-url must be an origin only, with no path, query or fragment, such as https://reports.example.com.");
|
|
33
|
+
}
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
/** Checks the passcode without ever putting it in the message. */
|
|
37
|
+
export function checkPasscode(passcode) {
|
|
38
|
+
if (!passcode)
|
|
39
|
+
throw new Error(`${PASSCODE_ENV} must be set when --public-url turns the OAuth layer on.`);
|
|
40
|
+
if (passcode.length < MIN_PASSCODE_LENGTH)
|
|
41
|
+
throw new Error(`${PASSCODE_ENV} must be at least ${MIN_PASSCODE_LENGTH} characters.`);
|
|
42
|
+
return passcode;
|
|
43
|
+
}
|
|
44
|
+
/** Validates the options before anything starts, so a bad start fails before Salesforce is touched. */
|
|
45
|
+
export function checkOAuthOptions(options) {
|
|
46
|
+
checkPasscode(options.passcode);
|
|
47
|
+
const publicUrl = resolvePublicUrl(options.publicUrl);
|
|
48
|
+
return { publicUrl, resource: new URL("/mcp", publicUrl) };
|
|
49
|
+
}
|
|
50
|
+
const sha256 = (value) => createHash("sha256").update(value, "utf8").digest();
|
|
51
|
+
const hashKey = (value) => sha256(value).toString("base64url");
|
|
52
|
+
const newSecret = () => randomBytes(32).toString("base64url");
|
|
53
|
+
// A resource compared as a string, without its fragment and one trailing slash, as the SDK's bearer check does.
|
|
54
|
+
function comparable(value) {
|
|
55
|
+
const text = String(value);
|
|
56
|
+
const hash = text.indexOf("#");
|
|
57
|
+
return (hash === -1 ? text : text.slice(0, hash)).replace(/\/$/, "");
|
|
58
|
+
}
|
|
59
|
+
function allowedRedirect(uri) {
|
|
60
|
+
let url;
|
|
61
|
+
try {
|
|
62
|
+
url = new URL(uri);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (url.hash)
|
|
68
|
+
return false;
|
|
69
|
+
return url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HTTP_HOSTS.includes(url.hostname));
|
|
70
|
+
}
|
|
71
|
+
const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
72
|
+
/** An in-memory OAuth server for one owner. Codes and tokens are kept only as SHA-256 hashes. */
|
|
73
|
+
export class OwnerOAuthProvider {
|
|
74
|
+
resource;
|
|
75
|
+
now;
|
|
76
|
+
passcodeHash;
|
|
77
|
+
clients = new Map();
|
|
78
|
+
codes = new Map();
|
|
79
|
+
grants = new Map();
|
|
80
|
+
wrongPasscodes = [];
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.resource = checkOAuthOptions(options).resource;
|
|
83
|
+
this.passcodeHash = sha256(options.passcode);
|
|
84
|
+
this.now = options.now ?? Date.now;
|
|
85
|
+
}
|
|
86
|
+
clientsStore = {
|
|
87
|
+
getClient: (clientId) => this.clients.get(clientId),
|
|
88
|
+
registerClient: (client) => {
|
|
89
|
+
const full = client;
|
|
90
|
+
if (full.redirect_uris.length === 0 || !full.redirect_uris.every(allowedRedirect)) {
|
|
91
|
+
throw new InvalidClientMetadataError("Each redirect URI must use https, or http on a loopback host.");
|
|
92
|
+
}
|
|
93
|
+
this.clients.set(full.client_id, full);
|
|
94
|
+
return full;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
async authorize(client, params, res) {
|
|
98
|
+
if (params.resource && comparable(params.resource) !== comparable(this.resource)) {
|
|
99
|
+
throw new InvalidTargetError(`This server issues tokens only for ${this.resource.href}.`);
|
|
100
|
+
}
|
|
101
|
+
const req = res.req;
|
|
102
|
+
if (req.method !== "POST") {
|
|
103
|
+
this.page(res, 200, client, params);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const now = this.now();
|
|
107
|
+
this.wrongPasscodes = this.wrongPasscodes.filter((t) => now - t < WRONG_PASSCODE_WINDOW_MS);
|
|
108
|
+
if (this.wrongPasscodes.length >= MAX_WRONG_PASSCODES) {
|
|
109
|
+
this.page(res, 429, client, params, "Too many wrong passcodes. Wait 15 minutes, then try again.");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const given = typeof req.body?.passcode === "string" ? req.body.passcode : "";
|
|
113
|
+
if (!timingSafeEqual(sha256(given), this.passcodeHash)) {
|
|
114
|
+
this.wrongPasscodes.push(now);
|
|
115
|
+
this.page(res, 403, client, params, "That passcode is not right.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.prune(now);
|
|
119
|
+
const code = newSecret();
|
|
120
|
+
this.codes.set(hashKey(code), {
|
|
121
|
+
clientId: client.client_id,
|
|
122
|
+
redirectUri: params.redirectUri,
|
|
123
|
+
challenge: params.codeChallenge,
|
|
124
|
+
resource: this.resource.href,
|
|
125
|
+
scopes: params.scopes ?? [],
|
|
126
|
+
expiresAt: now + CODE_TTL_MS,
|
|
127
|
+
challenged: false,
|
|
128
|
+
});
|
|
129
|
+
const target = new URL(params.redirectUri);
|
|
130
|
+
target.searchParams.set("code", code);
|
|
131
|
+
if (params.state !== undefined)
|
|
132
|
+
target.searchParams.set("state", params.state);
|
|
133
|
+
res.redirect(302, target.href);
|
|
134
|
+
}
|
|
135
|
+
// The token handler asks for the challenge, checks the verifier, then exchanges. Asking spends the code,
|
|
136
|
+
// so a wrong verifier burns it and a second try with the same code fails.
|
|
137
|
+
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
138
|
+
const key = hashKey(authorizationCode);
|
|
139
|
+
const entry = this.codes.get(key);
|
|
140
|
+
if (!entry || entry.challenged || entry.clientId !== client.client_id || entry.expiresAt <= this.now()) {
|
|
141
|
+
this.codes.delete(key);
|
|
142
|
+
throw new InvalidGrantError("The authorization code is invalid, used or expired.");
|
|
143
|
+
}
|
|
144
|
+
entry.challenged = true;
|
|
145
|
+
return entry.challenge;
|
|
146
|
+
}
|
|
147
|
+
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
|
|
148
|
+
const key = hashKey(authorizationCode);
|
|
149
|
+
const entry = this.codes.get(key);
|
|
150
|
+
this.codes.delete(key);
|
|
151
|
+
if (!entry || !entry.challenged || entry.clientId !== client.client_id || entry.expiresAt <= this.now()) {
|
|
152
|
+
throw new InvalidGrantError("The authorization code is invalid, used or expired.");
|
|
153
|
+
}
|
|
154
|
+
if (redirectUri !== undefined && redirectUri !== entry.redirectUri) {
|
|
155
|
+
throw new InvalidGrantError("redirect_uri does not match the authorization request.");
|
|
156
|
+
}
|
|
157
|
+
if (resource && comparable(resource) !== comparable(entry.resource)) {
|
|
158
|
+
throw new InvalidTargetError("resource does not match the authorization request.");
|
|
159
|
+
}
|
|
160
|
+
return this.issueTokens(client.client_id, new URL(entry.resource), entry.scopes);
|
|
161
|
+
}
|
|
162
|
+
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
163
|
+
const key = hashKey(refreshToken);
|
|
164
|
+
const grant = this.grants.get(key);
|
|
165
|
+
if (!grant || grant.kind !== "refresh" || grant.clientId !== client.client_id || grant.expiresAt <= this.now()) {
|
|
166
|
+
if (grant && grant.expiresAt <= this.now())
|
|
167
|
+
this.grants.delete(key);
|
|
168
|
+
throw new InvalidGrantError("The refresh token is invalid, used or expired.");
|
|
169
|
+
}
|
|
170
|
+
if (resource && comparable(resource) !== comparable(grant.resource)) {
|
|
171
|
+
throw new InvalidTargetError("resource does not match the refresh token.");
|
|
172
|
+
}
|
|
173
|
+
if (scopes && !scopes.every((s) => grant.scopes.includes(s))) {
|
|
174
|
+
throw new InvalidScopeError("A refresh cannot widen the scopes.");
|
|
175
|
+
}
|
|
176
|
+
// Rotate: the old refresh token stops working.
|
|
177
|
+
this.grants.delete(key);
|
|
178
|
+
return this.issueTokens(client.client_id, new URL(grant.resource), scopes ?? grant.scopes);
|
|
179
|
+
}
|
|
180
|
+
async verifyAccessToken(token) {
|
|
181
|
+
const key = hashKey(token);
|
|
182
|
+
const grant = this.grants.get(key);
|
|
183
|
+
if (!grant || grant.kind !== "access")
|
|
184
|
+
throw new InvalidTokenError("The access token is not valid.");
|
|
185
|
+
if (grant.expiresAt <= this.now()) {
|
|
186
|
+
this.grants.delete(key);
|
|
187
|
+
throw new InvalidTokenError("The access token has expired.");
|
|
188
|
+
}
|
|
189
|
+
if (comparable(grant.resource) !== comparable(this.resource)) {
|
|
190
|
+
throw new InvalidTokenError("The access token was not issued for this resource.");
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
token,
|
|
194
|
+
clientId: grant.clientId,
|
|
195
|
+
scopes: grant.scopes,
|
|
196
|
+
expiresAt: Math.floor(grant.expiresAt / 1000),
|
|
197
|
+
resource: new URL(grant.resource),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
async revokeToken(client, request) {
|
|
201
|
+
const key = hashKey(request.token);
|
|
202
|
+
if (this.grants.get(key)?.clientId === client.client_id)
|
|
203
|
+
this.grants.delete(key);
|
|
204
|
+
}
|
|
205
|
+
/** Issues an access and refresh token pair. Only the hashes are kept. */
|
|
206
|
+
issueTokens(clientId, resource, scopes) {
|
|
207
|
+
const now = this.now();
|
|
208
|
+
this.prune(now);
|
|
209
|
+
const accessToken = newSecret();
|
|
210
|
+
const refreshToken = newSecret();
|
|
211
|
+
this.grants.set(hashKey(accessToken), { kind: "access", clientId, resource: resource.href, scopes, expiresAt: now + ACCESS_TTL_S * 1000 });
|
|
212
|
+
this.grants.set(hashKey(refreshToken), { kind: "refresh", clientId, resource: resource.href, scopes, expiresAt: now + REFRESH_TTL_S * 1000 });
|
|
213
|
+
return {
|
|
214
|
+
access_token: accessToken,
|
|
215
|
+
token_type: "Bearer",
|
|
216
|
+
expires_in: ACCESS_TTL_S,
|
|
217
|
+
refresh_token: refreshToken,
|
|
218
|
+
...(scopes.length ? { scope: scopes.join(" ") } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
prune(now) {
|
|
222
|
+
for (const [key, code] of this.codes)
|
|
223
|
+
if (code.expiresAt <= now)
|
|
224
|
+
this.codes.delete(key);
|
|
225
|
+
for (const [key, grant] of this.grants)
|
|
226
|
+
if (grant.expiresAt <= now)
|
|
227
|
+
this.grants.delete(key);
|
|
228
|
+
}
|
|
229
|
+
// One self-contained page: no script, no external asset. The passcode is never written back into it.
|
|
230
|
+
page(res, status, client, params, error) {
|
|
231
|
+
const name = escapeHtml(client.client_name || client.client_id);
|
|
232
|
+
const returnHost = escapeHtml(new URL(params.redirectUri).host);
|
|
233
|
+
const hidden = {
|
|
234
|
+
response_type: "code",
|
|
235
|
+
client_id: client.client_id,
|
|
236
|
+
redirect_uri: params.redirectUri,
|
|
237
|
+
code_challenge: params.codeChallenge,
|
|
238
|
+
code_challenge_method: "S256",
|
|
239
|
+
state: params.state,
|
|
240
|
+
scope: params.scopes?.length ? params.scopes.join(" ") : undefined,
|
|
241
|
+
resource: params.resource?.href,
|
|
242
|
+
};
|
|
243
|
+
const fields = Object.entries(hidden)
|
|
244
|
+
.filter((entry) => entry[1] !== undefined)
|
|
245
|
+
.map(([k, v]) => `<input type="hidden" name="${k}" value="${escapeHtml(v)}">`)
|
|
246
|
+
.join("\n ");
|
|
247
|
+
const html = `<!doctype html>
|
|
248
|
+
<html lang="en">
|
|
249
|
+
<head>
|
|
250
|
+
<meta charset="utf-8">
|
|
251
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
252
|
+
<title>Sign in to Salesforce reports</title>
|
|
253
|
+
<style>
|
|
254
|
+
:root { color-scheme: light dark; }
|
|
255
|
+
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: Canvas; color: CanvasText; }
|
|
256
|
+
main { max-width: 28rem; margin: 3rem auto; padding: 0 1rem; }
|
|
257
|
+
h1 { font-size: 1.4rem; }
|
|
258
|
+
.error { color: #b3261e; font-weight: 600; }
|
|
259
|
+
label { display: block; margin: 1.25rem 0 .25rem; font-weight: 600; }
|
|
260
|
+
input[type=password] { box-sizing: border-box; width: 100%; padding: .6rem; font: inherit; }
|
|
261
|
+
button { margin-top: 1rem; padding: .6rem 1.2rem; font: inherit; cursor: pointer; }
|
|
262
|
+
.small { font-size: .875rem; opacity: .8; }
|
|
263
|
+
</style>
|
|
264
|
+
</head>
|
|
265
|
+
<body>
|
|
266
|
+
<main>
|
|
267
|
+
<h1>Allow ${name} to use Salesforce reports?</h1>
|
|
268
|
+
<p><strong>${name}</strong> asks to connect to this server. Approving lets it run this server's tools as the Salesforce user this server is signed in as.</p>
|
|
269
|
+
<p class="small">After you approve, you go back to ${returnHost}. Only approve a client you just added yourself.</p>
|
|
270
|
+
${error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : ""}
|
|
271
|
+
<form method="post" action="/authorize">
|
|
272
|
+
${fields}
|
|
273
|
+
<label for="passcode">Passcode</label>
|
|
274
|
+
<input id="passcode" name="passcode" type="password" autocomplete="current-password" required autofocus>
|
|
275
|
+
<button type="submit">Approve</button>
|
|
276
|
+
</form>
|
|
277
|
+
</main>
|
|
278
|
+
</body>
|
|
279
|
+
</html>
|
|
280
|
+
`;
|
|
281
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'");
|
|
282
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
283
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
284
|
+
res.status(status).type("html").send(html);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export function createOwnerAuth(options) {
|
|
288
|
+
const { publicUrl, resource } = checkOAuthOptions(options);
|
|
289
|
+
const provider = new OwnerOAuthProvider(options);
|
|
290
|
+
// Behind a tunnel every request arrives from loopback with an X-Forwarded-For header. The SDK's limits then
|
|
291
|
+
// count all callers together, which suits one owner; skip the check that would print a stack trace for it.
|
|
292
|
+
const rateLimit = { validate: { xForwardedForHeader: false } };
|
|
293
|
+
const router = mcpAuthRouter({
|
|
294
|
+
provider,
|
|
295
|
+
issuerUrl: publicUrl,
|
|
296
|
+
resourceServerUrl: resource,
|
|
297
|
+
resourceName: "Salesforce reports",
|
|
298
|
+
authorizationOptions: { rateLimit },
|
|
299
|
+
clientRegistrationOptions: { rateLimit },
|
|
300
|
+
tokenOptions: { rateLimit },
|
|
301
|
+
revocationOptions: { rateLimit },
|
|
302
|
+
});
|
|
303
|
+
const bearer = requireBearerAuth({
|
|
304
|
+
verifier: provider,
|
|
305
|
+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resource),
|
|
306
|
+
expectedResource: resource,
|
|
307
|
+
});
|
|
308
|
+
const allowedHosts = [...new Set([publicUrl.hostname, ...LOOPBACK_HTTP_HOSTS])];
|
|
309
|
+
return { provider, router, bearer, allowedHosts };
|
|
310
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { createOwnerAuth } from "./http-auth.js";
|
|
6
|
+
export const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"];
|
|
7
|
+
export function assertLoopbackHost(host) {
|
|
8
|
+
if (!LOOPBACK_HOSTS.includes(host)) {
|
|
9
|
+
throw new Error(`--host ${host} is not loopback. A non-loopback host needs the OAuth layer: set --public-url and SF_REPORTS_HTTP_PASSCODE.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// Express request and response types are not imported here; the SDK's own types are enough.
|
|
13
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
14
|
+
export async function startHttpServer(options) {
|
|
15
|
+
const auth = options.oauth ? createOwnerAuth(options.oauth) : undefined;
|
|
16
|
+
if (!auth)
|
|
17
|
+
assertLoopbackHost(options.host);
|
|
18
|
+
const app = createMcpExpressApp(auth ? { host: options.host, allowedHosts: auth.allowedHosts } : { host: options.host });
|
|
19
|
+
if (auth)
|
|
20
|
+
app.use(auth.router);
|
|
21
|
+
const guard = auth ? [auth.bearer] : [];
|
|
22
|
+
const sessions = new Map();
|
|
23
|
+
const reject = (res, status, message) => {
|
|
24
|
+
res.status(status).json({ jsonrpc: "2.0", error: { code: -32000, message }, id: null });
|
|
25
|
+
};
|
|
26
|
+
app.post("/mcp", ...guard, async (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
29
|
+
const existing = sessionId ? sessions.get(sessionId) : undefined;
|
|
30
|
+
if (existing) {
|
|
31
|
+
await existing.transport.handleRequest(req, res, req.body);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (sessionId || !isInitializeRequest(req.body)) {
|
|
35
|
+
reject(res, 400, "Bad Request: no valid session. Send an initialize request first.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const server = options.createServer();
|
|
39
|
+
const transport = new StreamableHTTPServerTransport({
|
|
40
|
+
sessionIdGenerator: () => randomUUID(),
|
|
41
|
+
onsessioninitialized: (id) => {
|
|
42
|
+
sessions.set(id, { transport, server });
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
transport.onclose = () => {
|
|
46
|
+
if (transport.sessionId)
|
|
47
|
+
sessions.delete(transport.sessionId);
|
|
48
|
+
};
|
|
49
|
+
await server.connect(transport);
|
|
50
|
+
await transport.handleRequest(req, res, req.body);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
if (!res.headersSent)
|
|
54
|
+
reject(res, 500, "Internal server error");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const bySession = async (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
60
|
+
const existing = sessionId ? sessions.get(sessionId) : undefined;
|
|
61
|
+
if (!existing) {
|
|
62
|
+
reject(res, 400, "Bad Request: unknown or missing session id.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await existing.transport.handleRequest(req, res);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
if (!res.headersSent)
|
|
69
|
+
reject(res, 500, "Internal server error");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
app.get("/mcp", ...guard, bySession);
|
|
73
|
+
app.delete("/mcp", ...guard, bySession);
|
|
74
|
+
const httpServer = await new Promise((resolve, reject2) => {
|
|
75
|
+
const s = app.listen(options.port, options.host, () => resolve(s));
|
|
76
|
+
s.on("error", reject2);
|
|
77
|
+
});
|
|
78
|
+
const port = httpServer.address().port;
|
|
79
|
+
let closing;
|
|
80
|
+
const close = () => {
|
|
81
|
+
closing ??= (async () => {
|
|
82
|
+
const open = [...sessions.values()];
|
|
83
|
+
sessions.clear();
|
|
84
|
+
await Promise.all(open.map(async ({ transport, server }) => {
|
|
85
|
+
await transport.close().catch(() => undefined);
|
|
86
|
+
await server.close().catch(() => undefined);
|
|
87
|
+
}));
|
|
88
|
+
httpServer.closeAllConnections();
|
|
89
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
90
|
+
})();
|
|
91
|
+
return closing;
|
|
92
|
+
};
|
|
93
|
+
return { port, host: options.host, close, oauth: auth?.provider };
|
|
94
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,10 +4,19 @@ import { parseArgs } from "node:util";
|
|
|
4
4
|
import { ReportsApi } from "./reports/api.js";
|
|
5
5
|
import { createClient, DEFAULT_API_VERSION } from "./salesforce/client.js";
|
|
6
6
|
import { resolveConnection } from "./salesforce/connection.js";
|
|
7
|
+
import { checkOAuthOptions, PASSCODE_ENV } from "./http-auth.js";
|
|
8
|
+
import { assertLoopbackHost, startHttpServer } from "./http.js";
|
|
7
9
|
import { buildServer } from "./server.js";
|
|
8
|
-
|
|
10
|
+
import { dashboardFilesCommand } from "./dashboard-files.js";
|
|
11
|
+
const HELP = `salesforce-reports-mcp: an MCP server for Salesforce reports (stdio, or Streamable HTTP with --http)
|
|
9
12
|
|
|
10
13
|
Usage: salesforce-reports-mcp [--org <alias>] [options]
|
|
14
|
+
salesforce-reports-mcp dashboard-files --org <alias> --defs <file.json> --out <dir>
|
|
15
|
+
|
|
16
|
+
dashboard-files Run report definitions read-only and write one result file per definition, for a Claude
|
|
17
|
+
Dashboard. Flags: --org <alias> (required), --defs <file.json> (a JSON object mapping an id
|
|
18
|
+
to query_report arguments), --out <dir> (required). Exit 0 when all reports were written,
|
|
19
|
+
1 when any failed, 2 on a usage error. It starts no server.
|
|
11
20
|
|
|
12
21
|
--org <alias> Authenticate through the Salesforce CLI (env SF_ORG). Default: the sf default org.
|
|
13
22
|
Or set SF_INSTANCE_URL and SF_ACCESS_TOKEN to use a token you already hold.
|
|
@@ -15,12 +24,22 @@ Usage: salesforce-reports-mcp [--org <alias>] [options]
|
|
|
15
24
|
--read-only Register no write tools (env SF_REPORTS_READ_ONLY=1).
|
|
16
25
|
--allow-delete Register delete_report (env SF_REPORTS_ALLOW_DELETE=1).
|
|
17
26
|
--max-rows <n> Default cap on detail rows per result (env SF_REPORTS_MAX_ROWS). Default 200, ceiling 2000.
|
|
27
|
+
--http <port> Serve Streamable HTTP at /mcp instead of stdio (env SF_REPORTS_HTTP_PORT). Port 0 picks a free port.
|
|
28
|
+
--host <host> Host for --http (env SF_REPORTS_HTTP_HOST). Default 127.0.0.1. Loopback only, unless --public-url is set.
|
|
29
|
+
--public-url <url> Turn on the OAuth layer for --http (env SF_REPORTS_PUBLIC_URL), for use behind a tunnel.
|
|
30
|
+
The URL clients reach this server at, https unless its host is loopback; /mcp then needs
|
|
31
|
+
a token this server issued. Needs ${PASSCODE_ENV} (env only, at least 16 characters):
|
|
32
|
+
the owner types it on the sign-in page to approve a client.
|
|
18
33
|
-h, --help Show this help.
|
|
19
34
|
`;
|
|
20
35
|
function flag(value, envName) {
|
|
21
36
|
return value ?? ["1", "true"].includes((process.env[envName] ?? "").toLowerCase());
|
|
22
37
|
}
|
|
23
38
|
async function main() {
|
|
39
|
+
if (process.argv[2] === "dashboard-files") {
|
|
40
|
+
process.exitCode = await dashboardFilesCommand(process.argv.slice(3));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
24
43
|
const { values } = parseArgs({
|
|
25
44
|
options: {
|
|
26
45
|
org: { type: "string" },
|
|
@@ -28,6 +47,9 @@ async function main() {
|
|
|
28
47
|
"read-only": { type: "boolean" },
|
|
29
48
|
"allow-delete": { type: "boolean" },
|
|
30
49
|
"max-rows": { type: "string" },
|
|
50
|
+
http: { type: "string" },
|
|
51
|
+
host: { type: "string" },
|
|
52
|
+
"public-url": { type: "string" },
|
|
31
53
|
help: { type: "boolean", short: "h" },
|
|
32
54
|
},
|
|
33
55
|
strict: true,
|
|
@@ -41,14 +63,48 @@ async function main() {
|
|
|
41
63
|
if (!Number.isInteger(maxRows) || maxRows < 1 || maxRows > 2000) {
|
|
42
64
|
throw new Error("--max-rows must be a whole number from 1 to 2000.");
|
|
43
65
|
}
|
|
66
|
+
const httpRaw = values.http ?? process.env.SF_REPORTS_HTTP_PORT;
|
|
67
|
+
const host = values.host ?? process.env.SF_REPORTS_HTTP_HOST ?? "127.0.0.1";
|
|
68
|
+
const publicUrl = values["public-url"] ?? process.env.SF_REPORTS_PUBLIC_URL;
|
|
69
|
+
let port;
|
|
70
|
+
let oauth;
|
|
71
|
+
if (httpRaw !== undefined && httpRaw !== "") {
|
|
72
|
+
port = Number(httpRaw);
|
|
73
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
74
|
+
throw new Error("--http must be a port from 0 to 65535.");
|
|
75
|
+
if (publicUrl) {
|
|
76
|
+
// The passcode comes from the environment only, never a flag.
|
|
77
|
+
oauth = { publicUrl, passcode: process.env[PASSCODE_ENV] ?? "" };
|
|
78
|
+
checkOAuthOptions(oauth);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
assertLoopbackHost(host);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (values["public-url"] !== undefined) {
|
|
85
|
+
throw new Error("--public-url needs --http.");
|
|
86
|
+
}
|
|
44
87
|
const connection = resolveConnection({ org: values.org });
|
|
45
88
|
const client = createClient(connection, { apiVersion: values["api-version"] ?? process.env.SF_API_VERSION });
|
|
46
|
-
const
|
|
89
|
+
const reports = new ReportsApi(client);
|
|
90
|
+
const make = () => buildServer(reports, {
|
|
47
91
|
readOnly: flag(values["read-only"], "SF_REPORTS_READ_ONLY"),
|
|
48
92
|
allowDelete: flag(values["allow-delete"], "SF_REPORTS_ALLOW_DELETE"),
|
|
49
93
|
maxRows,
|
|
50
94
|
});
|
|
51
|
-
|
|
95
|
+
if (port === undefined) {
|
|
96
|
+
await make().connect(new StdioServerTransport());
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const handle = await startHttpServer({ port, host, createServer: make, oauth });
|
|
100
|
+
const shutdown = () => {
|
|
101
|
+
void handle.close().then(() => process.exit(0));
|
|
102
|
+
};
|
|
103
|
+
process.once("SIGINT", shutdown);
|
|
104
|
+
process.once("SIGTERM", shutdown);
|
|
105
|
+
const local = `http://${host.includes(":") ? `[${host}]` : host}:${handle.port}/mcp`;
|
|
106
|
+
const signIn = handle.oauth ? ` (public at ${handle.oauth.resource.href}, OAuth sign-in on)` : "";
|
|
107
|
+
process.stderr.write(`salesforce-reports-mcp: listening on ${local}${signIn}\n`);
|
|
52
108
|
}
|
|
53
109
|
main().catch((error) => {
|
|
54
110
|
process.stderr.write(`salesforce-reports-mcp: ${error instanceof Error ? error.message : String(error)}\n`);
|
package/dist/reports/api.js
CHANGED
|
@@ -59,6 +59,25 @@ export class ReportsApi {
|
|
|
59
59
|
const out = await this.api.request("GET", `/query?q=${enc(query)}`);
|
|
60
60
|
return out.records;
|
|
61
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
|
+
}
|
|
62
81
|
async runAsync(id, overrides) {
|
|
63
82
|
const started = await this.api.request("POST", `/analytics/reports/${enc(id)}/instances?includeDetails=true`, overrides ? { reportMetadata: overrides } : undefined);
|
|
64
83
|
const instanceId = started.id ?? started.attributes?.id;
|