@salesforce/b2c-dx-mcp 1.4.1 → 1.5.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.
@@ -21,6 +21,7 @@ export default class McpServerCommand extends BaseCommand<typeof McpServerComman
21
21
  static flags: {
22
22
  toolsets: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
23
23
  tools: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
+ 'docs-topics': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
25
  'allow-non-ga-tools': import("@oclif/core/interfaces").BooleanFlag<boolean>;
25
26
  server: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
26
27
  'webdav-server': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -34,7 +35,7 @@ export default class McpServerCommand extends BaseCommand<typeof McpServerComman
34
35
  'client-id': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
35
36
  'client-secret': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
36
37
  'auth-scope': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
37
- 'short-code': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
38
+ 'short-code': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
38
39
  'tenant-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
39
40
  'auth-methods': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
40
41
  'user-auth': import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -193,6 +193,12 @@ export default class McpServerCommand extends BaseCommand {
193
193
  env: 'SFCC_TOOLS',
194
194
  parse: async (input) => input.toLowerCase(),
195
195
  }),
196
+ 'docs-topics': Flags.string({
197
+ description: 'Limit the documentation exposed by the docs tools to these categories (comma-separated allowlist). ' +
198
+ 'Options: script-api, job-step, commerce-api, pwa-kit-managed-runtime, sfnext, sfra, b2c-commerce, tooling. ' +
199
+ 'Bounds the whole docs corpus; per-call category/storefront narrow within it. Unknown names are ignored.',
200
+ env: 'SFCC_DOCS_TOPICS',
201
+ }),
196
202
  // Feature flags
197
203
  'allow-non-ga-tools': Flags.boolean({
198
204
  description: 'Enable non-GA (experimental) tools',
@@ -302,6 +308,8 @@ export default class McpServerCommand extends BaseCommand {
302
308
  configPath: this.flags.config,
303
309
  // Project directory for auto-discovery. oclif handles flag with env fallback.
304
310
  projectDirectory: this.flags['project-directory'],
311
+ // Docs topic allowlist (bounds the docs corpus at startup).
312
+ docsTopics: this.flags['docs-topics'],
305
313
  };
306
314
  // Add toolsets to telemetry attributes
307
315
  if (this.telemetry && startupFlags.toolsets) {
@@ -1,3 +1,5 @@
1
+ import { type ProjectType } from '@salesforce/b2c-tooling-sdk/discovery';
2
+ import { type DocCategory } from '@salesforce/b2c-tooling-sdk/docs';
1
3
  import type { McpTool, Toolset, StartupFlags } from './utils/index.js';
2
4
  import type { B2CDxMcpServer } from './server.js';
3
5
  import type { Services } from './services.js';
@@ -15,5 +17,5 @@ export type ToolRegistry = Record<Toolset, McpTool[]>;
15
17
  * @param loadServices - Function that loads configuration and returns Services instance
16
18
  * @returns Complete tool registry
17
19
  */
18
- export declare function createToolRegistry(loadServices: () => Promise<Services> | Services, serverContext?: ServerContext): ToolRegistry;
20
+ export declare function createToolRegistry(loadServices: () => Promise<Services> | Services, serverContext?: ServerContext, detectedWorkspaces?: readonly ProjectType[], enabledDocCategories?: readonly DocCategory[]): ToolRegistry;
19
21
  export declare function registerToolsets(flags: StartupFlags, server: B2CDxMcpServer, loadServices: () => Promise<Services> | Services, serverContext?: ServerContext): Promise<void>;
package/dist/registry.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { getLogger } from '@salesforce/b2c-tooling-sdk/logging';
7
7
  import { detectWorkspaceType } from '@salesforce/b2c-tooling-sdk/discovery';
8
+ import { DOC_CATEGORIES, resolveEnabledCategories } from '@salesforce/b2c-tooling-sdk/docs';
8
9
  import { ALL_TOOLSETS, DEPRECATED_TOOLSETS, TOOLSETS, VALID_TOOLSET_NAMES } from './utils/index.js';
9
10
  import { createCartridgesTools } from './tools/cartridges/index.js';
10
11
  import { createDiagnosticsTools } from './tools/diagnostics/index.js';
@@ -26,6 +27,9 @@ const BASE_TOOLSETS = ['SCAPI', 'DIAGNOSTICS'];
26
27
  */
27
28
  const PROJECT_TYPE_TOOLSETS = {
28
29
  cartridges: ['CARTRIDGES'],
30
+ // SFRA is a cartridge project, so it enables the same toolset as `cartridges`.
31
+ // (A SFRA workspace also matches the `cartridges` marker; the union dedupes.)
32
+ sfra: ['CARTRIDGES'],
29
33
  'pwa-kit-v3': ['PWAV3', 'MRT'],
30
34
  // Note: STOREFRONTNEXT_DEPRECATED is intentionally NOT auto-activated. The
31
35
  // legacy sfnext_* tools are superseded by the storefront-next agent-skills
@@ -70,7 +74,7 @@ function getToolsetsForProjectTypes(projectTypes) {
70
74
  * @param loadServices - Function that loads configuration and returns Services instance
71
75
  * @returns Complete tool registry
72
76
  */
73
- export function createToolRegistry(loadServices, serverContext) {
77
+ export function createToolRegistry(loadServices, serverContext, detectedWorkspaces = [], enabledDocCategories) {
74
78
  const registry = {
75
79
  CARTRIDGES: [],
76
80
  DIAGNOSTICS: [],
@@ -84,7 +88,7 @@ export function createToolRegistry(loadServices, serverContext) {
84
88
  const allTools = [
85
89
  ...createCartridgesTools(loadServices),
86
90
  ...createDiagnosticsTools(loadServices, serverContext),
87
- ...createDocsTools(loadServices),
91
+ ...createDocsTools(loadServices, { detectedWorkspaces, enabledCategories: enabledDocCategories }),
88
92
  ...createMrtTools(loadServices),
89
93
  ...createPwav3Tools(loadServices),
90
94
  ...createScapiTools(loadServices),
@@ -106,7 +110,7 @@ export function createToolRegistry(loadServices, serverContext) {
106
110
  * @param reason - Reason for triggering auto-discovery (for logging)
107
111
  * @returns Array of toolsets to enable
108
112
  */
109
- async function performAutoDiscovery(flags, reason) {
113
+ async function detectProjectTypes(flags) {
110
114
  const logger = getLogger();
111
115
  // Project directory from --project-directory flag or SFCC_PROJECT_DIRECTORY env var
112
116
  const projectDirectory = flags.projectDirectory ?? process.cwd();
@@ -117,16 +121,8 @@ async function performAutoDiscovery(flags, reason) {
117
121
  'Set --project-directory or SFCC_PROJECT_DIRECTORY for reliable auto-discovery.');
118
122
  }
119
123
  const detectionResult = await detectWorkspaceType(projectDirectory);
120
- // Map all detected project types to MCP toolsets (union)
121
- // Note: getToolsetsForProjectTypes always includes BASE_TOOLSET
122
- const mappedToolsets = getToolsetsForProjectTypes(detectionResult.projectTypes);
123
- logger.info({
124
- reason,
125
- projectTypes: detectionResult.projectTypes,
126
- matchedPatterns: detectionResult.matchedPatterns,
127
- enabledToolsets: mappedToolsets,
128
- }, `Auto-discovery (${reason}): project types: ${detectionResult.projectTypes.join(', ') || 'none'}`);
129
- return mappedToolsets;
124
+ logger.info({ projectTypes: detectionResult.projectTypes, matchedPatterns: detectionResult.matchedPatterns }, `Workspace detection: project types: ${detectionResult.projectTypes.join(', ') || 'none'}`);
125
+ return detectionResult.projectTypes;
130
126
  }
131
127
  /**
132
128
  * Register tools with the MCP server based on startup flags.
@@ -160,8 +156,18 @@ export async function registerToolsets(flags, server, loadServices, serverContex
160
156
  const individualTools = flags.tools ?? [];
161
157
  const allowNonGaTools = flags.allowNonGaTools ?? false;
162
158
  const logger = getLogger();
159
+ // Detect the workspace type once up front. It informs both the
160
+ // docs tools (favoring the current workspace's docs and surfacing it in the
161
+ // tool descriptions) and toolset auto-discovery below, so we only hit the
162
+ // filesystem a single time.
163
+ const detectedWorkspaces = await detectProjectTypes(flags);
164
+ // Resolve the launch-time docs topic allowlist (bounds the whole docs corpus).
165
+ const enabledDocCategories = resolveEnabledCategories(flags.docsTopics, (invalid) => logger.warn({ invalidTopics: invalid, validTopics: DOC_CATEGORIES }, `Ignoring unknown documentation topic(s) in --docs-topics: "${invalid.join('", "')}"`));
166
+ if (enabledDocCategories) {
167
+ logger.info({ docsTopics: enabledDocCategories }, `Documentation restricted to topics: ${enabledDocCategories.join(', ')}`);
168
+ }
163
169
  // Create the tool registry (all available tools)
164
- const toolRegistry = createToolRegistry(loadServices, serverContext);
170
+ const toolRegistry = createToolRegistry(loadServices, serverContext, detectedWorkspaces, enabledDocCategories);
165
171
  // Build flat list of all tools for lookup
166
172
  const allTools = Object.values(toolRegistry).flat();
167
173
  const allToolsByName = new Map(allTools.map((tool) => [tool.name, tool]));
@@ -184,12 +190,13 @@ export async function registerToolsets(flags, server, loadServices, serverContex
184
190
  const validToolsets = toolsets.filter((t) => TOOLSETS.includes(t));
185
191
  const allNonDeprecatedToolsets = TOOLSETS.filter((t) => !DEPRECATED_TOOLSETS.includes(t));
186
192
  const toolsetsToEnable = new Set(toolsets.includes(ALL_TOOLSETS) ? allNonDeprecatedToolsets : validToolsets);
187
- // Auto-discovery: If no valid toolsets AND no valid individual tools, detect workspace type.
188
- // This handles both: (1) no flags provided, and (2) all provided flags are invalid.
189
- // Auto-discovery enables appropriate toolsets based on workspace type,
190
- // or at minimum the BASE_TOOLSETS if no project types are detected.
193
+ // Auto-discovery: If no valid toolsets AND no valid individual tools, map the
194
+ // already-detected workspace type(s) to toolsets. This handles both: (1) no
195
+ // flags provided, and (2) all provided flags are invalid. Always enables at
196
+ // least the BASE_TOOLSETS even if no project types are detected.
191
197
  if (toolsetsToEnable.size === 0 && validIndividualTools.length === 0) {
192
- const discoveredToolsets = await performAutoDiscovery(flags, 'no valid toolsets or tools');
198
+ const discoveredToolsets = getToolsetsForProjectTypes(detectedWorkspaces);
199
+ logger.info({ projectTypes: detectedWorkspaces, enabledToolsets: discoveredToolsets }, `Auto-discovery: enabling toolsets ${discoveredToolsets.join(', ')}`);
193
200
  for (const toolset of discoveredToolsets) {
194
201
  toolsetsToEnable.add(toolset);
195
202
  }
package/dist/server.js CHANGED
@@ -48,11 +48,14 @@ export class B2CDxMcpServer extends McpServer {
48
48
  try {
49
49
  const result = await handler(args);
50
50
  const runTimeMs = Date.now() - startTime;
51
+ // Extract error message from CallToolResult content when isError is true
52
+ const errorMessage = result.isError ? result.content?.find((c) => c.type === 'text')?.text : undefined;
51
53
  await this.telemetry
52
54
  ?.sendEventAndFlush('TOOL_CALLED', {
53
55
  toolName: name,
54
56
  runTimeMs,
55
57
  isError: result.isError ?? false,
58
+ ...(errorMessage ? { errorMessage } : {}),
56
59
  })
57
60
  .catch((error_) => {
58
61
  getLogger().debug({ err: error_, toolName: name }, '[mcp] telemetry sendEventAndFlush failed');
@@ -66,6 +69,8 @@ export class B2CDxMcpServer extends McpServer {
66
69
  toolName: name,
67
70
  runTimeMs,
68
71
  isError: true,
72
+ errorMessage: error instanceof Error ? error.message : String(error),
73
+ ...(error instanceof Error && error.cause ? { errorCause: String(error.cause) } : {}),
69
74
  })
70
75
  .catch((error_) => {
71
76
  getLogger().debug({ err: error_, toolName: name }, '[mcp] telemetry sendEventAndFlush failed');
@@ -9,6 +9,9 @@ import { projectFrame, projectVariable, resolveBreakpointPath, } from '@salesfor
9
9
  import { getRegistry, getSessionEntry } from './session-registry.js';
10
10
  const DEFAULT_TIMEOUT_MS = 30_000;
11
11
  const MAX_TIMEOUT_MS = 120_000;
12
+ const TIMEOUT_HINT = 'Breakpoint not hit. First confirm the triggered request actually exercised this code path and the breakpoint line is reachable. ' +
13
+ 'In some production instance group configurations (which can run multiple app servers — this never applies to sandboxes), a breakpoint may be missed because the request landed on a different app server than the one holding the debug session. ' +
14
+ 'Only in that specific case, retrieve session_cookie from debug_list_sessions and resend the triggering request with that cookie (Cookie: <name>=<value>) to pin it to the app server holding the session.';
12
15
  export function createDebugCaptureAtBreakpointTool(loadServices, serverContext) {
13
16
  return createToolAdapter({
14
17
  name: 'debug_capture_at_breakpoint',
@@ -73,6 +76,7 @@ export function createDebugCaptureAtBreakpointTool(loadServices, serverContext)
73
76
  halted: false,
74
77
  timed_out: true,
75
78
  auto_continued: false,
79
+ hint: TIMEOUT_HINT,
76
80
  };
77
81
  }
78
82
  const threadDetail = await entry.manager.client.getThread(thread.id);
@@ -9,8 +9,8 @@ import { getRegistry } from './session-registry.js';
9
9
  export function createDebugListSessionsTool(loadServices, serverContext) {
10
10
  return createToolAdapter({
11
11
  name: 'debug_list_sessions',
12
- description: 'List active script debugger sessions with their breakpoints, any halted threads, and the session_cookie (e.g. dwsid). ' +
13
- 'Use this to discover orphaned sessions, check whether breakpoints are armed, poll for halted threads in the non-blocking debug workflow, and retrieve the session cookie to pin a triggering request (Cookie: <name>=<value>) to the app server holding the session.',
12
+ description: 'List active script debugger sessions with their breakpoints and any halted threads. ' +
13
+ 'Use this to discover orphaned sessions, check whether breakpoints are armed, and poll for halted threads in the non-blocking debug workflow.',
14
14
  toolsets: ['CARTRIDGES', 'DIAGNOSTICS', 'SCAPI'],
15
15
  inputSchema: {},
16
16
  async execute(_args, context) {
@@ -13,9 +13,7 @@ export function createDebugStartSessionTool(loadServices, serverContext) {
13
13
  name: 'debug_start_session',
14
14
  description: 'Start a script debugger session on a B2C Commerce instance to debug SFRA controllers, custom API scripts, hooks, jobs, or any server-side script. ' +
15
15
  'Returns a session_id for use with other debug tools, plus discovered cartridge mappings. ' +
16
- 'Also returns session_cookie (e.g. dwsid): to reliably hit a breakpoint on a multi-app-server instance, send the request that triggers your code (storefront page load, SCAPI/OCAPI call) with this cookie (Cookie: <name>=<value>) so it lands on the same app server holding the debug session. ' +
17
- 'WARNING: Debug sessions halt remote request threads on the instance. Always call debug_end_session when finished. ' +
18
- 'Requires Basic auth credentials (username/password) and the script debugger enabled in Business Manager.',
16
+ 'WARNING: Debug sessions halt remote request threads on the instance. Always call debug_end_session when finished.',
19
17
  toolsets: ['CARTRIDGES', 'DIAGNOSTICS', 'SCAPI'],
20
18
  inputSchema: {
21
19
  cartridge_directory: z
@@ -31,8 +29,8 @@ export function createDebugStartSessionTool(loadServices, serverContext) {
31
29
  const registry = getRegistry(context);
32
30
  const credentials = context.services.getBasicAuthCredentials();
33
31
  if (!credentials) {
34
- throw new Error('Basic auth credentials (hostname, username, password) are required for the script debugger. ' +
35
- 'Set via SFCC_SERVER/SFCC_USERNAME/SFCC_PASSWORD env vars, or configure in dw.json.');
32
+ throw new Error('Basic auth credentials (username/password) are required for the script debugger. ' +
33
+ 'Set via SFCC_SERVER/SFCC_USERNAME/SFCC_PASSWORD env vars, or dw.json.');
36
34
  }
37
35
  const { hostname, username, password } = credentials;
38
36
  const clientId = args.client_id ?? 'b2c-cli';
@@ -9,6 +9,9 @@ import { projectThreadLocation } from '@salesforce/b2c-tooling-sdk/operations/de
9
9
  import { getRegistry, getSessionEntry } from './session-registry.js';
10
10
  const DEFAULT_TIMEOUT_MS = 30_000;
11
11
  const MAX_TIMEOUT_MS = 120_000;
12
+ const TIMEOUT_HINT = 'Breakpoint not hit. First confirm the request actually exercised this code path and the breakpoint line is reachable. ' +
13
+ 'In some production instance group configurations (which can run multiple app servers — this never applies to sandboxes), a breakpoint may be missed because the request landed on a different app server than the one holding the debug session. ' +
14
+ 'Only in that specific case, retrieve session_cookie from debug_list_sessions and resend the triggering request with that cookie (Cookie: <name>=<value>) to pin it to the app server holding the session.';
12
15
  export function createDebugWaitForStopTool(loadServices, serverContext) {
13
16
  return createToolAdapter({
14
17
  name: 'debug_wait_for_stop',
@@ -31,7 +34,7 @@ export function createDebugWaitForStopTool(loadServices, serverContext) {
31
34
  const timeout = Math.min(args.timeout_ms ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
32
35
  const thread = await getRegistry(context).waitForHalt(entry, timeout);
33
36
  if (!thread)
34
- return { halted: false, timed_out: true };
37
+ return { halted: false, timed_out: true, hint: TIMEOUT_HINT };
35
38
  const location = projectThreadLocation(thread, entry.sourceMapper);
36
39
  return {
37
40
  halted: true,
@@ -1,3 +1,5 @@
1
+ import { type DocCategory } from '@salesforce/b2c-tooling-sdk/docs';
2
+ import type { ProjectType } from '@salesforce/b2c-tooling-sdk/discovery';
1
3
  import type { McpTool } from '../../utils/index.js';
2
4
  import type { Services } from '../../services.js';
3
- export declare function createDocsListTool(loadServices: () => Promise<Services> | Services): McpTool;
5
+ export declare function createDocsListTool(loadServices: () => Promise<Services> | Services, detectedWorkspaces?: readonly ProjectType[], enabledCategories?: readonly DocCategory[]): McpTool;
@@ -3,18 +3,78 @@
3
3
  * SPDX-License-Identifier: Apache-2
4
4
  * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5
5
  */
6
- import { listDocs } from '@salesforce/b2c-tooling-sdk/docs';
6
+ import { z } from 'zod';
7
+ import { listDocs, categoriesForWorkspace } from '@salesforce/b2c-tooling-sdk/docs';
7
8
  import { createToolAdapter, jsonResult } from '../adapter.js';
8
- export function createDocsListTool(loadServices) {
9
+ import { categoryEnumValues, enabledCategoriesNote } from './topics.js';
10
+ import { WORKSPACE_VALUES, detectedWorkspaceNote, resolveWorkspace } from './storefront.js';
11
+ /** Default page size for a filtered listing. Bounds payload size (large corpora hold 500+ entries). */
12
+ const DEFAULT_LIMIT = 100;
13
+ /** Projects a full entry to the table-of-contents shape. */
14
+ function toListEntry(entry) {
15
+ return { id: entry.id, title: entry.title, category: entry.category };
16
+ }
17
+ export function createDocsListTool(loadServices, detectedWorkspaces = [], enabledCategories) {
9
18
  return createToolAdapter({
10
19
  name: 'docs_list',
11
- description: 'List every available Script API documentation entry (id + title). ' +
12
- 'Output is large; prefer docs_search for targeted lookups.',
20
+ description: 'Enumerate B2C Commerce documentation entries (id + title + category only) for a category or workspace. ' +
21
+ 'Prefer docs_search for questions this tool is for browsing a known category. Without a category or ' +
22
+ 'workspace it returns just a category directory (counts), not the full corpus. Results are a table of ' +
23
+ 'contents; paginated via limit/offset. Use docs_read for content.' +
24
+ enabledCategoriesNote(enabledCategories) +
25
+ detectedWorkspaceNote(detectedWorkspaces),
13
26
  toolsets: ['CARTRIDGES', 'DIAGNOSTICS', 'MRT', 'PWAV3', 'SCAPI', 'STOREFRONTNEXT'],
14
- inputSchema: {},
15
- async execute() {
16
- const entries = listDocs();
17
- return { count: entries.length, entries };
27
+ inputSchema: {
28
+ category: z
29
+ .enum(categoryEnumValues(enabledCategories))
30
+ .optional()
31
+ .describe('Restrict the listing to one documentation category.'),
32
+ workspace: z
33
+ .enum(WORKSPACE_VALUES)
34
+ .optional()
35
+ .describe('Limit to a workspace\'s relevant categories. "auto" uses the auto-detected workspace; ' +
36
+ 'or name a type. Omit for the category directory.'),
37
+ limit: z.number().int().positive().optional().describe(`Max entries per page. Defaults to ${DEFAULT_LIMIT}.`),
38
+ offset: z.number().int().nonnegative().optional().describe('Number of entries to skip (for pagination).'),
39
+ },
40
+ async execute(args) {
41
+ // Explicit category wins; otherwise a workspace narrows to its relevant categories.
42
+ const workspace = resolveWorkspace(args.workspace, detectedWorkspaces);
43
+ const filter = args.category ??
44
+ (args.workspace && args.workspace !== 'all' && workspace ? categoriesForWorkspace(workspace) : undefined);
45
+ // No filter at all → return a compact directory of categories + counts,
46
+ // never the whole corpus (which would blow the inline payload budget).
47
+ // The directory itself is bounded by the launch-time allowlist.
48
+ if (!filter) {
49
+ const counts = new Map();
50
+ for (const e of listDocs(undefined, enabledCategories)) {
51
+ if (e.category)
52
+ counts.set(e.category, (counts.get(e.category) ?? 0) + 1);
53
+ }
54
+ const categories = [...counts.entries()]
55
+ .map(([category, count]) => ({ category, count }))
56
+ .sort((a, b) => b.count - a.count);
57
+ const total = categories.reduce((sum, c) => sum + c.count, 0);
58
+ return {
59
+ note: 'Pass a category (or workspace) to list its entries, or use docs_search for a query.',
60
+ total,
61
+ categories,
62
+ };
63
+ }
64
+ const all = listDocs(filter, enabledCategories);
65
+ const offset = args.offset ?? 0;
66
+ const limit = args.limit ?? DEFAULT_LIMIT;
67
+ const page = all.slice(offset, offset + limit).map((e) => toListEntry(e));
68
+ const end = offset + page.length;
69
+ const truncated = end < all.length;
70
+ return {
71
+ category: filter,
72
+ total: all.length,
73
+ offset,
74
+ count: page.length,
75
+ entries: page,
76
+ ...(truncated && { truncated: true, nextOffset: end }),
77
+ };
18
78
  },
19
79
  formatOutput: (output) => jsonResult(output),
20
80
  }, loadServices);
@@ -1,3 +1,4 @@
1
+ import { type DocCategory } from '@salesforce/b2c-tooling-sdk/docs';
1
2
  import type { McpTool } from '../../utils/index.js';
2
3
  import type { Services } from '../../services.js';
3
- export declare function createDocsReadTool(loadServices: () => Promise<Services> | Services): McpTool;
4
+ export declare function createDocsReadTool(loadServices: () => Promise<Services> | Services, enabledCategories?: readonly DocCategory[]): McpTool;
@@ -6,18 +6,56 @@
6
6
  import { z } from 'zod';
7
7
  import { readDocByQuery } from '@salesforce/b2c-tooling-sdk/docs';
8
8
  import { createToolAdapter, errorResult, jsonResult } from '../adapter.js';
9
- export function createDocsReadTool(loadServices) {
9
+ import { enabledCategoriesNote } from './topics.js';
10
+ /** Default maximum characters of content returned per read call, to bound the inline payload. */
11
+ const DEFAULT_MAX_LENGTH = 12_000;
12
+ export function createDocsReadTool(loadServices, enabledCategories) {
10
13
  return createToolAdapter({
11
14
  name: 'docs_read',
12
- description: 'Read full Script API documentation (markdown) for a B2C Commerce class or module. ' +
13
- 'Accepts an exact id (e.g., "dw.catalog.ProductMgr") or fuzzy query — best match wins. ' +
14
- 'Content can be large; if you do not know the id, call docs_search first to narrow down.',
15
+ description: 'Read B2C Commerce documentation (markdown) for a class, module, job step, or guide. ' +
16
+ 'Accepts an exact id (e.g. "dw.catalog.ProductMgr", "sfnext/sfnext-get-started") or a fuzzy ' +
17
+ 'query best match wins. Script API / job-step content is bundled; Developer Center guide ' +
18
+ 'content is fetched from its published URL on demand (with a summary/headings fallback if the ' +
19
+ 'network is unavailable). If you do not know the id, call docs_search first. Long docs are ' +
20
+ 'truncated to maxLength chars; page with offset when truncated=true. The returned entry ' +
21
+ 'includes the canonical url for citation.' +
22
+ enabledCategoriesNote(enabledCategories),
15
23
  toolsets: ['CARTRIDGES', 'DIAGNOSTICS', 'MRT', 'PWAV3', 'SCAPI', 'STOREFRONTNEXT'],
16
24
  inputSchema: {
17
- query: z.string().min(1).describe('Exact id ("dw.catalog.ProductMgr") or fuzzy query ("ProductMgr").'),
25
+ query: z
26
+ .string()
27
+ .min(1)
28
+ .describe('Exact id ("dw.catalog.ProductMgr", "sfnext/sfnext-get-started") or fuzzy query.'),
29
+ offset: z
30
+ .number()
31
+ .int()
32
+ .nonnegative()
33
+ .optional()
34
+ .describe('Character offset to start reading from (for paging long docs). Defaults to 0.'),
35
+ maxLength: z
36
+ .number()
37
+ .int()
38
+ .positive()
39
+ .optional()
40
+ .describe(`Maximum characters of content to return. Defaults to ${DEFAULT_MAX_LENGTH}.`),
18
41
  },
19
42
  async execute(args) {
20
- return readDocByQuery(args.query);
43
+ const found = await readDocByQuery(args.query, { enabledCategories });
44
+ if (!found)
45
+ return null;
46
+ const totalLength = found.content.length;
47
+ const offset = Math.min(args.offset ?? 0, totalLength);
48
+ const maxLength = args.maxLength ?? DEFAULT_MAX_LENGTH;
49
+ const slice = found.content.slice(offset, offset + maxLength);
50
+ const end = offset + slice.length;
51
+ const truncated = end < totalLength;
52
+ return {
53
+ entry: found.entry,
54
+ content: slice,
55
+ totalLength,
56
+ offset,
57
+ ...(truncated && { truncated: true, nextOffset: end }),
58
+ };
21
59
  },
22
60
  formatOutput: (output) => output ? jsonResult(output) : errorResult('No documentation found. Try docs_search to find candidates.'),
23
61
  }, loadServices);
@@ -1,3 +1,5 @@
1
+ import { type DocCategory } from '@salesforce/b2c-tooling-sdk/docs';
2
+ import type { ProjectType } from '@salesforce/b2c-tooling-sdk/discovery';
1
3
  import type { McpTool } from '../../utils/index.js';
2
4
  import type { Services } from '../../services.js';
3
- export declare function createDocsSearchTool(loadServices: () => Promise<Services> | Services): McpTool;
5
+ export declare function createDocsSearchTool(loadServices: () => Promise<Services> | Services, detectedWorkspaces?: readonly ProjectType[], enabledCategories?: readonly DocCategory[]): McpTool;
@@ -6,20 +6,81 @@
6
6
  import { z } from 'zod';
7
7
  import { searchDocs } from '@salesforce/b2c-tooling-sdk/docs';
8
8
  import { createToolAdapter, jsonResult } from '../adapter.js';
9
- export function createDocsSearchTool(loadServices) {
9
+ import { categoryEnumValues, enabledCategoriesNote } from './topics.js';
10
+ import { WORKSPACE_VALUES, detectedWorkspaceNote, resolveWorkspace } from './storefront.js';
11
+ /** Default number of results returned when `limit` is not supplied. Kept small to bound payload size for agents. */
12
+ const DEFAULT_LIMIT = 5;
13
+ /**
14
+ * Projects a search hit to the payload returned to an agent. By default we keep
15
+ * only the triage-critical fields (id, title, category, summary, score) and drop
16
+ * `keywords` (index-tuning metadata) and `url` (derivable / returned on read),
17
+ * which together roughly double the payload. `verbose` restores them.
18
+ */
19
+ function leanResult(entry, score, verbose) {
20
+ const base = {
21
+ id: entry.id,
22
+ title: entry.title,
23
+ category: entry.category,
24
+ score,
25
+ };
26
+ if (entry.summary)
27
+ base.summary = entry.summary;
28
+ if (verbose) {
29
+ if (entry.keywords && entry.keywords.length > 0)
30
+ base.keywords = entry.keywords;
31
+ if (entry.url)
32
+ base.url = entry.url;
33
+ if (entry.sourceUrl)
34
+ base.sourceUrl = entry.sourceUrl;
35
+ }
36
+ return base;
37
+ }
38
+ export function createDocsSearchTool(loadServices, detectedWorkspaces = [], enabledCategories) {
10
39
  return createToolAdapter({
11
40
  name: 'docs_search',
12
- description: 'Fuzzy-search the bundled B2C Commerce Script API documentation by class, module, or partial name ' +
13
- '(e.g., "ProductMgr", "dw.catalog", "Status"). Returns matching ids + relevance score. ' +
14
- 'Use this BEFORE docs_read when you do not know the exact id.',
41
+ description: 'PRIMARY entry point for B2C Commerce docs: Script API reference (e.g. "ProductMgr"), standard job steps, ' +
42
+ 'Developer Center guides (commerce-api, pwa-kit-managed-runtime, sfnext, sfra, b2c-commerce), and this ' +
43
+ "tooling's own guides. Use for ANY B2C Commerce developer or admin question not already grounded in a " +
44
+ 'loaded skill or the current project. Content-aware ranking — pass a natural-language query (prefer this ' +
45
+ 'over docs_list, which only enumerates). Optionally restrict by category or workspace. Returns id, title, ' +
46
+ 'category, summary, and score for triage; pass verbose=true for keywords+url. Call this BEFORE docs_read ' +
47
+ 'when you do not know the exact id.' +
48
+ enabledCategoriesNote(enabledCategories) +
49
+ detectedWorkspaceNote(detectedWorkspaces),
15
50
  toolsets: ['CARTRIDGES', 'DIAGNOSTICS', 'MRT', 'PWAV3', 'SCAPI', 'STOREFRONTNEXT'],
16
51
  inputSchema: {
17
- query: z.string().min(1).describe('Search query (class name, module path, or partial match).'),
18
- limit: z.number().int().positive().optional().describe('Maximum number of results to return. Defaults to 20.'),
52
+ query: z.string().min(1).describe('Search query (class name, topic, or natural-language phrase).'),
53
+ category: z.enum(categoryEnumValues(enabledCategories)).optional().describe('Restrict results to one corpus.'),
54
+ workspace: z
55
+ .enum(WORKSPACE_VALUES)
56
+ .optional()
57
+ .describe('Workspace context. "auto" (default) favors the auto-detected workspace\'s docs; ' +
58
+ '"all" disables the preference; or name a type (cartridges, sfra, pwa-kit-v3, storefront-next).'),
59
+ limit: z
60
+ .number()
61
+ .int()
62
+ .positive()
63
+ .optional()
64
+ .describe(`Maximum number of results to return. Defaults to ${DEFAULT_LIMIT}.`),
65
+ verbose: z
66
+ .boolean()
67
+ .optional()
68
+ .describe('Include keywords and canonical url on each result (larger payload). Defaults to false.'),
19
69
  },
20
70
  async execute(args) {
21
- const results = searchDocs(args.query, args.limit ?? 20);
22
- return { query: args.query, results };
71
+ const workspace = resolveWorkspace(args.workspace, detectedWorkspaces);
72
+ const results = searchDocs(args.query, {
73
+ limit: args.limit ?? DEFAULT_LIMIT,
74
+ category: args.category,
75
+ workspace,
76
+ enabledCategories,
77
+ });
78
+ return {
79
+ query: args.query,
80
+ ...(args.category && { category: args.category }),
81
+ ...(workspace && { workspace }),
82
+ results: results.map((r) => leanResult(r.entry, r.score, args.verbose ?? false)),
83
+ };
23
84
  },
24
85
  formatOutput: (output) => jsonResult(output),
25
86
  }, loadServices);
@@ -1,3 +1,25 @@
1
+ import type { DocCategory } from '@salesforce/b2c-tooling-sdk/docs';
2
+ import type { ProjectType } from '@salesforce/b2c-tooling-sdk/discovery';
1
3
  import type { McpTool } from '../../utils/index.js';
2
4
  import type { Services } from '../../services.js';
3
- export declare function createDocsTools(loadServices: () => Promise<Services> | Services): McpTool[];
5
+ /**
6
+ * Server-startup context baked into the documentation tools.
7
+ */
8
+ export interface DocsToolContext {
9
+ /**
10
+ * Workspace type(s) detected at startup. Baked into the search/list
11
+ * tool descriptions and used as the default workspace context so results
12
+ * favor the current workspace.
13
+ */
14
+ detectedWorkspaces?: readonly ProjectType[];
15
+ /**
16
+ * A launch-time allowlist of documentation categories (from `--docs-topics`).
17
+ * When set, the docs tools expose ONLY these categories — a hard boundary the
18
+ * per-call `category`/`workspace` narrowing operates within.
19
+ */
20
+ enabledCategories?: readonly DocCategory[];
21
+ }
22
+ /**
23
+ * Builds the documentation tools with the given server-startup context.
24
+ */
25
+ export declare function createDocsTools(loadServices: () => Promise<Services> | Services, context?: DocsToolContext): McpTool[];
@@ -9,11 +9,15 @@ import { createDocsSchemaListTool } from './docs-schema-list.js';
9
9
  import { createDocsSchemaReadTool } from './docs-schema-read.js';
10
10
  import { createDocsSchemaSearchTool } from './docs-schema-search.js';
11
11
  import { createDocsSearchTool } from './docs-search.js';
12
- export function createDocsTools(loadServices) {
12
+ /**
13
+ * Builds the documentation tools with the given server-startup context.
14
+ */
15
+ export function createDocsTools(loadServices, context = {}) {
16
+ const { detectedWorkspaces = [], enabledCategories } = context;
13
17
  return [
14
- createDocsSearchTool(loadServices),
15
- createDocsReadTool(loadServices),
16
- createDocsListTool(loadServices),
18
+ createDocsSearchTool(loadServices, detectedWorkspaces, enabledCategories),
19
+ createDocsReadTool(loadServices, enabledCategories),
20
+ createDocsListTool(loadServices, detectedWorkspaces, enabledCategories),
17
21
  createDocsSchemaSearchTool(loadServices),
18
22
  createDocsSchemaReadTool(loadServices),
19
23
  createDocsSchemaListTool(loadServices),
@@ -0,0 +1,27 @@
1
+ import type { ProjectType } from '@salesforce/b2c-tooling-sdk/discovery';
2
+ /**
3
+ * Human-readable label for each detected workspace/project type, used in the
4
+ * docs tool descriptions so an agent can see what was auto-detected.
5
+ */
6
+ export declare const PROJECT_TYPE_LABELS: Record<ProjectType, string>;
7
+ /**
8
+ * Accepted values for the docs tools' `workspace` parameter. Mirrors the CLI
9
+ * `--workspace` vocabulary: `auto` uses the auto-detected workspace, `all`
10
+ * disables the preference, or name a concrete type.
11
+ */
12
+ export declare const WORKSPACE_VALUES: readonly ["auto", "all", "cartridges", "sfra", "pwa-kit-v3", "storefront-next"];
13
+ export type WorkspaceParam = (typeof WORKSPACE_VALUES)[number];
14
+ /**
15
+ * Resolves the `workspace` tool parameter into the concrete project type(s) to
16
+ * pass to the SDK search, given what was auto-detected at server startup.
17
+ *
18
+ * - `all` → undefined (no workspace preference)
19
+ * - `auto` (or unset) → the detected workspace(s), if any
20
+ * - an explicit type → that type
21
+ */
22
+ export declare function resolveWorkspace(param: undefined | WorkspaceParam, detected: readonly ProjectType[]): ProjectType[] | undefined;
23
+ /**
24
+ * Builds a short sentence describing the detected workspace for a tool
25
+ * description, or an empty string when nothing was detected.
26
+ */
27
+ export declare function detectedWorkspaceNote(detected: readonly ProjectType[]): string;
@@ -0,0 +1,48 @@
1
+ /*
2
+ * Copyright (c) 2025, Salesforce, Inc.
3
+ * SPDX-License-Identifier: Apache-2
4
+ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5
+ */
6
+ /**
7
+ * Human-readable label for each detected workspace/project type, used in the
8
+ * docs tool descriptions so an agent can see what was auto-detected.
9
+ */
10
+ export const PROJECT_TYPE_LABELS = {
11
+ cartridges: 'Cartridges',
12
+ sfra: 'SFRA (cartridges)',
13
+ 'pwa-kit-v3': 'PWA Kit (Composable Storefront)',
14
+ 'storefront-next': 'Storefront Next',
15
+ };
16
+ /**
17
+ * Accepted values for the docs tools' `workspace` parameter. Mirrors the CLI
18
+ * `--workspace` vocabulary: `auto` uses the auto-detected workspace, `all`
19
+ * disables the preference, or name a concrete type.
20
+ */
21
+ export const WORKSPACE_VALUES = ['auto', 'all', 'cartridges', 'sfra', 'pwa-kit-v3', 'storefront-next'];
22
+ /**
23
+ * Resolves the `workspace` tool parameter into the concrete project type(s) to
24
+ * pass to the SDK search, given what was auto-detected at server startup.
25
+ *
26
+ * - `all` → undefined (no workspace preference)
27
+ * - `auto` (or unset) → the detected workspace(s), if any
28
+ * - an explicit type → that type
29
+ */
30
+ export function resolveWorkspace(param, detected) {
31
+ if (param === 'all')
32
+ return undefined;
33
+ if (param && param !== 'auto')
34
+ return [param];
35
+ // 'auto' or unset: default to the detected workspace(s)
36
+ return detected.length > 0 ? [...detected] : undefined;
37
+ }
38
+ /**
39
+ * Builds a short sentence describing the detected workspace for a tool
40
+ * description, or an empty string when nothing was detected.
41
+ */
42
+ export function detectedWorkspaceNote(detected) {
43
+ if (detected.length === 0)
44
+ return '';
45
+ const labels = detected.map((t) => PROJECT_TYPE_LABELS[t] ?? t).join(' + ');
46
+ return ` Detected workspace: ${labels} — by default results favor this workspace's docs (pass workspace="all" to disable).`;
47
+ }
48
+ //# sourceMappingURL=storefront.js.map
@@ -0,0 +1,21 @@
1
+ import { type DocCategory } from '@salesforce/b2c-tooling-sdk/docs';
2
+ /**
3
+ * Helpers for the launch-time documentation "topics" allowlist (`--docs-topics`),
4
+ * shared across the docs search/list/read tools so the enabled-category boundary
5
+ * is described and enforced consistently.
6
+ */
7
+ /**
8
+ * The category values a docs tool's `category` parameter should accept, given
9
+ * the optional launch-time allowlist. When an allowlist is configured the enum
10
+ * is narrowed to it (so the schema itself reflects what is reachable); otherwise
11
+ * every category is accepted.
12
+ *
13
+ * Always returns a non-empty tuple so `z.enum(...)` is happy — an allowlist that
14
+ * somehow filtered to nothing falls back to the full set.
15
+ */
16
+ export declare function categoryEnumValues(enabledCategories?: readonly DocCategory[]): [DocCategory, ...DocCategory[]];
17
+ /**
18
+ * A sentence for a tool description noting the corpus is restricted to the
19
+ * configured topics, or an empty string when there is no restriction.
20
+ */
21
+ export declare function enabledCategoriesNote(enabledCategories?: readonly DocCategory[]): string;
@@ -0,0 +1,38 @@
1
+ /*
2
+ * Copyright (c) 2025, Salesforce, Inc.
3
+ * SPDX-License-Identifier: Apache-2
4
+ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5
+ */
6
+ import { DOC_CATEGORIES } from '@salesforce/b2c-tooling-sdk/docs';
7
+ /**
8
+ * Helpers for the launch-time documentation "topics" allowlist (`--docs-topics`),
9
+ * shared across the docs search/list/read tools so the enabled-category boundary
10
+ * is described and enforced consistently.
11
+ */
12
+ /**
13
+ * The category values a docs tool's `category` parameter should accept, given
14
+ * the optional launch-time allowlist. When an allowlist is configured the enum
15
+ * is narrowed to it (so the schema itself reflects what is reachable); otherwise
16
+ * every category is accepted.
17
+ *
18
+ * Always returns a non-empty tuple so `z.enum(...)` is happy — an allowlist that
19
+ * somehow filtered to nothing falls back to the full set.
20
+ */
21
+ export function categoryEnumValues(enabledCategories) {
22
+ const all = [...DOC_CATEGORIES];
23
+ if (!enabledCategories || enabledCategories.length === 0) {
24
+ return all;
25
+ }
26
+ const allowed = all.filter((c) => enabledCategories.includes(c));
27
+ return (allowed.length > 0 ? allowed : all);
28
+ }
29
+ /**
30
+ * A sentence for a tool description noting the corpus is restricted to the
31
+ * configured topics, or an empty string when there is no restriction.
32
+ */
33
+ export function enabledCategoriesNote(enabledCategories) {
34
+ if (!enabledCategories || enabledCategories.length === 0)
35
+ return '';
36
+ return ` Documentation is restricted at startup to: ${enabledCategories.join(', ')}.`;
37
+ }
38
+ //# sourceMappingURL=topics.js.map
@@ -42,4 +42,10 @@ export interface StartupFlags {
42
42
  configPath?: string;
43
43
  /** Project project directory for tools (auto-discovery, scaffolding, etc.) */
44
44
  projectDirectory?: string;
45
+ /**
46
+ * Comma-separated allowlist of documentation categories the docs tools may
47
+ * expose (from `--docs-topics` / `SFCC_DOCS_TOPICS`). Bounds the whole docs
48
+ * corpus at startup; unset means all categories.
49
+ */
50
+ docsTopics?: string;
45
51
  }
@@ -402,6 +402,14 @@
402
402
  "multiple": false,
403
403
  "type": "option"
404
404
  },
405
+ "docs-topics": {
406
+ "description": "Limit the documentation exposed by the docs tools to these categories (comma-separated allowlist). Options: script-api, job-step, commerce-api, pwa-kit-managed-runtime, sfnext, sfra, b2c-commerce, tooling. Bounds the whole docs corpus; per-call category/storefront narrow within it. Unknown names are ignored.",
407
+ "env": "SFCC_DOCS_TOPICS",
408
+ "name": "docs-topics",
409
+ "hasDynamicHelp": false,
410
+ "multiple": false,
411
+ "type": "option"
412
+ },
405
413
  "allow-non-ga-tools": {
406
414
  "description": "Enable non-GA (experimental) tools",
407
415
  "env": "SFCC_ALLOW_NON_GA_TOOLS",
@@ -420,5 +428,5 @@
420
428
  "enableJsonFlag": false
421
429
  }
422
430
  },
423
- "version": "1.4.1"
431
+ "version": "1.5.0"
424
432
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@salesforce/b2c-dx-mcp",
3
3
  "description": "MCP server for B2C Commerce developer experience tools",
4
- "version": "1.4.1",
4
+ "version": "1.5.0",
5
5
  "author": "Salesforce",
6
6
  "license": "Apache-2.0",
7
7
  "repository": "SalesforceCommerceCloud/b2c-developer-tooling",
@@ -80,7 +80,7 @@
80
80
  "yaml": "2.9.0",
81
81
  "postcss": "8.5.15",
82
82
  "zod": "3.25.76",
83
- "@salesforce/b2c-tooling-sdk": "1.17.0"
83
+ "@salesforce/b2c-tooling-sdk": "1.18.0"
84
84
  },
85
85
  "devDependencies": {
86
86
  "@eslint/compat": "^1",
@@ -123,10 +123,10 @@
123
123
  "inspect": "mcp-inspector node bin/run.js --toolsets all --allow-non-ga-tools",
124
124
  "inspect:dev": "mcp-inspector node --conditions development bin/dev.js --toolsets all --allow-non-ga-tools",
125
125
  "pretest": "tsc --noEmit -p test",
126
- "test": "c8 mocha --forbid-only --ignore 'test/e2e/**' \"test/**/*.test.ts\"",
127
- "test:ci": "c8 mocha --forbid-only --reporter json --reporter-option output=test-results.json --ignore 'test/e2e/**' \"test/**/*.test.ts\"",
128
- "test:ci:win": "c8 --check-coverage=false mocha --forbid-only --reporter json --reporter-option output=test-results.json --ignore 'test/e2e/**' \"test/**/*.test.ts\"",
129
- "test:agent": "mocha --forbid-only --reporter min --ignore 'test/e2e/**' \"test/**/*.test.ts\"",
126
+ "test": "c8 mocha --forbid-only --ignore \"test/e2e/**\" \"test/**/*.test.ts\"",
127
+ "test:ci": "c8 mocha --forbid-only --reporter json --reporter-option output=test-results.json --ignore \"test/e2e/**\" \"test/**/*.test.ts\"",
128
+ "test:ci:win": "c8 --check-coverage=false mocha --forbid-only --reporter json --reporter-option output=test-results.json --ignore \"test/e2e/**\" \"test/**/*.test.ts\"",
129
+ "test:agent": "mocha --forbid-only --reporter min --ignore \"test/e2e/**\" \"test/**/*.test.ts\"",
130
130
  "test:e2e": "mocha --forbid-only \"test/e2e/**/*.test.ts\"",
131
131
  "test:e2e:ci": "mocha --forbid-only --reporter json --reporter-option output=test-results-e2e.json \"test/e2e/**/*.test.ts\"",
132
132
  "coverage": "c8 report",