@pitcher/cli-scripts 2.9.0 → 2.11.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.
Files changed (42) hide show
  1. package/README.md +19 -1
  2. package/dist/index.js +0 -0
  3. package/dist/src/commands/canvases.js +11 -8
  4. package/dist/src/commands/dsrLanding.d.ts +2 -0
  5. package/dist/src/commands/dsrLanding.js +18 -10
  6. package/dist/src/commands/emailTemplate.d.ts +1 -0
  7. package/dist/src/commands/emailTemplate.js +8 -4
  8. package/dist/src/core/readInput.d.ts +2 -0
  9. package/dist/src/core/readInput.js +24 -0
  10. package/dist/src/mcp/bundled.d.ts +4 -0
  11. package/dist/src/mcp/bundled.js +25 -0
  12. package/dist/src/mcp/prompts/announcements.d.ts +2 -0
  13. package/dist/src/mcp/prompts/announcements.js +35 -0
  14. package/dist/src/mcp/prompts/dsr.d.ts +2 -0
  15. package/dist/src/mcp/prompts/dsr.js +50 -0
  16. package/dist/src/mcp/prompts/dsrLanding.d.ts +2 -0
  17. package/dist/src/mcp/prompts/dsrLanding.js +40 -0
  18. package/dist/src/mcp/prompts/emailTemplate.d.ts +2 -0
  19. package/dist/src/mcp/prompts/emailTemplate.js +34 -0
  20. package/dist/src/mcp/prompts/registry.d.ts +2 -0
  21. package/dist/src/mcp/prompts/registry.js +6 -0
  22. package/dist/src/mcp/prompts/shared.d.ts +3 -0
  23. package/dist/src/mcp/prompts/shared.js +31 -0
  24. package/dist/src/mcp/prompts/types.d.ts +9 -0
  25. package/dist/src/mcp/prompts/types.js +1 -0
  26. package/dist/src/mcp/resources/contentDesign.d.ts +3 -0
  27. package/dist/src/mcp/resources/contentDesign.js +62 -0
  28. package/dist/src/mcp/resources/registry.d.ts +2 -0
  29. package/dist/src/mcp/resources/registry.js +3 -0
  30. package/dist/src/mcp/resources/types.d.ts +8 -0
  31. package/dist/src/mcp/resources/types.js +1 -0
  32. package/dist/src/mcp/result.d.ts +2 -1
  33. package/dist/src/mcp/result.js +71 -11
  34. package/dist/src/mcp/serve.d.ts +4 -1
  35. package/dist/src/mcp/serve.js +8 -3
  36. package/dist/src/mcp/server.d.ts +4 -1
  37. package/dist/src/mcp/server.js +50 -3
  38. package/dist/src/mcp/tools/canvases.js +74 -7
  39. package/dist/src/mcp/tools/dsrLanding.js +10 -2
  40. package/dist/src/mcp/tools/emailTemplate.js +4 -0
  41. package/dist/src/mcp/tools/instances.js +57 -3
  42. package/package.json +6 -2
package/README.md CHANGED
@@ -45,6 +45,18 @@ One config entry = one org, because the API key is org-specific. To work against
45
45
  - Read commands are marked `readOnlyHint`. `dev-sync` counts as a write (it mutates remote dev-override state).
46
46
  - `publish-app` is always marked `destructiveHint` (it creates a live, org-visible release). Other write tools get `destructiveHint` only when the resolved environment is production (`NG_ENV=prod`, or `NG_ORG` other than `dev` with `NG_ENV` unset). This steers hosts toward requiring extra confirmation before mutating a production org.
47
47
 
48
+ ### Desktop Extension (`.mcpb`)
49
+
50
+ For one-click install in Claude Desktop (name + icon, an install-time API-key/org form, no hand-edited config, no Node prerequisite), build a `.mcpb` bundle:
51
+
52
+ ```bash
53
+ yarn mcpb:pack # → pitcher.mcpb (validated + packed)
54
+ ```
55
+
56
+ Install by double-clicking `pitcher.mcpb` (or Settings → Extensions → Install Extension). Claude Desktop prompts for the API key (stored in the OS keychain) and organization; both are editable afterward, so switching orgs needs no reinstall.
57
+
58
+ The bundle reuses the same server as `p mcp` but registers a **content-tool subset (47)** — it omits the developer-only `publish-app` and `dev-sync`/`dev-sync-revert`. The `organization` field accepts a subdomain (`acme`), a bare hostname (`acme.my.pitcher.com`), or a full `https://` URL. Build details (esbuild single-file, version injection) live in `mcpb/` and `CLAUDE.md`.
59
+
48
60
  This section is a reference only. For app-development conventions (modules, settings, mount points, SFDC), see the `pitcher-apps` skill, not this README.
49
61
 
50
62
  ## To run the `publish-app` command:
@@ -244,6 +256,9 @@ Create/list/get HTML email templates for the emails app. `create`, `list`, and `
244
256
  # Create a template from an HTML file (inline CSS)
245
257
  p email-template create --instance-id 01... --name "Welcome" --body-file ./welcome.html --api-key myapikey
246
258
 
259
+ # ...or pass the HTML inline instead of a file (use one, not both)
260
+ p email-template create --instance-id 01... --name "Welcome" --body-html "<p>Hi {{recipient.firstName}}</p>" --api-key myapikey
261
+
247
262
  # List templates (optionally filter by name/status/category/target)
248
263
  p email-template list --instance-id 01... --api-key myapikey
249
264
 
@@ -266,6 +281,9 @@ Create/list DSR (Digital Sales Room) landing-page templates, and set one as the
266
281
  # Create a landing template from an HTML file (must contain the email-gate marker)
267
282
  p dsr-landing create --instance-id 01... --name "Default" --html-file ./landing.html --api-key myapikey
268
283
 
284
+ # ...or pass the HTML (and optionally CSS) inline instead of a file (use one, not both)
285
+ p dsr-landing create --instance-id 01... --name "Default" --html "<div data-pitcher-email-gate></div>" --api-key myapikey
286
+
269
287
  # Create and set as default in one call
270
288
  p dsr-landing create --instance-id 01... --name "Default" --html-file ./landing.html --default --api-key myapikey
271
289
 
@@ -279,7 +297,7 @@ p dsr-landing list --instance-id 01... --api-key myapikey
279
297
  p dsr-landing scaffold --out ./landing.html --api-key myapikey
280
298
  ```
281
299
 
282
- `--html-file` must contain the `data-pitcher-email-gate` marker verbatim (the platform injects the email-gate form there) — `create` fails client-side before any request if it's missing.
300
+ The landing HTML (whether via `--html-file` or `--html`) must contain the `data-pitcher-email-gate` marker verbatim (the platform injects the email-gate form there) — `create` fails client-side before any request if it's missing. Provide exactly one of `--html` / `--html-file` (and at most one of `--css` / `--css-file`).
283
301
 
284
302
  Run `p dsr-landing create --help` for the full token/marker reference (editable-region attributes, recipient/customer/org/rep tokens).
285
303
 
package/dist/index.js CHANGED
File without changes
@@ -13,14 +13,17 @@ export async function runCanvasesList(opts, deps = {}) {
13
13
  assertIntFlags(opts);
14
14
  const client = clientFor(opts, deps);
15
15
  const params = { instance_id: opts.instanceId, ...listPageParams(opts) };
16
- // Send each type flag ONLY when set. The server FilterSet does
17
- // setdefault("is_block", False), so blocks are hidden unless --is-block.
18
- if (opts.isTemplate)
19
- params.is_template = true;
20
- if (opts.isSection)
21
- params.is_section = true;
22
- if (opts.isBlock)
23
- params.is_block = true;
16
+ // Forward each type flag only when explicitly set (true OR false) — the server honors
17
+ // is_section=false / is_template=false for precise type filtering. CLI switches are
18
+ // present→true / absent→undefined, so this is behavior-preserving for the CLI; it lets a
19
+ // programmatic caller (the MCP `type` filter) isolate one canvas type. The server FilterSet
20
+ // does setdefault("is_block", False), so blocks stay hidden unless is_block is set.
21
+ if (opts.isTemplate !== undefined)
22
+ params.is_template = opts.isTemplate;
23
+ if (opts.isSection !== undefined)
24
+ params.is_section = opts.isSection;
25
+ if (opts.isBlock !== undefined)
26
+ params.is_block = opts.isBlock;
24
27
  if (opts.tags)
25
28
  params.tags__in = opts.tags;
26
29
  if (opts.idIn)
@@ -8,7 +8,9 @@ export interface DsrLandingDeps {
8
8
  export declare function runDsrLandingCreate(opts: CommonOpts & {
9
9
  instanceId?: string;
10
10
  name?: string;
11
+ html?: string;
11
12
  htmlFile?: string;
13
+ css?: string;
12
14
  cssFile?: string;
13
15
  mergeSchema?: string;
14
16
  thumbnailUrl?: string;
@@ -3,7 +3,7 @@ import { clientFor, toCliError } from '../core/http.js';
3
3
  import { ok, fail } from '../core/envelope.js';
4
4
  import { createCliError } from '../core/errors.js';
5
5
  import { emitEnvelope } from '../core/output.js';
6
- import { readTextFile } from '../core/readInput.js';
6
+ import { resolveTextInput } from '../core/readInput.js';
7
7
  import { parseJsonArray } from '../schema/content.js';
8
8
  import { withAuthFlags } from './withAuth.js';
9
9
  import { withInstanceFlag } from './withInstance.js';
@@ -12,7 +12,7 @@ const DSR_ENDPOINT = '/core/api/protected/dsr-landing-templates';
12
12
  const REQUIRED_MARKER = 'data-pitcher-email-gate';
13
13
  const TOKEN_HELP = `
14
14
  Required marker (the platform injects the email-gate form here at render time):
15
- data-pitcher-email-gate must be present verbatim in --html-file, or create fails client-side
15
+ data-pitcher-email-gate must be present verbatim in the landing HTML (--html or --html-file), or create fails client-side
16
16
 
17
17
  Editable regions (optional, edited in the DSR landing editor without touching markup):
18
18
  data-field-id="..." unique id for the editable block
@@ -33,10 +33,15 @@ export async function runDsrLandingCreate(opts, deps = {}) {
33
33
  throw createCliError('VALIDATION', '--name is required for dsr-landing create');
34
34
  if (!opts.instanceId)
35
35
  throw createCliError('VALIDATION', '--instance-id is required for dsr-landing create');
36
- if (!opts.htmlFile)
37
- throw createCliError('VALIDATION', '--html-file is required for dsr-landing create');
38
- // readTextFile throws VALIDATION on a missing/unreadable file, before any request is made.
39
- const html = readTextFile(opts.htmlFile);
36
+ // Inline HTML (html) OR a file path (htmlFile), not both — exactly one is required.
37
+ // Inline is the only route that works in hosts without a shared filesystem (e.g. Claude
38
+ // Desktop). resolveTextInput throws VALIDATION on conflict/oversize/unreadable.
39
+ const html = resolveTextInput(opts.html, opts.htmlFile, 'html');
40
+ if (html === undefined) {
41
+ // host-neutral wording: the MCP tool params are html/htmlFile, the CLI flags are --html/--html-file
42
+ throw createCliError('VALIDATION', 'provide the landing HTML inline or as a file (html / htmlFile) for dsr-landing create');
43
+ }
44
+ // Marker check runs on the resolved HTML regardless of source.
40
45
  if (!html.includes(REQUIRED_MARKER)) {
41
46
  throw createCliError('VALIDATION', `landing HTML must contain the ${REQUIRED_MARKER} marker`);
42
47
  }
@@ -44,8 +49,9 @@ export async function runDsrLandingCreate(opts, deps = {}) {
44
49
  throw createCliError('VALIDATION', '--language must be at most 10 characters');
45
50
  }
46
51
  const body = { instance_id: opts.instanceId, name: opts.name, html };
47
- if (opts.cssFile)
48
- body.css = readTextFile(opts.cssFile);
52
+ const css = resolveTextInput(opts.css, opts.cssFile, 'css');
53
+ if (css !== undefined)
54
+ body.css = css;
49
55
  if (opts.mergeSchema)
50
56
  body.merge_schema = parseJsonArray(opts.mergeSchema, 'merge-schema');
51
57
  if (opts.thumbnailUrl)
@@ -117,8 +123,10 @@ export function createDsrLandingCommand() {
117
123
  const cmd = new Command('dsr-landing').description('create/list DSR (Digital Sales Room) landing-page templates, and set one as default');
118
124
  withInstanceFlag(withAuthFlags(cmd.command('create')))
119
125
  .option('--name <name>', 'Template name (required)')
120
- .option('--html-file <path>', `Path to the landing HTML (required; must contain ${REQUIRED_MARKER})`)
121
- .option('--css-file <path>', 'Path to a CSS file for the template')
126
+ .option('--html <html>', `Inline landing HTML (must contain ${REQUIRED_MARKER}). Use this OR --html-file`)
127
+ .option('--html-file <path>', `Path to the landing HTML (must contain ${REQUIRED_MARKER}). Use this OR --html`)
128
+ .option('--css <css>', 'Inline CSS for the template. Use this OR --css-file')
129
+ .option('--css-file <path>', 'Path to a CSS file for the template. Use this OR --css')
122
130
  .option('--merge-schema <json>', 'Merge schema array as inline JSON')
123
131
  .option('--language <lang>', 'Language code (max 10 chars)')
124
132
  .option('--thumbnail-url <url>', 'Thumbnail image URL')
@@ -8,6 +8,7 @@ export interface EmailTemplateDeps {
8
8
  export declare function runEmailTemplateCreate(opts: CommonOpts & {
9
9
  instanceId?: string;
10
10
  name?: string;
11
+ bodyHtml?: string;
11
12
  bodyFile?: string;
12
13
  subject?: string;
13
14
  tags?: string;
@@ -3,7 +3,7 @@ import { clientFor, toCliError } from '../core/http.js';
3
3
  import { ok, fail } from '../core/envelope.js';
4
4
  import { createCliError } from '../core/errors.js';
5
5
  import { emitEnvelope } from '../core/output.js';
6
- import { readTextFile } from '../core/readInput.js';
6
+ import { resolveTextInput } from '../core/readInput.js';
7
7
  import { listLimitOffsetParams } from '../core/pagination.js';
8
8
  import { assertIntFlags, parseJsonObject } from '../schema/content.js';
9
9
  import { withAuthFlags } from './withAuth.js';
@@ -26,9 +26,12 @@ export async function runEmailTemplateCreate(opts, deps = {}) {
26
26
  const body = { name: opts.name };
27
27
  if (opts.subject)
28
28
  body.subject = opts.subject;
29
- // readTextFile throws VALIDATION on a missing/unreadable file, before any request is made.
30
- if (opts.bodyFile)
31
- body.body = readTextFile(opts.bodyFile);
29
+ // Inline body (bodyHtml) OR a file path (bodyFile), not both. Inline is the only
30
+ // route that works in hosts without a shared filesystem (e.g. Claude Desktop).
31
+ // resolveTextInput throws VALIDATION on conflict/oversize/unreadable, before any request.
32
+ const bodyHtml = resolveTextInput(opts.bodyHtml, opts.bodyFile, 'body');
33
+ if (bodyHtml !== undefined)
34
+ body.body = bodyHtml;
32
35
  if (opts.tags)
33
36
  body.tags = parseJsonObject(opts.tags, 'tags');
34
37
  if (opts.metadata)
@@ -86,6 +89,7 @@ export function createEmailTemplateCommand() {
86
89
  const cmd = new Command('email-template').description('create/list/get HTML email templates for the emails app');
87
90
  withInstanceFlag(withAuthFlags(cmd.command('create')))
88
91
  .option('--name <name>', 'Template name (required)')
92
+ .option('--body-html <html>', 'Inline HTML body (inline CSS). Use this OR --body-file')
89
93
  .option('--body-file <path>', 'Path to an HTML file (inline CSS) used as the template body')
90
94
  .option('--subject <text>', 'Email subject (supports {{tokens}})')
91
95
  .option('--tags <json>', 'Tags object as inline JSON')
@@ -1,2 +1,4 @@
1
1
  export declare function readTextFile(filePath: string): string;
2
2
  export declare function readJsonFile(filePath: string): unknown;
3
+ export declare const MAX_INLINE_BYTES = 400000;
4
+ export declare function resolveTextInput(inline: string | undefined, file: string | undefined, label: string): string | undefined;
@@ -21,3 +21,27 @@ export function readJsonFile(filePath) {
21
21
  throw createCliError('VALIDATION', `invalid JSON in ${filePath}`);
22
22
  }
23
23
  }
24
+ // Cap inbound inline content so a runaway model-generated payload fails cleanly
25
+ // client-side (a clear VALIDATION) instead of ballooning memory or POSTing a huge body
26
+ // that the platform rejects with an opaque 4xx. Bytes, not chars (UTF-8).
27
+ export const MAX_INLINE_BYTES = 400000;
28
+ // Resolve a text field from either an inline string or a file path (exactly one).
29
+ // Truthy guards (not `!== undefined`) preserve the pre-existing falsy-skip behavior:
30
+ // an empty inline string or empty path counts as "not provided", matching the old
31
+ // `if (opts.bodyFile)` / `if (opts.htmlFile)` checks. Returns undefined if neither.
32
+ // The size cap applies only to inline content — a file path is disk-bounded already,
33
+ // and capping it could break existing large-file callers.
34
+ export function resolveTextInput(inline, file, label) {
35
+ if (inline && file) {
36
+ throw createCliError('VALIDATION', `provide either an inline ${label} or a ${label} file, not both`);
37
+ }
38
+ if (inline) {
39
+ if (Buffer.byteLength(inline, 'utf-8') > MAX_INLINE_BYTES) {
40
+ throw createCliError('VALIDATION', `inline ${label} exceeds ${MAX_INLINE_BYTES} bytes`);
41
+ }
42
+ return inline;
43
+ }
44
+ if (file)
45
+ return readTextFile(file);
46
+ return undefined;
47
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToolDescriptor } from './tools/types.js';
2
+ export declare const BUNDLE_EXCLUDED_TOOLS: Set<string>;
3
+ export declare const bundledTools: ToolDescriptor[];
4
+ export declare function normalizeOrgUrl(env: NodeJS.ProcessEnv): void;
@@ -0,0 +1,25 @@
1
+ import { allTools } from './registry.js';
2
+ // Developer-only tools excluded from the consumer .mcpb bundle. `publish_app` is alwaysDestructive
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` keeps all 50.
5
+ export const BUNDLE_EXCLUDED_TOOLS = new Set(['pitcher_publish_app', 'pitcher_dev_sync', 'pitcher_dev_sync_revert']);
6
+ // The tool set the .mcpb bundle registers.
7
+ export const bundledTools = allTools.filter((t) => !BUNDLE_EXCLUDED_TOOLS.has(t.name));
8
+ // resolveBaseUrl interpolates NG_ORG into `https://<org>.my.pitcher.com`, so anything that is
9
+ // already a host (a full URL, or a bare hostname like "acme.my.pitcher.com") must route via NG_URL
10
+ // instead — otherwise it becomes a double-interpolated garbage host. The .mcpb install form accepts
11
+ // a subdomain, hostname, or full URL in one `org` field; this moves host-shaped values to NG_URL
12
+ // before the server reads env. A plain subdomain (no dot) stays in NG_ORG. Mutates env in place.
13
+ export function normalizeOrgUrl(env) {
14
+ const org = env.NG_ORG?.trim();
15
+ if (!org)
16
+ return;
17
+ if (/^https?:\/\//i.test(org)) {
18
+ env.NG_URL = org;
19
+ delete env.NG_ORG;
20
+ }
21
+ else if (org.includes('.')) {
22
+ env.NG_URL = `https://${org}`;
23
+ delete env.NG_ORG;
24
+ }
25
+ }
@@ -0,0 +1,2 @@
1
+ import type { PromptDescriptor } from './types.js';
2
+ export declare const announcementsPrompts: PromptDescriptor[];
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { instanceLine, recipePreamble } from './shared.js';
3
+ export const announcementsPrompts = [
4
+ {
5
+ name: 'seed_announcement',
6
+ title: 'Create announcement',
7
+ description: 'Author an on-brand in-app announcement (title + body HTML + CTA) and create it in an instance.',
8
+ argsSchema: {
9
+ instance_id: z.string().optional().describe('Target instance ID (leave blank to resolve interactively)'),
10
+ vertical: z.string().optional().describe('pharma | medtech | cpg | finserv | generic'),
11
+ use_case: z.string().optional().describe('Short description of what this announcement is about'),
12
+ },
13
+ build: (args) => {
14
+ const vertical = args.vertical || 'generic';
15
+ const useCase = args.use_case || 'a general product / process announcement a rep would see in-app';
16
+ const text = [
17
+ recipePreamble(),
18
+ '',
19
+ '## Task: seed an announcement',
20
+ instanceLine(args.instance_id),
21
+ `Vertical: ${vertical}. Use case: ${useCase}.`,
22
+ '',
23
+ 'Steps:',
24
+ '1. Draft a short, on-brand announcement: a concise title and a **body in inline-CSS HTML** ' +
25
+ 'following the principles above — a few sentences, one clear call-to-action, brand-derived colors, ' +
26
+ 'and any dynamic values via escaped `{{token}}` merge fields.',
27
+ '2. Create it by calling `pitcher_announcements_create` with `{ instanceId, title, body }` — ' +
28
+ 'pass the HTML **inline via `body`** (a file path cannot cross a host\'s sandbox boundary, so always inline). ' +
29
+ 'Add `priority` only if the user asks for an important announcement.',
30
+ '3. Report the created announcement id and title.',
31
+ ].join('\n');
32
+ return [{ role: 'user', content: { type: 'text', text } }];
33
+ },
34
+ },
35
+ ];
@@ -0,0 +1,2 @@
1
+ import type { PromptDescriptor } from './types.js';
2
+ export declare const dsrPrompts: PromptDescriptor[];
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ import { instanceLine, safetyPreamble } from './shared.js';
3
+ export const dsrPrompts = [
4
+ {
5
+ name: 'seed_dsr',
6
+ title: 'Create and share a DSR',
7
+ description: 'Share a canvas (or files) as a Digital Sales Room with the right gating, optionally branded with a landing page.',
8
+ argsSchema: {
9
+ instance_id: z.string().optional().describe('Target instance ID (leave blank to resolve interactively)'),
10
+ canvas_id: z.string().optional().describe('Canvas to share (leave blank to pick one interactively)'),
11
+ audience: z.string().optional().describe('Who the DSR is for, e.g. "internal", "a known contact", "an account/domain"'),
12
+ },
13
+ build: (args) => {
14
+ const audience = args.audience || 'the intended external recipient';
15
+ const text = [
16
+ // NOTE: this is a share WORKFLOW recipe, not an HTML-authoring one — it does NOT author a
17
+ // landing page (that is the separate `seed_dsr_landing` recipe). It only BINDS an existing
18
+ // template. So it embeds only the safety guidance, not the HTML design principles.
19
+ safetyPreamble(),
20
+ '',
21
+ '## DSR sharing guidance',
22
+ '- **Source is exactly one of** a canvas (`canvasId`) OR files (`shareboxFileIds`).',
23
+ `- **Pick the gating (\`authStrategy\`) by audience.** For ${audience}: internal/quick demo → \`none\` ` +
24
+ '(open link); a known individual → `specified_emails` (+ `allowedEmails`); a company → ' +
25
+ '`specified_domains` (+ `allowedDomains`); the server default is `any_email`. Add `validUntil` ' +
26
+ '(ISO datetime) to time-box a customer-facing link; set `allowForward`/`allowDownload` deliberately.',
27
+ '- **Branding the pre-auth page is a SEPARATE step:** this recipe only **binds** an existing ' +
28
+ 'landing template (`landingTemplateId`, discover via `pitcher_dsr_landing_list`; pass ' +
29
+ '`landingContext` merge fields **as a JSON object serialized to a string**, not as an object). ' +
30
+ 'If no suitable template exists and the user wants a ' +
31
+ 'branded landing, author one FIRST with the **`seed_dsr_landing`** recipe, then bind its id here. ' +
32
+ 'Do NOT hand-write landing HTML in this recipe.',
33
+ '',
34
+ '## Task: create and share a DSR',
35
+ instanceLine(args.instance_id),
36
+ args.canvas_id ? `Canvas to share: ${args.canvas_id}.` : 'No canvas given — help the user pick one via `pitcher_canvases_list`.',
37
+ `Audience: ${audience}.`,
38
+ '',
39
+ 'Steps:',
40
+ '1. Resolve the source (the given `canvas_id`, or pick a canvas with the user via `pitcher_canvases_list`).',
41
+ '2. Choose the gating strategy with the user per the guidance above; set expiry/forward/download as appropriate.',
42
+ '3. (Optional) To brand the pre-auth page, list templates with `pitcher_dsr_landing_list` and bind one via ' +
43
+ '`landingTemplateId` — or point the user at `seed_dsr_landing` to author a new one first.',
44
+ '4. Call `pitcher_dsr_create` with the resolved fields. A denied/failed landing bind returns the link with a ' +
45
+ 'warning (not a failure) — surface it. Report the returned `short_url`.',
46
+ ].join('\n');
47
+ return [{ role: 'user', content: { type: 'text', text } }];
48
+ },
49
+ },
50
+ ];
@@ -0,0 +1,2 @@
1
+ import type { PromptDescriptor } from './types.js';
2
+ export declare const dsrLandingPrompts: PromptDescriptor[];
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { instanceLine, recipePreamble } from './shared.js';
3
+ export const dsrLandingPrompts = [
4
+ {
5
+ name: 'seed_dsr_landing',
6
+ title: 'Create DSR landing page',
7
+ description: 'Author a responsive DSR (Digital Sales Room) landing-page template and create it in an instance.',
8
+ argsSchema: {
9
+ instance_id: z.string().optional().describe('Target instance ID (leave blank to resolve interactively)'),
10
+ vertical: z.string().optional().describe('pharma | medtech | cpg | finserv | generic'),
11
+ set_default: z.string().optional().describe('"true" to also set it as the instance default'),
12
+ },
13
+ build: (args) => {
14
+ const vertical = args.vertical || 'generic';
15
+ const setDefault = args.set_default === 'true';
16
+ const text = [
17
+ recipePreamble(),
18
+ '',
19
+ '## Task: seed a DSR landing page',
20
+ instanceLine(args.instance_id),
21
+ `Vertical: ${vertical}.`,
22
+ '',
23
+ 'Steps:',
24
+ '1. Draft a responsive landing HTML following the principles above. It **must contain the ' +
25
+ '`data-pitcher-email-gate` marker** (the platform injects the email-gate form there). Use ' +
26
+ '`clamp()` for fluid sizing (no media queries), guard `{{rep.*}}` merge fields with fallbacks, ' +
27
+ 'and pick an aesthetic that fits the vertical.',
28
+ '2. Create it by calling `pitcher_dsr_landing_create` with ' +
29
+ `\`{ instanceId, name, html${setDefault ? ', default: true' : ''} }\` — pass the HTML **inline via ` +
30
+ '`html`**, not a file path (inline is the only route that works in hosts without a shared filesystem).',
31
+ setDefault
32
+ ? ' (`set_default` was "true" → pass `default: true` so it becomes the instance default.)'
33
+ : ' (Leave `default` unset — the caller did not ask to set it as the instance default.)',
34
+ '3. The create endpoint is editor-gated; if it returns 403, surface it as-is. Report the created ' +
35
+ 'template id and, if applicable, the DSR link.',
36
+ ].join('\n');
37
+ return [{ role: 'user', content: { type: 'text', text } }];
38
+ },
39
+ },
40
+ ];
@@ -0,0 +1,2 @@
1
+ import type { PromptDescriptor } from './types.js';
2
+ export declare const emailTemplatePrompts: PromptDescriptor[];
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { instanceLine, recipePreamble } from './shared.js';
3
+ export const emailTemplatePrompts = [
4
+ {
5
+ name: 'seed_email_template',
6
+ title: 'Create email template',
7
+ description: 'Author an on-brand HTML email template for the emails app and create it in an instance.',
8
+ argsSchema: {
9
+ instance_id: z.string().optional().describe('Target instance ID (leave blank to resolve interactively)'),
10
+ vertical: z.string().optional().describe('pharma | medtech | cpg | finserv | generic'),
11
+ use_case: z.string().optional().describe('Short description of what this email is for'),
12
+ },
13
+ build: (args) => {
14
+ const vertical = args.vertical || 'generic';
15
+ const useCase = args.use_case || 'a general product-update email a rep would send to a customer';
16
+ const text = [
17
+ recipePreamble(),
18
+ '',
19
+ '## Task: seed an email template',
20
+ instanceLine(args.instance_id),
21
+ `Vertical: ${vertical}. Use case: ${useCase}.`,
22
+ '',
23
+ 'Steps:',
24
+ '1. Draft compliant inline-CSS HTML following the principles above: max ~620px, table layout, ' +
25
+ 'one clear CTA, visually distinct editable fields, and a vertical-appropriate greeting and ' +
26
+ 'compliance footer.',
27
+ '2. Create it by calling `pitcher_email_template_create` with `{ instanceId, name, bodyHtml }` — ' +
28
+ 'pass the HTML **inline via `bodyHtml`** (a file path cannot cross a host\'s sandbox boundary, so always inline).',
29
+ '3. Report the created template id and name.',
30
+ ].join('\n');
31
+ return [{ role: 'user', content: { type: 'text', text } }];
32
+ },
33
+ },
34
+ ];
@@ -0,0 +1,2 @@
1
+ import type { PromptDescriptor } from './types.js';
2
+ export declare const allPrompts: PromptDescriptor[];
@@ -0,0 +1,6 @@
1
+ import { emailTemplatePrompts } from './emailTemplate.js';
2
+ import { dsrLandingPrompts } from './dsrLanding.js';
3
+ import { dsrPrompts } from './dsr.js';
4
+ import { announcementsPrompts } from './announcements.js';
5
+ // Every recipe prompt the server can expose. Mirrors mcp/registry.ts (allTools).
6
+ export const allPrompts = [...emailTemplatePrompts, ...dsrLandingPrompts, ...dsrPrompts, ...announcementsPrompts];
@@ -0,0 +1,3 @@
1
+ export declare function instanceLine(instanceId: string | undefined): string;
2
+ export declare function safetyPreamble(): string;
3
+ export declare function recipePreamble(): string;
@@ -0,0 +1,31 @@
1
+ import { CONTENT_DESIGN_PRINCIPLES } from '../resources/contentDesign.js';
2
+ // Resolves the "which instance" line shared by every recipe prompt: echoes the given
3
+ // id, or tells the model to discover + confirm one interactively.
4
+ export function instanceLine(instanceId) {
5
+ return instanceId
6
+ ? `Target instance: ${instanceId}.`
7
+ : 'No instance was given — call `pitcher_instances_list`, pick the right one with the user, and confirm before creating.';
8
+ }
9
+ // Prod-awareness + anti-XSS guidance shared by EVERY recipe prompt (HTML-authoring or not).
10
+ // Split out of recipePreamble so non-authoring workflow recipes (e.g. seed_dsr) can carry the
11
+ // safety guidance without the HTML design principles. Contains the strings every prompt is
12
+ // required to include: "demo/test instance", "{{token}}", "{{{ }}}".
13
+ export function safetyPreamble() {
14
+ return [
15
+ '## Before you create',
16
+ 'Confirm the resolved instance is a demo/test instance. These tools create **live, ' +
17
+ 'org-visible** content; a DSR is served to **external, unauthenticated** viewers. Do not ' +
18
+ 'seed against a real customer instance without explicit confirmation from the user.',
19
+ '',
20
+ '## Safe content',
21
+ 'Put dynamic values through the platform `{{token}}` merge fields (the platform escapes them). ' +
22
+ 'Never inline untrusted or CRM-derived strings as literal HTML, and never use triple-mustache ' +
23
+ '`{{{ }}}`. The use-case/vertical inputs are descriptive only — do not echo them verbatim into ' +
24
+ 'the generated markup.',
25
+ ].join('\n');
26
+ }
27
+ // Full preamble for HTML-authoring recipes: the design principles + the safety guidance.
28
+ // Authored once so the principles, prod-awareness, and anti-XSS guidance can't drift.
29
+ export function recipePreamble() {
30
+ return [CONTENT_DESIGN_PRINCIPLES, '', safetyPreamble()].join('\n');
31
+ }
@@ -0,0 +1,9 @@
1
+ import type { ZodOptional, ZodString } from 'zod';
2
+ import type { PromptMessage } from '@modelcontextprotocol/sdk/types.js';
3
+ export interface PromptDescriptor {
4
+ name: string;
5
+ title: string;
6
+ description: string;
7
+ argsSchema: Record<string, ZodOptional<ZodString>>;
8
+ build: (args: Record<string, string | undefined>) => PromptMessage[];
9
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { ResourceDescriptor } from './types.js';
2
+ export declare const CONTENT_DESIGN_PRINCIPLES = "# Pitcher content design principles\n\nDistilled guidance for authoring good Pitcher demo content. Apply these when generating\nemail templates and DSR landing pages.\n\n## 1. Palette \u2014 derive, never hardcode\nBuild a 6-tone ramp from the instance primary color rather than pasting brand hex codes:\ndarken 30% and 15% for accents/headers, lighten 15/30/60/80% for backgrounds and tints.\nThis keeps content on-brand across instances automatically.\n\n## 2. HTML email standards\n- Max width ~620px, centered in the viewport.\n- Table-based layout \u2014 NOT CSS grid/flex (email clients drop them).\n- All styles inline \u2014 no `<style>` block.\n- Exactly one clear call-to-action.\n- Make editable regions obvious: wrap them in a visually distinct container (colored\n background, left border, an uppercase label above).\n- Every template needs a realistic demo story \u2014 a use case a sales rep would actually send.\n\n## 3. DSR landing-page standards\n- The HTML MUST contain the `data-pitcher-email-gate` marker (the platform injects the\n email-gate form there at render time), or creation fails client-side.\n- Responsive without media queries: use `clamp(min, fluid, max)` for sizing plus\n `flex-wrap: wrap`; that covers 320px to 1440px with no breakpoints.\n- Guard merge fields at the field level with sensible fallbacks, e.g. a default when\n `{{rep.name}}` is absent.\n\n## 4. Band / bookend layout (shared canvas vocabulary)\n- Hero: full brand fill with a white title.\n- Content bands: alternate white and a faint brand tint; use generous padding.\n- Closing: a deeper brand fill.\n- Avoid the \"washed-out trap\" \u2014 inject real brand fills instead of relying on theme\n presets that hard-code headings to black.\n\n## 5. Safe dynamic content (security \u2014 required)\nDynamic values go ONLY through the platform's `{{token}}` merge fields, which the platform\nescapes. Never inline untrusted or CRM-derived strings as literal HTML, and never use\ntriple-mustache `{{{ }}}`. DSR landing pages render to external, unauthenticated viewers,\nso unescaped dynamic HTML is a stored-XSS boundary. Descriptive inputs (a use case, a\nvertical) are for steering the design \u2014 do not echo them verbatim into the markup.\n\n## 6. Demo-story discipline (house rules)\n- Every artifact needs a realistic use case a rep would actually send.\n- No real vendor or competitor names.\n- Stats and figures are illustrative, not represented as real customer data.\n- All copy in English.\n";
3
+ export declare const contentDesignResource: ResourceDescriptor;
@@ -0,0 +1,62 @@
1
+ // Distilled Pitcher content-design nucleus. This is the SINGLE source: the resource
2
+ // below returns it, and both recipe prompts (mcp/prompts/*) embed it inline (a URI
3
+ // mention alone is not auto-fetched by MCP hosts, so prompts must carry the text).
4
+ //
5
+ // Distilled from the personal Claude Code skills `email-templates`, `dsr-landing-seed`,
6
+ // and `playbook-seed` (palette/band vocabulary). Those skills remain the authoritative
7
+ // deep version; keep this in sync on material design changes. Last synced: 2026-07-17.
8
+ export const CONTENT_DESIGN_PRINCIPLES = `# Pitcher content design principles
9
+
10
+ Distilled guidance for authoring good Pitcher demo content. Apply these when generating
11
+ email templates and DSR landing pages.
12
+
13
+ ## 1. Palette — derive, never hardcode
14
+ Build a 6-tone ramp from the instance primary color rather than pasting brand hex codes:
15
+ darken 30% and 15% for accents/headers, lighten 15/30/60/80% for backgrounds and tints.
16
+ This keeps content on-brand across instances automatically.
17
+
18
+ ## 2. HTML email standards
19
+ - Max width ~620px, centered in the viewport.
20
+ - Table-based layout — NOT CSS grid/flex (email clients drop them).
21
+ - All styles inline — no \`<style>\` block.
22
+ - Exactly one clear call-to-action.
23
+ - Make editable regions obvious: wrap them in a visually distinct container (colored
24
+ background, left border, an uppercase label above).
25
+ - Every template needs a realistic demo story — a use case a sales rep would actually send.
26
+
27
+ ## 3. DSR landing-page standards
28
+ - The HTML MUST contain the \`data-pitcher-email-gate\` marker (the platform injects the
29
+ email-gate form there at render time), or creation fails client-side.
30
+ - Responsive without media queries: use \`clamp(min, fluid, max)\` for sizing plus
31
+ \`flex-wrap: wrap\`; that covers 320px to 1440px with no breakpoints.
32
+ - Guard merge fields at the field level with sensible fallbacks, e.g. a default when
33
+ \`{{rep.name}}\` is absent.
34
+
35
+ ## 4. Band / bookend layout (shared canvas vocabulary)
36
+ - Hero: full brand fill with a white title.
37
+ - Content bands: alternate white and a faint brand tint; use generous padding.
38
+ - Closing: a deeper brand fill.
39
+ - Avoid the "washed-out trap" — inject real brand fills instead of relying on theme
40
+ presets that hard-code headings to black.
41
+
42
+ ## 5. Safe dynamic content (security — required)
43
+ Dynamic values go ONLY through the platform's \`{{token}}\` merge fields, which the platform
44
+ escapes. Never inline untrusted or CRM-derived strings as literal HTML, and never use
45
+ triple-mustache \`{{{ }}}\`. DSR landing pages render to external, unauthenticated viewers,
46
+ so unescaped dynamic HTML is a stored-XSS boundary. Descriptive inputs (a use case, a
47
+ vertical) are for steering the design — do not echo them verbatim into the markup.
48
+
49
+ ## 6. Demo-story discipline (house rules)
50
+ - Every artifact needs a realistic use case a rep would actually send.
51
+ - No real vendor or competitor names.
52
+ - Stats and figures are illustrative, not represented as real customer data.
53
+ - All copy in English.
54
+ `;
55
+ export const contentDesignResource = {
56
+ name: 'content-principles',
57
+ uri: 'pitcher://design/content-principles',
58
+ title: 'Pitcher content design principles',
59
+ description: 'Palette, HTML-email, DSR-landing, band-layout, and safe-token guidance for authoring content.',
60
+ mimeType: 'text/markdown',
61
+ read: () => CONTENT_DESIGN_PRINCIPLES,
62
+ };
@@ -0,0 +1,2 @@
1
+ import type { ResourceDescriptor } from './types.js';
2
+ export declare const allResources: ResourceDescriptor[];
@@ -0,0 +1,3 @@
1
+ import { contentDesignResource } from './contentDesign.js';
2
+ // Every resource the server can expose. Mirrors mcp/registry.ts (allTools).
3
+ export const allResources = [contentDesignResource];
@@ -0,0 +1,8 @@
1
+ export interface ResourceDescriptor {
2
+ name: string;
3
+ uri: string;
4
+ title: string;
5
+ description: string;
6
+ mimeType: string;
7
+ read: () => string;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -9,5 +9,6 @@ export interface CallToolResultShape {
9
9
  isError: boolean;
10
10
  [key: string]: unknown;
11
11
  }
12
+ export declare const MAX_RESULT_BYTES = 900000;
12
13
  export declare function toErrorResult(code: string, message: string): CallToolResultShape;
13
- export declare function envelopeToToolResult(env: Envelope<unknown>): CallToolResultShape;
14
+ export declare function envelopeToToolResult(env: Envelope<unknown>, maxBytes?: number): CallToolResultShape;
@@ -1,20 +1,80 @@
1
+ // MCP hosts cap a single tool result (Claude Desktop: 1 MB). We stay well under it,
2
+ // leaving headroom for JSON-RPC framing, warning blocks, and multi-byte characters.
3
+ export const MAX_RESULT_BYTES = 900000;
4
+ // Cap any single variable-length string (a server error message, a warning) so one huge value
5
+ // can't blow the size budget regardless of which tier it lands in.
6
+ const MAX_TEXT_BYTES = 8000;
7
+ function clampText(s) {
8
+ return Buffer.byteLength(s, 'utf8') <= MAX_TEXT_BYTES ? s : `${s.slice(0, MAX_TEXT_BYTES)}… (truncated)`;
9
+ }
1
10
  function isPlainObject(v) {
2
11
  return typeof v === 'object' && v !== null && !Array.isArray(v);
3
12
  }
13
+ // Best-effort item count for the "too large" guidance, without assuming a payload shape.
14
+ function countItems(data) {
15
+ if (Array.isArray(data))
16
+ return data.length;
17
+ if (isPlainObject(data) && Array.isArray(data.results))
18
+ return data.results.length;
19
+ return undefined;
20
+ }
4
21
  export function toErrorResult(code, message) {
5
- return { content: [{ type: 'text', text: `${code}: ${message}` }], isError: true };
22
+ return { content: [{ type: 'text', text: clampText(`${code}: ${message}`) }], isError: true };
6
23
  }
7
- export function envelopeToToolResult(env) {
24
+ export function envelopeToToolResult(env, maxBytes = MAX_RESULT_BYTES) {
8
25
  if (!env.ok) {
9
26
  return toErrorResult(env.error?.code ?? 'UNKNOWN', env.error?.message ?? 'unknown error');
10
27
  }
11
- const text = env.data === undefined ? 'null' : JSON.stringify(env.data);
12
- const content = [{ type: 'text', text }];
13
- for (const w of env.warnings ?? [])
14
- content.push({ type: 'text', text: `warning: ${w}` });
15
- const result = { content, isError: false };
16
- // structuredContent must be an object; omit for arrays/primitives (text still carries the data).
17
- if (isPlainObject(env.data))
18
- result.structuredContent = env.data;
19
- return result;
28
+ const full = env.data === undefined ? 'null' : JSON.stringify(env.data);
29
+ const bytes = Buffer.byteLength(full, 'utf8');
30
+ // structuredContent must be an object; arrays/primitives ride in the text block only.
31
+ const obj = isPlainObject(env.data) ? env.data : undefined;
32
+ const withWarnings = (content) => {
33
+ for (const w of env.warnings ?? [])
34
+ content.push({ type: 'text', text: clampText(`warning: ${w}`) });
35
+ return content;
36
+ };
37
+ // Measure the ACTUAL serialized size of a candidate result — JSON re-escaping (a payload copied
38
+ // into content.text is escaped a second time), warning blocks, keys, and structure are all
39
+ // counted — rather than projecting from the raw data size. maxBytes keeps headroom under the
40
+ // host cap for the outer JSON-RPC framing around this result.
41
+ const fits = (candidate) => Buffer.byteLength(JSON.stringify(candidate), 'utf8') <= maxBytes;
42
+ // Tier 1: full payload with the default dual representation (text + structuredContent).
43
+ const dual = { content: withWarnings([{ type: 'text', text: full }]), isError: false };
44
+ if (obj)
45
+ dual.structuredContent = obj;
46
+ if (fits(dual))
47
+ return dual;
48
+ const kb = Math.round(bytes / 1024);
49
+ const count = countItems(env.data);
50
+ const countPhrase = count === undefined ? '' : ` (${count} item${count === 1 ? '' : 's'})`;
51
+ // Tier 2: the payload fits once but not twice — keep it in structuredContent, drop the text dup.
52
+ if (obj) {
53
+ const note = `Large result${countPhrase} (~${kb} KB) returned in structuredContent; not duplicated as text to stay under the host's tool-result size limit.`;
54
+ const structuredOnly = {
55
+ content: withWarnings([{ type: 'text', text: note }]),
56
+ structuredContent: obj,
57
+ isError: false,
58
+ };
59
+ if (fits(structuredOnly))
60
+ return structuredOnly;
61
+ }
62
+ // Tier 3: even a single copy exceeds the limit — return a summary + guidance instead of an
63
+ // unusable dump the host would reject outright.
64
+ const limitKb = Math.round(maxBytes / 1024);
65
+ const guidance = `Result too large to return${countPhrase}: ~${kb} KB exceeds the ~${limitKb} KB tool-result limit. ` +
66
+ `Narrow the request — add filters, fetch a specific item by id, or use a smaller page-size — then retry.`;
67
+ const summary = { truncated: true, sizeBytes: bytes, limitBytes: maxBytes };
68
+ if (count !== undefined)
69
+ summary.count = count;
70
+ const summaryResult = {
71
+ content: withWarnings([{ type: 'text', text: guidance }]),
72
+ structuredContent: summary,
73
+ isError: false,
74
+ };
75
+ if (fits(summaryResult))
76
+ return summaryResult;
77
+ // Last resort (e.g. many warnings): drop the warnings — guidance + summary are always tiny, so
78
+ // this is guaranteed size-compliant rather than returning a result the host would reject.
79
+ return { content: [{ type: 'text', text: guidance }], structuredContent: summary, isError: false };
20
80
  }
@@ -1 +1,4 @@
1
- export declare function runMcpServe(env?: NodeJS.ProcessEnv): Promise<void>;
1
+ import type { ToolDescriptor } from './tools/types.js';
2
+ import type { PromptDescriptor } from './prompts/types.js';
3
+ import type { ResourceDescriptor } from './resources/types.js';
4
+ export declare function runMcpServe(env?: NodeJS.ProcessEnv, tools?: ToolDescriptor[], prompts?: PromptDescriptor[], resources?: ResourceDescriptor[]): Promise<void>;
@@ -2,12 +2,17 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
2
2
  import { resolveMcpEnv } from './env.js';
3
3
  import { buildServer } from './server.js';
4
4
  import { allTools } from './registry.js';
5
+ import { allPrompts } from './prompts/registry.js';
6
+ import { allResources } from './resources/registry.js';
5
7
  // Resolves auth/env, builds the server, connects stdio. Never writes to stdout except protocol frames.
6
- export async function runMcpServe(env = process.env) {
8
+ // `tools` defaults to the full set (the CLI `p mcp`); the .mcpb bundle passes a curated subset.
9
+ // Prompts/resources default to the full set for every frontend (no subsetting).
10
+ export async function runMcpServe(env = process.env, tools = allTools, prompts = allPrompts, resources = allResources) {
7
11
  const mcpEnv = resolveMcpEnv(env);
8
- const server = buildServer(mcpEnv);
12
+ const server = buildServer(mcpEnv, tools, prompts, resources);
9
13
  const transport = new StdioServerTransport();
10
14
  await server.connect(transport);
11
15
  const target = mcpEnv.url ?? `https://${mcpEnv.org}.my.pitcher.com`;
12
- console.error(`[pitcher-mcp] serving ${allTools.length} tools against ${target}${mcpEnv.prod ? ' (prod: destructive-hints on)' : ''}`);
16
+ console.error(`[pitcher-mcp] serving ${tools.length} tools, ${prompts.length} prompts, ${resources.length} resources ` +
17
+ `against ${target}${mcpEnv.prod ? ' (prod: destructive-hints on)' : ''}`);
13
18
  }
@@ -1,3 +1,6 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { McpEnv } from './env.js';
3
- export declare function buildServer(env: McpEnv): McpServer;
3
+ import type { ToolDescriptor } from './tools/types.js';
4
+ import type { PromptDescriptor } from './prompts/types.js';
5
+ import type { ResourceDescriptor } from './resources/types.js';
6
+ export declare function buildServer(env: McpEnv, tools?: ToolDescriptor[], prompts?: PromptDescriptor[], resources?: ResourceDescriptor[]): McpServer;
@@ -3,9 +3,16 @@ import { readFileSync } from 'fs';
3
3
  import { dirname, join } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { allTools } from './registry.js';
6
+ import { allPrompts } from './prompts/registry.js';
7
+ import { allResources } from './resources/registry.js';
6
8
  import { envelopeToToolResult, toErrorResult } from './result.js';
7
9
  import { isCliError } from '../core/errors.js';
8
10
  const version = (() => {
11
+ // The .mcpb bundle inlines this at build time (esbuild define) because the bundled single
12
+ // file has no package.json to walk to. Undefined at CLI runtime → the readFileSync path below.
13
+ const injected = process.env.PITCHER_CLI_VERSION;
14
+ if (injected)
15
+ return injected;
9
16
  try {
10
17
  const dir = dirname(fileURLToPath(import.meta.url));
11
18
  return JSON.parse(readFileSync(join(dir, '..', '..', '..', 'package.json'), 'utf8')).version ?? '0.0.0';
@@ -15,9 +22,26 @@ const version = (() => {
15
22
  return '0.0.0';
16
23
  }
17
24
  })();
18
- export function buildServer(env) {
19
- const server = new McpServer({ name: 'pitcher', version });
20
- for (const tool of allTools) {
25
+ // Connector branding shown by MCP hosts (Claude Desktop, Cursor). Self-contained
26
+ // data URI so it needs no network/asset resolution: a navy (#29335C) tile with a
27
+ // white "P" and the Pitcher-red (#CC0000) accent from the wordmark.
28
+ const icon = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiI+PHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHJ4PSIxMTYiIGZpbGw9IiMyOTMzNUMiLz48Y2lyY2xlIGN4PSIzNzIiIGN5PSIxNTAiIHI9IjMwIiBmaWxsPSIjQ0MwMDAwIi8+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTE1MCAxMTZoOTZhOTIgOTIgMCAwIDEgMCAxODRoLTUwdjk2aC00NnoiLz48cGF0aCBmaWxsPSIjMjkzMzVDIiBkPSJNMTk2IDE2MHY5Nmg1MGE0OCA0OCAwIDAgMCAwLTk2eiIvPjwvc3ZnPg==';
29
+ // Compact, sanitized error string (no raw stack) — shared by the prompt/resource callbacks.
30
+ function errorText(e) {
31
+ if (isCliError(e))
32
+ return `${e.code}: ${e.message}`;
33
+ return e instanceof Error ? e.message : String(e);
34
+ }
35
+ export function buildServer(env, tools = allTools, prompts = allPrompts, resources = allResources) {
36
+ const server = new McpServer({
37
+ name: 'pitcher',
38
+ version,
39
+ title: 'Pitcher',
40
+ description: 'Search, manage, and share Pitcher content, canvases, files, DSRs, and instances.',
41
+ websiteUrl: 'https://www.pitcher.com',
42
+ icons: [{ src: icon, mimeType: 'image/svg+xml', sizes: ['any'] }],
43
+ });
44
+ for (const tool of tools) {
21
45
  const destructive = tool.alwaysDestructive === true || (tool.write === true && env.prod);
22
46
  server.registerTool(tool.name, {
23
47
  title: tool.title,
@@ -37,5 +61,28 @@ export function buildServer(env) {
37
61
  }
38
62
  });
39
63
  }
64
+ for (const prompt of prompts) {
65
+ server.registerPrompt(prompt.name, { title: prompt.title, description: prompt.description, argsSchema: prompt.argsSchema }, (args) => {
66
+ try {
67
+ return { messages: prompt.build(args) };
68
+ }
69
+ catch (e) {
70
+ // build() is pure string assembly, but guard defensively: prompts have no result-level
71
+ // error channel (unlike tools), so surface failures by throwing — the SDK converts a
72
+ // thrown error into a JSON-RPC error. Re-throw a sanitized message (no raw stack).
73
+ throw new Error(`prompt "${prompt.name}" failed: ${errorText(e)}`);
74
+ }
75
+ });
76
+ }
77
+ for (const resource of resources) {
78
+ server.registerResource(resource.name, resource.uri, { title: resource.title, description: resource.description, mimeType: resource.mimeType }, (uri) => {
79
+ try {
80
+ return { contents: [{ uri: uri.href, mimeType: resource.mimeType, text: resource.read() }] };
81
+ }
82
+ catch (e) {
83
+ throw new Error(`resource "${resource.name}" failed: ${errorText(e)}`);
84
+ }
85
+ });
86
+ }
40
87
  return server;
41
88
  }
@@ -1,30 +1,97 @@
1
1
  import { z } from 'zod';
2
2
  import { runCanvasesList, runCanvasesGet } from '../../commands/canvases.js';
3
+ // The five real canvas types, derived from the (is_section, is_template, is_block) flags.
4
+ // One stable enum the model can filter and reason on — independent of any per-instance display
5
+ // label an admin may configure for these in the UI.
6
+ const CANVAS_TYPES = ['presentation', 'template', 'section', 'section_template', 'block'];
7
+ function canvasType(c) {
8
+ if (c.is_block === true)
9
+ return 'block';
10
+ if (c.is_section === true && c.is_template === true)
11
+ return 'section_template';
12
+ if (c.is_template === true)
13
+ return 'template';
14
+ if (c.is_section === true)
15
+ return 'section';
16
+ return 'presentation';
17
+ }
18
+ // type → the explicit flag filter the Django canvases endpoint understands (false is honored server-side).
19
+ // Non-block types pin isBlock:false so the canonical filter is unambiguous even if a caller also
20
+ // passed a conflicting legacy flag.
21
+ const TYPE_FLAGS = {
22
+ presentation: { isSection: false, isTemplate: false, isBlock: false },
23
+ template: { isSection: false, isTemplate: true, isBlock: false },
24
+ section: { isSection: true, isTemplate: false, isBlock: false },
25
+ section_template: { isSection: true, isTemplate: true, isBlock: false },
26
+ block: { isBlock: true },
27
+ };
28
+ // Tag a canvas object with its derived `type`; leave non-objects as-is. Spread `...c` FIRST so
29
+ // the derived value always wins — if the API payload ever carries its own `type`, our canonical
30
+ // enum must not be clobbered by it (JSON key order is cosmetic; correctness needs derived-last).
31
+ function withType(c) {
32
+ if (c && typeof c === 'object' && !Array.isArray(c))
33
+ return { ...c, type: canvasType(c) };
34
+ return c;
35
+ }
36
+ // Wrapper: maps the `type` filter to explicit flags, then tags each returned canvas with its derived
37
+ // `type`. Non-ok envelopes pass through untouched. `wraps` lets the coverage guard match by identity.
38
+ async function runCanvasesListTyped(opts, deps) {
39
+ // Pull the legacy flags out of rest so a canonical `type` can override them entirely — otherwise
40
+ // e.g. `type: 'presentation'` + `isBlock: true` would still send is_block=true and return blocks.
41
+ const { type, isSection, isTemplate, isBlock, ...rest } = opts;
42
+ const flags = typeof type === 'string' && type in TYPE_FLAGS ? TYPE_FLAGS[type] : { isSection, isTemplate, isBlock };
43
+ const env = await runCanvasesList({ ...rest, ...flags }, deps);
44
+ if (!env.ok)
45
+ return env;
46
+ const data = env.data;
47
+ if (Array.isArray(data?.results))
48
+ return { ...env, data: { ...data, results: data.results.map(withType) } };
49
+ return env;
50
+ }
51
+ async function runCanvasesGetTyped(opts, deps) {
52
+ const env = await runCanvasesGet(opts, deps);
53
+ if (!env.ok)
54
+ return env;
55
+ return { ...env, data: withType(env.data) };
56
+ }
3
57
  export const canvasesTools = [
4
58
  {
5
59
  name: 'pitcher_canvases_list',
6
60
  title: 'List canvases',
7
- description: 'List canvases (presentations, sections, blocks) in an instance. Use to discover canvas IDs before sharing or reading. Blocks are hidden unless isBlock is true.',
61
+ description: 'List canvases in an instance and discover their IDs. Each canvas is one of five types (returned as `type`): ' +
62
+ "presentation (a rep's finished deck), template (a reusable starter deck), section (a single slide / product " +
63
+ 'module), section_template (a reusable section), or block (a reusable component group). ' +
64
+ 'Filter to one with `type` (preferred) — e.g. type="presentation" for finished decks, type="template" for templates. ' +
65
+ 'With no filter, presentations + templates + sections come back together and blocks are hidden. ' +
66
+ 'Some instances rename these types in their UI (e.g. section → "Slide"); pitcher_instances_info returns a ' +
67
+ '`terminology` map — use those labels when addressing the user, but keep filtering on the canonical `type`.',
8
68
  inputShape: {
9
69
  instanceId: z.string().describe('Instance ID (required)'),
10
- isTemplate: z.boolean().optional().describe('Only templates'),
11
- isSection: z.boolean().optional().describe('Only sections'),
12
- isBlock: z.boolean().optional().describe('Only blocks (hidden unless set)'),
70
+ type: z
71
+ .enum(CANVAS_TYPES)
72
+ .optional()
73
+ .describe('Filter to one canvas type (preferred over the is* flags): presentation | template | section | section_template | block'),
74
+ isTemplate: z.boolean().optional().describe('Legacy flag filter; prefer `type`'),
75
+ isSection: z.boolean().optional().describe('Legacy flag filter; prefer `type`'),
76
+ isBlock: z.boolean().optional().describe('Legacy flag filter; prefer `type`. Blocks are hidden unless set'),
13
77
  tags: z.string().optional().describe('Comma-separated tags'),
14
78
  idIn: z.string().optional().describe('Comma-separated canvas IDs'),
15
79
  accessType: z.enum(['public', 'restricted', 'personal']).optional(),
16
80
  page: z.number().int().optional(),
17
81
  pageSize: z.number().int().optional().describe('Page size (max 500)'),
18
82
  },
19
- run: runCanvasesList,
83
+ run: runCanvasesListTyped,
84
+ wraps: runCanvasesList,
20
85
  readOnly: true,
21
86
  },
22
87
  {
23
88
  name: 'pitcher_canvases_get',
24
89
  title: 'Get a canvas',
25
- description: 'Retrieve a single canvas by its ID.',
90
+ description: 'Retrieve a single canvas by its ID. The returned object includes a derived `type` ' +
91
+ '(presentation, template, section, section_template, or block).',
26
92
  inputShape: { id: z.string().describe('Canvas ID') },
27
- run: runCanvasesGet,
93
+ run: runCanvasesGetTyped,
94
+ wraps: runCanvasesGet,
28
95
  readOnly: true,
29
96
  },
30
97
  ];
@@ -9,8 +9,16 @@ export const dsrLandingTools = [
9
9
  inputShape: {
10
10
  instanceId: z.string().describe('Instance ID (required)'),
11
11
  name: z.string().describe('Template name (required)'),
12
- htmlFile: z.string().describe('Path to the landing HTML (required; must contain data-pitcher-email-gate)'),
13
- cssFile: z.string().optional().describe('Path to a CSS file for the template'),
12
+ html: z
13
+ .string()
14
+ .optional()
15
+ .describe('Inline landing HTML (must contain data-pitcher-email-gate). Use this OR htmlFile — prefer this in hosts without a shared filesystem, e.g. Claude Desktop.'),
16
+ htmlFile: z
17
+ .string()
18
+ .optional()
19
+ .describe('Path to the landing HTML (must contain data-pitcher-email-gate). Use this OR html.'),
20
+ css: z.string().optional().describe('Inline CSS for the template. Use this OR cssFile.'),
21
+ cssFile: z.string().optional().describe('Path to a CSS file for the template. Use this OR css.'),
14
22
  mergeSchema: z.string().optional().describe('Merge schema JSON array as a string'),
15
23
  thumbnailUrl: z.string().optional().describe('Thumbnail image URL'),
16
24
  language: z.string().optional().describe('Language code (max 10 chars)'),
@@ -8,6 +8,10 @@ export const emailTemplateTools = [
8
8
  inputShape: {
9
9
  instanceId: z.string().describe('Instance ID (required)'),
10
10
  name: z.string().describe('Template name (required)'),
11
+ bodyHtml: z
12
+ .string()
13
+ .optional()
14
+ .describe('Inline HTML body (inline CSS). Use this OR bodyFile — prefer this in hosts without a shared filesystem, e.g. Claude Desktop.'),
11
15
  bodyFile: z.string().optional().describe('Path to an HTML file (inline CSS) used as the template body'),
12
16
  subject: z.string().optional().describe('Email subject (supports {{tokens}})'),
13
17
  tags: z.string().optional().describe('Tags JSON object as a string'),
@@ -1,5 +1,56 @@
1
1
  import { z } from 'zod';
2
2
  import { runInstancesList, runInstancesInfo } from '../../commands/instances.js';
3
+ // An instance's settings.translation_overrides (keyed by language, using Pitcher's UI keys) lets an
4
+ // admin relabel the canvas types in their UI — e.g. section → "Slide", canvas → "PitchDeck". Map those
5
+ // onto the canonical canvas `type` names so the model can address the user in each instance's own
6
+ // vocabulary while still reasoning/filtering on the stable `type`. Zero extra calls — the overrides are
7
+ // already in the instance payload.
8
+ function terminologyFromOverrides(overrides) {
9
+ var _a;
10
+ if (!overrides || typeof overrides !== 'object')
11
+ return undefined;
12
+ const out = {};
13
+ for (const [lang, raw] of Object.entries(overrides)) {
14
+ if (!raw || typeof raw !== 'object')
15
+ continue;
16
+ // Fold keys to lowercase: the platform's override engine matches dotless keys
17
+ // case-insensitively (RegExp('\\b'+key+'\\b','gi') in canvas-ui translations.ts), and real
18
+ // instances do use e.g. `CANVAS`. First occurrence wins on a (theoretical) case collision.
19
+ const m = {};
20
+ for (const [k, v] of Object.entries(raw))
21
+ m[_a = k.toLowerCase()] ?? (m[_a] = v);
22
+ const labels = {};
23
+ const put = (type, ...keys) => {
24
+ for (const k of keys) {
25
+ const v = m[k];
26
+ if (typeof v === 'string' && v) {
27
+ labels[type] = v;
28
+ return;
29
+ }
30
+ }
31
+ };
32
+ put('presentation', 'canvas');
33
+ put('section', 'section');
34
+ put('template', 'nav.templates', 'navigation.templates');
35
+ put('block', 'nav.blocks', 'navigation.blocks');
36
+ if (Object.keys(labels).length)
37
+ out[lang] = labels;
38
+ }
39
+ return Object.keys(out).length ? out : undefined;
40
+ }
41
+ // Wrapper: attach a normalized `terminology` map derived from the instance's translation_overrides,
42
+ // so the model knows what this customer calls each canvas type. `wraps` keeps the coverage guard happy.
43
+ async function runInstancesInfoWithTerminology(opts, deps) {
44
+ const env = await runInstancesInfo(opts, deps);
45
+ if (!env.ok || !env.data || typeof env.data !== 'object')
46
+ return env;
47
+ const data = env.data;
48
+ const settings = data.settings;
49
+ const terminology = terminologyFromOverrides(settings?.translation_overrides);
50
+ if (!terminology)
51
+ return env;
52
+ return { ...env, data: { ...data, terminology } };
53
+ }
3
54
  export const instancesTools = [
4
55
  {
5
56
  name: 'pitcher_instances_list',
@@ -19,10 +70,13 @@ export const instancesTools = [
19
70
  {
20
71
  name: 'pitcher_instances_info',
21
72
  title: 'Get an instance',
22
- description: 'Retrieve details for a single instance by its ID.' +
23
- ' Output may include raw instance settings/metadata (potentially integration credentials) - treat as sensitive.',
73
+ description: 'Retrieve details for a single instance by its ID. If the instance renames canvas types in its UI, a ' +
74
+ 'normalized `terminology` map is included ({ <lang>: { presentation, section, template, block } }, e.g. ' +
75
+ 'section → "Slide") — use those labels when addressing the user, but keep filtering/reasoning on the canonical ' +
76
+ 'canvas `type`. Output may include raw instance settings/metadata (potentially integration credentials) - treat as sensitive.',
24
77
  inputShape: { id: z.string().describe('Instance ID') },
25
- run: runInstancesInfo,
78
+ run: runInstancesInfoWithTerminology,
79
+ wraps: runInstancesInfo,
26
80
  readOnly: true,
27
81
  },
28
82
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitcher/cli-scripts",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "The Pitcher CLI for various tasks in its ecosystem.",
5
5
  "author": "Pitcher AG",
6
6
  "license": "MIT",
@@ -14,9 +14,11 @@
14
14
  "types": "./dist/index.d.ts",
15
15
  "scripts": {
16
16
  "build": "tsc",
17
+ "type-check": "tsc --noEmit && tsc -p mcpb/tsconfig.json",
17
18
  "test": "vitest run",
18
19
  "test:watch": "vitest",
19
- "prepublishOnly": "yarn build"
20
+ "prepublishOnly": "yarn build",
21
+ "mcpb:pack": "tsc -p mcpb/tsconfig.json && node mcpb/build.mjs"
20
22
  },
21
23
  "bin": {
22
24
  "p": "./dist/index.js"
@@ -25,8 +27,10 @@
25
27
  "dist"
26
28
  ],
27
29
  "devDependencies": {
30
+ "@anthropic-ai/mcpb": "^2.1.2",
28
31
  "@types/archiver": "^7.0.0",
29
32
  "@types/node": "^24.13.2",
33
+ "esbuild": "^0.28.1",
30
34
  "typescript": "^6.0.3",
31
35
  "vitest": "^4.1.9"
32
36
  },