@pitcher/cli-scripts 2.18.0 → 2.20.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/README.md +45 -10
- package/dist/src/commands/analytics.d.ts +3 -1
- package/dist/src/commands/analytics.js +7 -3
- package/dist/src/commands/devSync.d.ts +2 -0
- package/dist/src/commands/devSync.js +5 -5
- package/dist/src/commands/dsr.d.ts +5 -0
- package/dist/src/commands/dsr.js +126 -14
- package/dist/src/commands/publishApp.d.ts +2 -0
- package/dist/src/commands/publishApp.js +3 -3
- package/dist/src/commands/scaffolds.js +11 -3
- package/dist/src/core/archive.js +3 -3
- package/dist/src/core/credential.d.ts +25 -0
- package/dist/src/core/credential.js +25 -0
- package/dist/src/core/http.d.ts +6 -1
- package/dist/src/core/http.js +6 -5
- package/dist/src/core/ld.d.ts +38 -2
- package/dist/src/core/ld.js +162 -6
- package/dist/src/core/readInput.js +8 -1
- package/dist/src/mcp/bundled.js +1 -1
- package/dist/src/mcp/hosted.d.ts +21 -0
- package/dist/src/mcp/hosted.js +82 -0
- package/dist/src/mcp/index.d.ts +7 -0
- package/dist/src/mcp/index.js +6 -0
- package/dist/src/mcp/serve.js +1 -1
- package/dist/src/mcp/server.d.ts +19 -2
- package/dist/src/mcp/server.js +4 -4
- package/dist/src/mcp/tools/analytics.js +1 -1
- package/dist/src/mcp/tools/devSync.js +2 -2
- package/dist/src/mcp/tools/dsr.js +19 -1
- package/dist/src/mcp/tools/publishApp.js +2 -1
- package/dist/src/mcp/tools/types.d.ts +1 -1
- package/package.json +17 -6
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolveApiKey } from './auth.js';
|
|
2
|
+
import { createCliError } from './errors.js';
|
|
3
|
+
/** The auth header(s) for a credential. Exactly one header, never both. */
|
|
4
|
+
export function authHeaders(c) {
|
|
5
|
+
return c.kind === 'bearer' ? { authorization: `Bearer ${c.token}` } : { 'x-api-key': c.key };
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The ONE credential resolver every call site uses.
|
|
9
|
+
*
|
|
10
|
+
* An explicit `credential` always wins and never falls through: a bearer context must fail CLOSED,
|
|
11
|
+
* so it can never pick up `--api-key` or `NG_API_KEY` from the process env (the hosted server
|
|
12
|
+
* would otherwise act as whoever minted that key). Only when no credential is given (CLI / stdio)
|
|
13
|
+
* does this reach `resolveApiKey`, which reads `--api-key` then `NG_API_KEY`.
|
|
14
|
+
*/
|
|
15
|
+
export function credentialOf(opts) {
|
|
16
|
+
const c = opts.credential;
|
|
17
|
+
if (c) {
|
|
18
|
+
const value = c.kind === 'bearer' ? c.token : c.key;
|
|
19
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
20
|
+
throw createCliError('AUTH', `Empty ${c.kind === 'bearer' ? 'bearer token' : 'API key'} credential`);
|
|
21
|
+
}
|
|
22
|
+
return c;
|
|
23
|
+
}
|
|
24
|
+
return { kind: 'apiKey', key: resolveApiKey(opts) };
|
|
25
|
+
}
|
package/dist/src/core/http.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { type AxiosInstance } from 'axios';
|
|
2
|
+
import { type Credential } from './credential.js';
|
|
2
3
|
import { type CliError } from './errors.js';
|
|
3
|
-
export declare function makeClient(baseUrl: string,
|
|
4
|
+
export declare function makeClient(baseUrl: string, cred: Credential, opts?: {
|
|
5
|
+
maxRedirects?: number;
|
|
6
|
+
}): AxiosInstance;
|
|
4
7
|
export interface CommonOpts {
|
|
5
8
|
apiKey?: string;
|
|
9
|
+
/** Explicit credential (hosted MCP: a user's bearer token). Wins over `apiKey`/`NG_API_KEY` — see `credentialOf`. */
|
|
10
|
+
credential?: Credential;
|
|
6
11
|
org?: string;
|
|
7
12
|
url?: string;
|
|
8
13
|
quiet?: boolean;
|
package/dist/src/core/http.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import { resolveApiKey } from './auth.js';
|
|
3
2
|
import { resolveBaseUrl } from './baseUrl.js';
|
|
3
|
+
import { authHeaders, credentialOf } from './credential.js';
|
|
4
4
|
import { createCliError, isCliError } from './errors.js';
|
|
5
|
-
export function makeClient(baseUrl,
|
|
5
|
+
export function makeClient(baseUrl, cred, opts = {}) {
|
|
6
6
|
return axios.create({
|
|
7
|
+
...opts,
|
|
7
8
|
baseURL: baseUrl,
|
|
8
9
|
headers: {
|
|
9
10
|
accept: 'application/json',
|
|
10
|
-
|
|
11
|
+
...authHeaders(cred),
|
|
11
12
|
},
|
|
12
13
|
});
|
|
13
14
|
}
|
|
14
15
|
/** Resolve auth + base URL into a client, or use an injected one (tests). */
|
|
15
16
|
export function clientFor(opts, deps = {}) {
|
|
16
|
-
return deps.client ?? makeClient(resolveBaseUrl({ org: opts.org, url: opts.url }),
|
|
17
|
+
return deps.client ?? makeClient(resolveBaseUrl({ org: opts.org, url: opts.url }), credentialOf(opts));
|
|
17
18
|
}
|
|
18
19
|
export const CLICKHOUSE_API_BASE_URL = 'https://clickhouse.pitcher.com';
|
|
19
20
|
/**
|
|
@@ -30,7 +31,7 @@ export function clickhouseClientFor(opts, deps = {}) {
|
|
|
30
31
|
baseURL: CLICKHOUSE_API_BASE_URL,
|
|
31
32
|
headers: {
|
|
32
33
|
accept: 'application/json',
|
|
33
|
-
|
|
34
|
+
...authHeaders(credentialOf(opts)),
|
|
34
35
|
'x-original-domain': originalDomain,
|
|
35
36
|
},
|
|
36
37
|
});
|
package/dist/src/core/ld.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AxiosInstance } from 'axios';
|
|
2
2
|
import { type CommonOpts } from './http.js';
|
|
3
3
|
import { type CliError } from './errors.js';
|
|
4
4
|
export interface LdCommonOpts extends CommonOpts {
|
|
@@ -8,7 +8,17 @@ export interface LdCommonOpts extends CommonOpts {
|
|
|
8
8
|
export interface LdDeps {
|
|
9
9
|
ldClient?: AxiosInstance;
|
|
10
10
|
platformClient?: AxiosInstance;
|
|
11
|
+
/** Test seam for the bearer → L&D token exchange POST (`{ldBase}/auth/token`). */
|
|
12
|
+
ldAuthClient?: AxiosInstance;
|
|
11
13
|
}
|
|
14
|
+
/** The exact hosts a bearer credential may be sent to. An unusable `LD_ALLOWED_HOSTS` entry fails closed. */
|
|
15
|
+
export declare function ldAllowedHosts(env?: NodeJS.ProcessEnv): Set<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Resolved L&D base URL per (platform host, instance). Bounded in TIME, not just per process: the
|
|
18
|
+
* hosted MCP server is one long-lived process, so an admin re-pointing an instance's ld-app
|
|
19
|
+
* `api_base_url` must take effect without a restart.
|
|
20
|
+
*/
|
|
21
|
+
export declare const LD_BASE_URL_TTL_MS: number;
|
|
12
22
|
/** Test-only: reset the resolved-base-URL cache between cases. */
|
|
13
23
|
export declare function __clearLdBaseUrlCache(): void;
|
|
14
24
|
/** Every L&D run* requires an instance; returns it narrowed to string, else VALIDATION. */
|
|
@@ -29,7 +39,33 @@ export declare function pickTruthy<T extends object>(opts: T, keys: readonly (ke
|
|
|
29
39
|
* over a credential the CLI actually holds.
|
|
30
40
|
*/
|
|
31
41
|
export declare function resolveLdBaseUrl(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<string>;
|
|
32
|
-
/**
|
|
42
|
+
/** Max cached L&D tokens (one per distinct Pitcher token × L&D base). Oldest-used evicted first. */
|
|
43
|
+
export declare const LD_TOKEN_CACHE_MAX = 500;
|
|
44
|
+
/** Test-only: reset the L&D token-exchange cache between cases. */
|
|
45
|
+
export declare function __clearLdTokenCache(): void;
|
|
46
|
+
/** Test-only: current number of cached L&D tokens. */
|
|
47
|
+
export declare function __ldTokenCacheSize(): number;
|
|
48
|
+
/**
|
|
49
|
+
* The client that POSTs the user's token. `maxRedirects: 0` is load-bearing: axios follows 307/308 and
|
|
50
|
+
* RE-SENDS the body, so an allowlisted host answering 307 would forward the token to any host it names —
|
|
51
|
+
* straight past the allowlist. A 3xx is therefore a failed exchange (non-2xx), never followed.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createLdAuthHttp(): AxiosInstance;
|
|
54
|
+
/**
|
|
55
|
+
* Exchange a Pitcher (Auth0) access token for an L&D JWT at `POST {ldBase}/auth/token {token}`.
|
|
56
|
+
* The L&D envelope is `{ success, message, data: { token, refreshToken, user }, statusCode }`, so the
|
|
57
|
+
* JWT is at `res.data.data.token` (ld-app `authController.ts`).
|
|
58
|
+
*
|
|
59
|
+
* Cached until `min(L&D exp, Pitcher exp) − 60 s`: the L&D JWT embeds the Pitcher token, so it is
|
|
60
|
+
* useless once that expires, and its own lifetime is env-configurable (`JWT_EXPIRES_IN`), so neither
|
|
61
|
+
* is assumed. A token whose TTL cannot be read, or is already ≤ 0, is used once and not cached.
|
|
62
|
+
*/
|
|
63
|
+
export declare function exchangeLdToken(ldBase: string, pitcherToken: string, deps: LdDeps, env: NodeJS.ProcessEnv): Promise<string>;
|
|
64
|
+
/**
|
|
65
|
+
* Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base.
|
|
66
|
+
* API key → sent as-is (`x-api-key`). Bearer → exchanged for an L&D JWT first (the Lambda does not
|
|
67
|
+
* accept an Auth0 token directly), and that JWT is sent as the bearer.
|
|
68
|
+
*/
|
|
33
69
|
export declare function ldClientFor(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<AxiosInstance>;
|
|
34
70
|
/**
|
|
35
71
|
* Unwrap the L&D success envelope `{ success, data, message? }` to its resource payload.
|
package/dist/src/core/ld.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { credentialOf } from './credential.js';
|
|
2
4
|
import { clientFor, makeClient, toCliError } from './http.js';
|
|
3
5
|
import { resolveBaseUrl } from './baseUrl.js';
|
|
4
6
|
import { createCliError } from './errors.js';
|
|
@@ -41,6 +43,50 @@ const LD_FALLBACK_BASE_URL = 'https://cgmfr9gjza.execute-api.eu-west-1.amazonaws
|
|
|
41
43
|
* what points each instance at it — the fallback must never silently stand in.
|
|
42
44
|
*/
|
|
43
45
|
const LD_FALLBACK_PLATFORM_ORIGIN = 'https://dev.my.pitcher.com';
|
|
46
|
+
/**
|
|
47
|
+
* Bearer mode (the hosted MCP server) sends the USER's Pitcher Auth0 access token to
|
|
48
|
+
* `POST {ldBase}/auth/token`. `ldBase` can come from `metadata.api_base_url`, which any
|
|
49
|
+
* instance admin can write — so without a gate an admin could point it at a host they own and
|
|
50
|
+
* collect every user's token (plus SSRF from the server). In bearer mode the resolved base
|
|
51
|
+
* must therefore be `https:` on an EXACT allowlisted host (no port, no wildcard, no suffix
|
|
52
|
+
* match — anyone can mint a `*.execute-api` host). Default = the fallback Lambda's host;
|
|
53
|
+
* `LD_ALLOWED_HOSTS` (comma-separated exact hostnames) extends it. API-key mode is unchanged.
|
|
54
|
+
*/
|
|
55
|
+
const LD_DEFAULT_ALLOWED_HOST = new URL(LD_FALLBACK_BASE_URL).host;
|
|
56
|
+
/** The exact hosts a bearer credential may be sent to. An unusable `LD_ALLOWED_HOSTS` entry fails closed. */
|
|
57
|
+
export function ldAllowedHosts(env = process.env) {
|
|
58
|
+
const hosts = new Set([LD_DEFAULT_ALLOWED_HOST]);
|
|
59
|
+
for (const raw of (env.LD_ALLOWED_HOSTS ?? '').split(',')) {
|
|
60
|
+
const entry = raw.trim().toLowerCase();
|
|
61
|
+
if (!entry)
|
|
62
|
+
continue;
|
|
63
|
+
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(entry)) {
|
|
64
|
+
throw createCliError('VALIDATION', `LD_ALLOWED_HOSTS entries must be exact hostnames (no wildcard/port/scheme): ${entry}`);
|
|
65
|
+
}
|
|
66
|
+
hosts.add(entry);
|
|
67
|
+
}
|
|
68
|
+
return hosts;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fail CLOSED unless `url` is safe to receive a bearer token. The message names the host, never the
|
|
72
|
+
* token. Called at resolution AND immediately before the exchange POST (defence in depth).
|
|
73
|
+
*/
|
|
74
|
+
function assertBearerLdBase(url, env) {
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = new URL(url);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw createCliError('VALIDATION', 'LD_HOST_NOT_ALLOWED: the L&D base URL is not a valid URL');
|
|
81
|
+
}
|
|
82
|
+
if (parsed.protocol !== 'https:') {
|
|
83
|
+
throw createCliError('VALIDATION', `LD_HOST_NOT_ALLOWED: the L&D base URL must be https (got ${parsed.protocol})`);
|
|
84
|
+
}
|
|
85
|
+
if (!ldAllowedHosts(env).has(parsed.host)) {
|
|
86
|
+
throw createCliError('VALIDATION', `LD_HOST_NOT_ALLOWED: ${parsed.host} is not an allowed L&D host for a signed-in user. ` +
|
|
87
|
+
`Add it to LD_ALLOWED_HOSTS on the server if it is a genuine L&D deployment.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
44
90
|
/**
|
|
45
91
|
* Safety stop for the installed-apps scan. At 500 records/page this is 10k apps in one
|
|
46
92
|
* instance — unreachable in practice, so tripping it means the server kept advertising a
|
|
@@ -51,6 +97,12 @@ const MAX_APP_PAGES = 20;
|
|
|
51
97
|
function isRecord(v) {
|
|
52
98
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
53
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolved L&D base URL per (platform host, instance). Bounded in TIME, not just per process: the
|
|
102
|
+
* hosted MCP server is one long-lived process, so an admin re-pointing an instance's ld-app
|
|
103
|
+
* `api_base_url` must take effect without a restart.
|
|
104
|
+
*/
|
|
105
|
+
export const LD_BASE_URL_TTL_MS = 10 * 60000;
|
|
54
106
|
const baseUrlCache = new Map();
|
|
55
107
|
/** Test-only: reset the resolved-base-URL cache between cases. */
|
|
56
108
|
export function __clearLdBaseUrlCache() {
|
|
@@ -176,6 +228,13 @@ async function fetchLdAppMetadata(platform, instanceId) {
|
|
|
176
228
|
* over a credential the CLI actually holds.
|
|
177
229
|
*/
|
|
178
230
|
export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
231
|
+
const url = await resolveLdBaseUrlUnchecked(opts, deps, env);
|
|
232
|
+
// Every source (flag, env, instance metadata, fallback, cache) is checked, on every call.
|
|
233
|
+
if (opts.credential?.kind === 'bearer')
|
|
234
|
+
assertBearerLdBase(url, env);
|
|
235
|
+
return url;
|
|
236
|
+
}
|
|
237
|
+
async function resolveLdBaseUrlUnchecked(opts, deps, env) {
|
|
179
238
|
const override = opts.ldUrl?.trim() || env.NG_LD_API_URL?.trim();
|
|
180
239
|
if (override)
|
|
181
240
|
return trimUrl(override, opts.ldUrl ? '--ld-url' : 'NG_LD_API_URL');
|
|
@@ -186,8 +245,8 @@ export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
|
186
245
|
// different platform hosts collide on one cached L&D URL within a single process.
|
|
187
246
|
const cacheKey = `${opts.url ?? opts.org ?? 'dev'}:${opts.instanceId}`;
|
|
188
247
|
const cached = baseUrlCache.get(cacheKey);
|
|
189
|
-
if (cached)
|
|
190
|
-
return cached;
|
|
248
|
+
if (cached && cached.expiresAt > Date.now())
|
|
249
|
+
return cached.url;
|
|
191
250
|
const platform = deps.platformClient ?? clientFor(opts);
|
|
192
251
|
const metadata = await fetchLdAppMetadata(platform, opts.instanceId);
|
|
193
252
|
if (!metadata) {
|
|
@@ -209,15 +268,112 @@ export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
|
209
268
|
}
|
|
210
269
|
url = LD_FALLBACK_BASE_URL;
|
|
211
270
|
}
|
|
212
|
-
baseUrlCache.set(cacheKey, url);
|
|
271
|
+
baseUrlCache.set(cacheKey, { url, expiresAt: Date.now() + LD_BASE_URL_TTL_MS });
|
|
213
272
|
return url;
|
|
214
273
|
}
|
|
215
|
-
/**
|
|
274
|
+
/** Max cached L&D tokens (one per distinct Pitcher token × L&D base). Oldest-used evicted first. */
|
|
275
|
+
export const LD_TOKEN_CACHE_MAX = 500;
|
|
276
|
+
/** Safety margin subtracted from the earliest expiry, so a cached token is never used at the edge. */
|
|
277
|
+
const LD_TOKEN_SKEW_MS = 60000;
|
|
278
|
+
/**
|
|
279
|
+
* Bearer → L&D token cache, keyed `sha256(pitcherToken)|ldBase` (the raw token is never a key).
|
|
280
|
+
* A `Map` iterates in insertion order, so re-inserting on hit makes the first key the LRU one.
|
|
281
|
+
*/
|
|
282
|
+
const ldTokenCache = new Map();
|
|
283
|
+
/** Test-only: reset the L&D token-exchange cache between cases. */
|
|
284
|
+
export function __clearLdTokenCache() {
|
|
285
|
+
ldTokenCache.clear();
|
|
286
|
+
}
|
|
287
|
+
/** Test-only: current number of cached L&D tokens. */
|
|
288
|
+
export function __ldTokenCacheSize() {
|
|
289
|
+
return ldTokenCache.size;
|
|
290
|
+
}
|
|
291
|
+
/** `exp` (seconds) of a JWT, read WITHOUT verification — only ever used to bound a cache TTL. */
|
|
292
|
+
function jwtExpMs(token) {
|
|
293
|
+
const payload = token.split('.')[1];
|
|
294
|
+
if (!payload)
|
|
295
|
+
return undefined;
|
|
296
|
+
try {
|
|
297
|
+
const exp = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).exp;
|
|
298
|
+
return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : undefined;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The client that POSTs the user's token. `maxRedirects: 0` is load-bearing: axios follows 307/308 and
|
|
306
|
+
* RE-SENDS the body, so an allowlisted host answering 307 would forward the token to any host it names —
|
|
307
|
+
* straight past the allowlist. A 3xx is therefore a failed exchange (non-2xx), never followed.
|
|
308
|
+
*/
|
|
309
|
+
export function createLdAuthHttp() {
|
|
310
|
+
return axios.create({ headers: { accept: 'application/json' }, timeout: 10000, maxRedirects: 0 });
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Exchange a Pitcher (Auth0) access token for an L&D JWT at `POST {ldBase}/auth/token {token}`.
|
|
314
|
+
* The L&D envelope is `{ success, message, data: { token, refreshToken, user }, statusCode }`, so the
|
|
315
|
+
* JWT is at `res.data.data.token` (ld-app `authController.ts`).
|
|
316
|
+
*
|
|
317
|
+
* Cached until `min(L&D exp, Pitcher exp) − 60 s`: the L&D JWT embeds the Pitcher token, so it is
|
|
318
|
+
* useless once that expires, and its own lifetime is env-configurable (`JWT_EXPIRES_IN`), so neither
|
|
319
|
+
* is assumed. A token whose TTL cannot be read, or is already ≤ 0, is used once and not cached.
|
|
320
|
+
*/
|
|
321
|
+
export async function exchangeLdToken(ldBase, pitcherToken, deps, env) {
|
|
322
|
+
// Re-checked at the point the token leaves, independent of how `ldBase` was obtained.
|
|
323
|
+
assertBearerLdBase(ldBase, env);
|
|
324
|
+
const key = `${createHash('sha256').update(pitcherToken).digest('hex')}|${ldBase}`;
|
|
325
|
+
const hit = ldTokenCache.get(key);
|
|
326
|
+
if (hit) {
|
|
327
|
+
ldTokenCache.delete(key);
|
|
328
|
+
if (hit.expiresAt > Date.now()) {
|
|
329
|
+
ldTokenCache.set(key, hit);
|
|
330
|
+
return hit.token;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
let ldToken;
|
|
334
|
+
try {
|
|
335
|
+
const http = deps.ldAuthClient ?? createLdAuthHttp();
|
|
336
|
+
const res = await http.post(`${ldBase}/auth/token`, { token: pitcherToken });
|
|
337
|
+
ldToken = res.data?.data?.token;
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
const err = toLdCliError(e);
|
|
341
|
+
throw createCliError('AUTH', `L&D token exchange failed: ${err.message}`);
|
|
342
|
+
}
|
|
343
|
+
if (typeof ldToken !== 'string' || !ldToken) {
|
|
344
|
+
throw createCliError('AUTH', 'L&D token exchange failed: no token in the /auth/token response');
|
|
345
|
+
}
|
|
346
|
+
const ldExp = jwtExpMs(ldToken);
|
|
347
|
+
const pitcherExp = jwtExpMs(pitcherToken);
|
|
348
|
+
if (ldExp !== undefined && pitcherExp !== undefined) {
|
|
349
|
+
const expiresAt = Math.min(ldExp, pitcherExp) - LD_TOKEN_SKEW_MS;
|
|
350
|
+
if (expiresAt > Date.now()) {
|
|
351
|
+
ldTokenCache.set(key, { token: ldToken, expiresAt });
|
|
352
|
+
while (ldTokenCache.size > LD_TOKEN_CACHE_MAX) {
|
|
353
|
+
const oldest = ldTokenCache.keys().next().value;
|
|
354
|
+
if (oldest === undefined)
|
|
355
|
+
break;
|
|
356
|
+
ldTokenCache.delete(oldest);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return ldToken;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base.
|
|
364
|
+
* API key → sent as-is (`x-api-key`). Bearer → exchanged for an L&D JWT first (the Lambda does not
|
|
365
|
+
* accept an Auth0 token directly), and that JWT is sent as the bearer.
|
|
366
|
+
*/
|
|
216
367
|
export async function ldClientFor(opts, deps = {}, env = process.env) {
|
|
217
368
|
if (deps.ldClient)
|
|
218
369
|
return deps.ldClient;
|
|
219
370
|
const base = await resolveLdBaseUrl(opts, deps, env);
|
|
220
|
-
|
|
371
|
+
const cred = credentialOf(opts);
|
|
372
|
+
if (cred.kind === 'apiKey')
|
|
373
|
+
return makeClient(base, cred);
|
|
374
|
+
// No redirects for a bearer either: the L&D JWT embeds the user's Pitcher token.
|
|
375
|
+
const ldToken = await exchangeLdToken(base, cred.token, deps, env);
|
|
376
|
+
return makeClient(base, { kind: 'bearer', token: ldToken }, { maxRedirects: 0 });
|
|
221
377
|
}
|
|
222
378
|
/**
|
|
223
379
|
* Unwrap the L&D success envelope `{ success, data, message? }` to its resource payload.
|
|
@@ -48,9 +48,16 @@ export function resolveTextInput(inline, file, label) {
|
|
|
48
48
|
// Resolve an inline-or-file JSON flag and parse it. VALIDATION when neither is given
|
|
49
49
|
// (`provide --<label> (inline JSON) or --<label>-file`) or the content is not valid JSON.
|
|
50
50
|
// Used by the L&D canvas / question / assessment-link inputs.
|
|
51
|
+
//
|
|
52
|
+
// The "neither" test is `=== undefined`, NOT truthiness: `resolveTextInput` returns `''` for a
|
|
53
|
+
// ZERO-BYTE file, and a falsy test would report that as "you did not pass the flag" when the
|
|
54
|
+
// caller passed `--<label>-file empty.json` and the file is simply empty. An empty string is
|
|
55
|
+
// content, so let it reach `JSON.parse` and be named for what it is — not valid JSON. (An empty
|
|
56
|
+
// INLINE value still lands on the "neither" message: `resolveTextInput`'s own XOR is truthy-based,
|
|
57
|
+
// so it returns undefined for `--<label> ''` before this line runs.)
|
|
51
58
|
export function parseJsonInput(inline, file, label) {
|
|
52
59
|
const raw = resolveTextInput(inline, file, label);
|
|
53
|
-
if (
|
|
60
|
+
if (raw === undefined)
|
|
54
61
|
throw createCliError('VALIDATION', `provide --${label} (inline JSON) or --${label}-file`);
|
|
55
62
|
try {
|
|
56
63
|
return JSON.parse(raw);
|
package/dist/src/mcp/bundled.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { allTools } from './registry.js';
|
|
2
2
|
// Developer-only tools excluded from the consumer .mcpb bundle. `publish_app` is alwaysDestructive
|
|
3
3
|
// (creates a live, org-visible app release); `dev_sync`/`dev_sync_revert` mutate remote S3 dev state.
|
|
4
|
-
// None are relevant to a non-technical adopter installing the extension. The CLI `p mcp`
|
|
4
|
+
// None are relevant to a non-technical adopter installing the extension. The CLI `p mcp` registers every tool.
|
|
5
5
|
export const BUNDLE_EXCLUDED_TOOLS = new Set(['pitcher_publish_app', 'pitcher_dev_sync', 'pitcher_dev_sync_revert']);
|
|
6
6
|
// The tool set the .mcpb bundle registers.
|
|
7
7
|
export const bundledTools = allTools.filter((t) => !BUNDLE_EXCLUDED_TOOLS.has(t.name));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BuildServerOptions } from './server.js';
|
|
2
|
+
import type { ToolDescriptor } from './tools/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The tool set served by the hosted (remote, OAuth) MCP server — PIT-8912.
|
|
5
|
+
*
|
|
6
|
+
* Differences from stdio `p mcp`:
|
|
7
|
+
* - the three disk-bound tools (`LOCAL_ONLY`) are dropped: they zip a folder on the server host;
|
|
8
|
+
* - every file-path input (`FILE_KEY`) is stripped from the schemas AND rejected at runtime, so a
|
|
9
|
+
* hosted call can never make the server read its own disk; every one has an inline alternative;
|
|
10
|
+
* - descriptions that talk about local files are rewritten.
|
|
11
|
+
*/
|
|
12
|
+
export declare const LOCAL_ONLY: ReadonlySet<string>;
|
|
13
|
+
/**
|
|
14
|
+
* A file-path input. Covers the 8 schema keys on main (`filePath`, `configFile`, `landingContextFile`,
|
|
15
|
+
* `htmlFile`, `cssFile`, `bodyFile`, `fromFile`, `sectionsFile`) AND command-layer keys no schema
|
|
16
|
+
* exposes today (`questionFile`, `answersFile`, `canvasFile`, `assessmentsFile`, `contentHtmlFile`).
|
|
17
|
+
*/
|
|
18
|
+
export declare const FILE_KEY: RegExp;
|
|
19
|
+
/** Build options every hosted server uses: a write always asks the client to confirm. */
|
|
20
|
+
export declare const HOSTED_BUILD_OPTIONS: Readonly<BuildServerOptions>;
|
|
21
|
+
export declare const hostedTools: ToolDescriptor[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { fail } from '../core/envelope.js';
|
|
2
|
+
import { createCliError } from '../core/errors.js';
|
|
3
|
+
import { allTools } from './registry.js';
|
|
4
|
+
/**
|
|
5
|
+
* The tool set served by the hosted (remote, OAuth) MCP server — PIT-8912.
|
|
6
|
+
*
|
|
7
|
+
* Differences from stdio `p mcp`:
|
|
8
|
+
* - the three disk-bound tools (`LOCAL_ONLY`) are dropped: they zip a folder on the server host;
|
|
9
|
+
* - every file-path input (`FILE_KEY`) is stripped from the schemas AND rejected at runtime, so a
|
|
10
|
+
* hosted call can never make the server read its own disk; every one has an inline alternative;
|
|
11
|
+
* - descriptions that talk about local files are rewritten.
|
|
12
|
+
*/
|
|
13
|
+
export const LOCAL_ONLY = new Set([
|
|
14
|
+
'pitcher_dev_sync',
|
|
15
|
+
'pitcher_dev_sync_revert',
|
|
16
|
+
'pitcher_publish_app',
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* A file-path input. Covers the 8 schema keys on main (`filePath`, `configFile`, `landingContextFile`,
|
|
20
|
+
* `htmlFile`, `cssFile`, `bodyFile`, `fromFile`, `sectionsFile`) AND command-layer keys no schema
|
|
21
|
+
* exposes today (`questionFile`, `answersFile`, `canvasFile`, `assessmentsFile`, `contentHtmlFile`).
|
|
22
|
+
*/
|
|
23
|
+
export const FILE_KEY = /File$|^filePath$/;
|
|
24
|
+
/** Build options every hosted server uses: a write always asks the client to confirm. */
|
|
25
|
+
export const HOSTED_BUILD_OPTIONS = Object.freeze({ destructiveWrites: true });
|
|
26
|
+
// Tool descriptions rewritten without the local-file wording (the stdio text is kept for p mcp).
|
|
27
|
+
const HOSTED_DESCRIPTIONS = {
|
|
28
|
+
pitcher_files_create: 'Create a content file in an instance from a source URL. Additive.',
|
|
29
|
+
pitcher_files_update: 'Update a file by ID (name, folder, access, tags, metadata). sourceUrl replaces/overwrites the file binary; metadata is deep-merged.',
|
|
30
|
+
pitcher_announcements_create: 'Create a wa-announcements appsDB record (title/body, optional media, CTA link, scheduling, group targeting). Requires title and body. Additive.',
|
|
31
|
+
pitcher_dsr_create: 'Create a Digital Sales Room (a canvas share link) and get a clickable link back. Source is a canvas (canvasId) OR files (shareboxFileIds) - exactly one. Optional email/domain gating and a bound landing-page template. Returns the share: id, url (the full link), short_url, and a nested canvas object - there is no full_url field. This endpoint is a GET-OR-CREATE: sharing an already-shared canvas returns the existing link and only updates the fields you pass, so use pitcher_dsr_update to change or remove a value on an existing share. A failed landing bind does not fail the call: it returns the link with a warning - and binding a landing page needs instanceId, without which the bind is skipped with that warning. tags are a ShareBox-only field (they name the canvas the server builds from shareboxFileIds), so non-empty tags together with canvasId are REJECTED rather than silently dropped by the server.',
|
|
32
|
+
pitcher_metadata_template_upsert: "Upsert an instance's metadata-field schema. Merges the incoming sections by section 'label' — a section whose label matches an existing one is replaced, other sections are preserved. Cannot remove a section or field. Requires an instance- or org-admin role.",
|
|
33
|
+
};
|
|
34
|
+
// Field descriptions that name a stripped key, rewritten for hosted (`tool name → field → text`).
|
|
35
|
+
const HOSTED_FIELD_DESCRIPTIONS = {
|
|
36
|
+
pitcher_files_update: {
|
|
37
|
+
instanceId: 'Instance ID (optional; not needed to update from a sourceUrl)',
|
|
38
|
+
},
|
|
39
|
+
pitcher_announcements_create: {
|
|
40
|
+
title: 'Announcement title',
|
|
41
|
+
body: 'Announcement body HTML',
|
|
42
|
+
priority: "Priority, e.g. 'normal' or 'important'",
|
|
43
|
+
},
|
|
44
|
+
pitcher_dsr_landing_create: {
|
|
45
|
+
html: 'Landing HTML (must contain data-pitcher-email-gate)',
|
|
46
|
+
css: 'CSS for the template',
|
|
47
|
+
},
|
|
48
|
+
pitcher_dsr_create: {
|
|
49
|
+
ifExists: 'Canvas already shared: "return" (default) hands back the existing link, "error" refuses and points at pitcher_dsr_update. Not checked for a ShareBox source, which always mints a new canvas',
|
|
50
|
+
},
|
|
51
|
+
pitcher_email_template_create: {
|
|
52
|
+
bodyHtml: 'HTML body (inline CSS).',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
function hostedShape(t) {
|
|
56
|
+
const fields = HOSTED_FIELD_DESCRIPTIONS[t.name] ?? {};
|
|
57
|
+
const shape = {};
|
|
58
|
+
for (const [key, schema] of Object.entries(t.inputShape)) {
|
|
59
|
+
if (FILE_KEY.test(key))
|
|
60
|
+
continue;
|
|
61
|
+
shape[key] = fields[key] !== undefined ? schema.describe(fields[key]) : schema;
|
|
62
|
+
}
|
|
63
|
+
return shape;
|
|
64
|
+
}
|
|
65
|
+
function toHosted(t) {
|
|
66
|
+
return {
|
|
67
|
+
...t,
|
|
68
|
+
description: HOSTED_DESCRIPTIONS[t.name] ?? t.description,
|
|
69
|
+
inputShape: hostedShape(t),
|
|
70
|
+
// Runtime guard, independent of the schema: the stripped keys are invisible to a well-behaved
|
|
71
|
+
// client, but this refuses any file-path key however it arrives (a schema that grows a new
|
|
72
|
+
// xFile key, a caller bypassing validation, a command-layer key no schema declares).
|
|
73
|
+
run: (opts, deps) => {
|
|
74
|
+
const bad = Object.keys(opts).filter((k) => FILE_KEY.test(k) && opts[k] !== undefined);
|
|
75
|
+
if (bad.length) {
|
|
76
|
+
return Promise.resolve(fail(createCliError('VALIDATION', `not supported on the hosted server (no local files): ${bad.join(', ')}`)));
|
|
77
|
+
}
|
|
78
|
+
return t.run(opts, deps);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export const hostedTools = allTools.filter((t) => !LOCAL_ONLY.has(t.name)).map(toHosted);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { buildServer, type McpRunContext, type BuildServerOptions } from './server.js';
|
|
2
|
+
export { hostedTools, LOCAL_ONLY, FILE_KEY, HOSTED_BUILD_OPTIONS } from './hosted.js';
|
|
3
|
+
export { allTools } from './registry.js';
|
|
4
|
+
export { allPrompts } from './prompts/registry.js';
|
|
5
|
+
export { allResources } from './resources/registry.js';
|
|
6
|
+
export type { ToolDescriptor } from './tools/types.js';
|
|
7
|
+
export type { Credential } from '../core/credential.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public entry `@pitcher/cli-scripts/mcp` — what the hosted MCP server (web/packages/mcp-server) consumes.
|
|
2
|
+
export { buildServer } from './server.js';
|
|
3
|
+
export { hostedTools, LOCAL_ONLY, FILE_KEY, HOSTED_BUILD_OPTIONS } from './hosted.js';
|
|
4
|
+
export { allTools } from './registry.js';
|
|
5
|
+
export { allPrompts } from './prompts/registry.js';
|
|
6
|
+
export { allResources } from './resources/registry.js';
|
package/dist/src/mcp/serve.js
CHANGED
|
@@ -9,7 +9,7 @@ import { allResources } from './resources/registry.js';
|
|
|
9
9
|
// Prompts/resources default to the full set for every frontend (no subsetting).
|
|
10
10
|
export async function runMcpServe(env = process.env, tools = allTools, prompts = allPrompts, resources = allResources) {
|
|
11
11
|
const mcpEnv = resolveMcpEnv(env);
|
|
12
|
-
const server = buildServer(mcpEnv, tools, prompts, resources);
|
|
12
|
+
const server = buildServer({ credential: { kind: 'apiKey', key: mcpEnv.apiKey }, org: mcpEnv.org, url: mcpEnv.url }, tools, prompts, resources, { destructiveWrites: mcpEnv.prod });
|
|
13
13
|
const transport = new StdioServerTransport();
|
|
14
14
|
await server.connect(transport);
|
|
15
15
|
const target = mcpEnv.url ?? `https://${mcpEnv.org}.my.pitcher.com`;
|
package/dist/src/mcp/server.d.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Credential } from '../core/credential.js';
|
|
3
3
|
import type { ToolDescriptor } from './tools/types.js';
|
|
4
4
|
import type { PromptDescriptor } from './prompts/types.js';
|
|
5
5
|
import type { ResourceDescriptor } from './resources/types.js';
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The per-server request context: who every tool call runs as, and against which origin.
|
|
8
|
+
* A plain value captured at build time — stdio builds one from env (API key); the hosted server
|
|
9
|
+
* builds one server per request from the caller's bearer token. Never a tool input.
|
|
10
|
+
*/
|
|
11
|
+
export interface McpRunContext {
|
|
12
|
+
credential: Credential;
|
|
13
|
+
org?: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface BuildServerOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Mark every `write` tool `destructiveHint: true`. Annotations are fixed at registration, so this
|
|
19
|
+
* is a build option, never a per-call field. Stdio passes `env.prod`; hosted always passes true.
|
|
20
|
+
*/
|
|
21
|
+
destructiveWrites?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function buildServer(ctx: McpRunContext, tools?: ToolDescriptor[], prompts?: PromptDescriptor[], resources?: ResourceDescriptor[], opts?: BuildServerOptions): McpServer;
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -33,7 +33,7 @@ function errorText(e) {
|
|
|
33
33
|
return `${e.code}: ${e.message}`;
|
|
34
34
|
return e instanceof Error ? e.message : String(e);
|
|
35
35
|
}
|
|
36
|
-
export function buildServer(
|
|
36
|
+
export function buildServer(ctx, tools = allTools, prompts = allPrompts, resources = allResources, opts = {}) {
|
|
37
37
|
const server = new McpServer({
|
|
38
38
|
name: 'pitcher',
|
|
39
39
|
version,
|
|
@@ -47,7 +47,7 @@ export function buildServer(env, tools = allTools, prompts = allPrompts, resourc
|
|
|
47
47
|
// dashboard) instead of a rigid full dashboard every time. See renderPolicy.ts.
|
|
48
48
|
{ instructions: ANALYTICS_RENDER_POLICY });
|
|
49
49
|
for (const tool of tools) {
|
|
50
|
-
const destructive = tool.alwaysDestructive === true || (tool.write === true &&
|
|
50
|
+
const destructive = tool.alwaysDestructive === true || (tool.write === true && opts.destructiveWrites === true);
|
|
51
51
|
server.registerTool(tool.name, {
|
|
52
52
|
title: tool.title,
|
|
53
53
|
description: tool.description,
|
|
@@ -55,8 +55,8 @@ export function buildServer(env, tools = allTools, prompts = allPrompts, resourc
|
|
|
55
55
|
annotations: { readOnlyHint: tool.readOnly, destructiveHint: destructive },
|
|
56
56
|
}, async (args) => {
|
|
57
57
|
try {
|
|
58
|
-
// spread args FIRST so injected auth/env always wins — never reorder to {
|
|
59
|
-
const envelope = await tool.run({ ...args,
|
|
58
|
+
// spread args FIRST so injected auth/env always wins — never reorder to { credential, ...args }
|
|
59
|
+
const envelope = await tool.run({ ...args, credential: ctx.credential, org: ctx.org, url: ctx.url });
|
|
60
60
|
return envelopeToToolResult(envelope);
|
|
61
61
|
}
|
|
62
62
|
catch (e) {
|
|
@@ -25,7 +25,7 @@ export const analyticsTools = [
|
|
|
25
25
|
'(inventory counts like total-members do not).',
|
|
26
26
|
inputShape: {
|
|
27
27
|
instanceId: z.string().describe('Instance ID (required)'),
|
|
28
|
-
type: z.string().
|
|
28
|
+
type: z.string().describe('KPI type, e.g. total-members, total-active-users (required)'),
|
|
29
29
|
startDate: z.string().optional().describe('Window start, ISO date (required by the server for activity metrics)'),
|
|
30
30
|
endDate: z.string().optional().describe('Window end, ISO date (required by the server for activity metrics)'),
|
|
31
31
|
groupIds: z.string().optional().describe('Comma-separated distribution-group IDs to scope by'),
|
|
@@ -27,7 +27,7 @@ export const devSyncTools = [
|
|
|
27
27
|
const fileId = requireFileId(opts);
|
|
28
28
|
if (!fileId)
|
|
29
29
|
return Promise.resolve(fail(createCliError('VALIDATION', 'fileId is required')));
|
|
30
|
-
return runDevSync({ fileId, apiKey: opts.apiKey, org: opts.org }, deps);
|
|
30
|
+
return runDevSync({ fileId, apiKey: opts.apiKey, credential: opts.credential, org: opts.org }, deps);
|
|
31
31
|
},
|
|
32
32
|
wraps: runDevSync,
|
|
33
33
|
readOnly: false,
|
|
@@ -44,7 +44,7 @@ export const devSyncTools = [
|
|
|
44
44
|
const fileId = requireFileId(opts);
|
|
45
45
|
if (!fileId)
|
|
46
46
|
return Promise.resolve(fail(createCliError('VALIDATION', 'fileId is required')));
|
|
47
|
-
return runRevert({ fileId, apiKey: opts.apiKey, org: opts.org }, deps);
|
|
47
|
+
return runRevert({ fileId, apiKey: opts.apiKey, credential: opts.credential, org: opts.org }, deps);
|
|
48
48
|
},
|
|
49
49
|
wraps: runRevert,
|
|
50
50
|
readOnly: false,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { runDsrCreate, runDsrBindLanding, runDsrUpdate, runDsrDelete, CLEARABLE_UPDATE_FIELDS, IF_EXISTS_MODES } from '../../commands/dsr.js';
|
|
2
|
+
import { runDsrCreate, runDsrGet, runDsrBindLanding, runDsrUpdate, runDsrDelete, CLEARABLE_UPDATE_FIELDS, IF_EXISTS_MODES } from '../../commands/dsr.js';
|
|
3
3
|
const AUTH_STRATEGIES = [
|
|
4
4
|
'none', 'any_email', 'specified_emails', 'specified_domains', 'verified_emails', 'verified_domains',
|
|
5
5
|
];
|
|
@@ -33,6 +33,10 @@ export const dsrTools = [
|
|
|
33
33
|
.optional()
|
|
34
34
|
.describe('Bind a landing-page template (discover via pitcher_dsr_landing_list). Needs instanceId: without it the bind is skipped and the share comes back with a warning'),
|
|
35
35
|
landingContext: z.string().optional().describe('Landing-page merge fields, a JSON object as a string'),
|
|
36
|
+
landingContextFile: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Path on the MCP server host to a JSON file holding the landing-page merge fields - the same field as landingContext, read off disk. Pass one or the other, never both (both is rejected as VALIDATION). Prefer landingContext inline: in a host whose sandbox is a separate filesystem from the MCP subprocess, a path written there is not visible here'),
|
|
36
40
|
configFile: z.string().optional().describe('Path on the MCP server host to a full {source,security,landing} spec JSON; mutually exclusive with the individual flags'),
|
|
37
41
|
ifExists: z
|
|
38
42
|
.enum(IF_EXISTS_MODES)
|
|
@@ -43,6 +47,16 @@ export const dsrTools = [
|
|
|
43
47
|
readOnly: false,
|
|
44
48
|
write: true,
|
|
45
49
|
},
|
|
50
|
+
{
|
|
51
|
+
name: 'pitcher_dsr_get',
|
|
52
|
+
title: 'Read a DSR share',
|
|
53
|
+
description: "Read an EXISTING DSR share back by its shared-link id. It returns the ACCESS CONFIG only: auth_strategy, allowed_emails / allowed_domains, valid_until, allow_forward (plus allow_forward_raw, the rep's own toggle before the admin setting is applied), the bound landing page (landing_page_template_id, landing_page_template_snapshot, landing_page_context) and created_at / modified_at. It does NOT return the canvas or the file - keep the id pitcher_dsr_create handed back for those. Use it to confirm what pitcher_dsr_create or pitcher_dsr_update actually stored, or to check a share before changing it. Read-only - it changes nothing. ANY id that matches no share comes back NOT_FOUND, malformed ids included: the endpoint does not validate the id's format.",
|
|
54
|
+
inputShape: {
|
|
55
|
+
id: z.string().describe('Shared-link ID'),
|
|
56
|
+
},
|
|
57
|
+
run: runDsrGet,
|
|
58
|
+
readOnly: true,
|
|
59
|
+
},
|
|
46
60
|
{
|
|
47
61
|
name: 'pitcher_dsr_bind_landing',
|
|
48
62
|
title: 'Bind a DSR landing page',
|
|
@@ -51,6 +65,10 @@ export const dsrTools = [
|
|
|
51
65
|
id: z.string().describe('Shared-link ID'),
|
|
52
66
|
landingTemplateId: z.string().describe('Landing template ID, or "none" to unbind'),
|
|
53
67
|
landingContext: z.string().optional().describe('Landing-page merge fields, a JSON object as a string'),
|
|
68
|
+
landingContextFile: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Path on the MCP server host to a JSON file holding the merge fields - the same field as landingContext, read off disk. Pass one or the other, never both (both is rejected as VALIDATION). Prefer landingContext inline: in a host whose sandbox is a separate filesystem from the MCP subprocess, a path written there is not visible here'),
|
|
54
72
|
instanceId: z
|
|
55
73
|
.string()
|
|
56
74
|
.describe('Instance ID. The endpoint resolves the instance from this query param and rejects the call without it'),
|