@salesforce/b2c-dx-mcp 1.6.0 → 1.8.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.
@@ -195,7 +195,8 @@ export default class McpServerCommand extends BaseCommand {
195
195
  }),
196
196
  'docs-topics': Flags.string({
197
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. ' +
198
+ 'Options: script-api, job-step, commerce-api, pwa-kit-managed-runtime, sfnext, sfra, b2c-commerce, tooling, ' +
199
+ 'help-admin, help-merchant. ' +
199
200
  'Bounds the whole docs corpus; per-call category/storefront narrow within it. Unknown names are ignored.',
200
201
  env: 'SFCC_DOCS_TOPICS',
201
202
  }),
@@ -308,8 +309,10 @@ export default class McpServerCommand extends BaseCommand {
308
309
  configPath: this.flags.config,
309
310
  // Project directory for auto-discovery. oclif handles flag with env fallback.
310
311
  projectDirectory: this.flags['project-directory'],
311
- // Docs topic allowlist (bounds the docs corpus at startup).
312
- docsTopics: this.flags['docs-topics'],
312
+ // Docs topic allowlist (bounds the docs corpus at startup). Flag first
313
+ // (--docs-topics / SFCC_DOCS_TOPICS), else config `docsCategories`
314
+ // (dw.json `docs-categories`, SFCC_DOCS_CATEGORIES, package.json).
315
+ docsTopics: this.flags['docs-topics'] ?? this.resolvedConfig?.values.docsCategories?.join(','),
313
316
  };
314
317
  // Add toolsets to telemetry attributes
315
318
  if (this.telemetry && startupFlags.toolsets) {
package/dist/registry.js CHANGED
@@ -3,6 +3,8 @@
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 os from 'node:os';
7
+ import path from 'node:path';
6
8
  import { getLogger } from '@salesforce/b2c-tooling-sdk/logging';
7
9
  import { detectWorkspaceType } from '@salesforce/b2c-tooling-sdk/discovery';
8
10
  import { DOC_CATEGORIES, resolveEnabledCategories } from '@salesforce/b2c-tooling-sdk/docs';
@@ -102,17 +104,49 @@ export function createToolRegistry(loadServices, serverContext, detectedWorkspac
102
104
  }
103
105
  return registry;
104
106
  }
107
+ /**
108
+ * Maximum directory depth workspace auto-discovery recurses when scanning the
109
+ * project directory. Bounding this is what keeps startup fast (and safe) when
110
+ * the server is launched from a broad root: a full `**` walk of a home
111
+ * directory can take many seconds and blocks the MCP handshake, since detection
112
+ * runs before the server connects.
113
+ *
114
+ * Depth is counted in path segments relative to the project directory. 5 covers
115
+ * the common layouts — a cartridge at `cartridges/<name>/.project` (depth 3) and
116
+ * a monorepo cartridge at `packages/<app>/cartridges/<name>/.project` (depth 5) —
117
+ * without descending into unrelated deep trees.
118
+ */
119
+ const DISCOVERY_MAX_DEPTH = 5;
120
+ /**
121
+ * Returns true when `dir` is a location that should never be recursively
122
+ * scanned for a workspace: the user's home directory or a filesystem root.
123
+ * These are the classic "MCP client spawned the server from ~" cases where an
124
+ * unbounded (or even bounded) scan is both pointless and expensive.
125
+ */
126
+ function isUnscannableRoot(dir) {
127
+ const resolved = path.resolve(dir);
128
+ // Filesystem root: parent equals self (handles `/` and Windows drive roots).
129
+ if (path.dirname(resolved) === resolved) {
130
+ return true;
131
+ }
132
+ const home = os.homedir();
133
+ return Boolean(home) && path.resolve(home) === resolved;
134
+ }
105
135
  /**
106
136
  * Performs workspace auto-discovery and returns appropriate toolsets.
107
137
  * Always includes the BASE_TOOLSETS even if no project types are detected.
108
138
  *
139
+ * The scan is depth-bounded ({@link DISCOVERY_MAX_DEPTH}) and skipped entirely
140
+ * for home/root directories so it can never fan out across a whole home tree.
141
+ *
109
142
  * @param flags - Startup flags containing projectDirectory
110
- * @param reason - Reason for triggering auto-discovery (for logging)
111
- * @returns Array of toolsets to enable
143
+ * @returns Array of detected project types (empty if skipped or none found)
112
144
  */
113
145
  async function detectProjectTypes(flags) {
114
146
  const logger = getLogger();
115
- // Project directory from --project-directory flag or SFCC_PROJECT_DIRECTORY env var
147
+ // Project directory from --project-directory flag or SFCC_PROJECT_DIRECTORY env
148
+ // var, defaulting to cwd. We always attempt detection from cwd rather than
149
+ // skipping it — the depth bound and home/root guard below make that safe.
116
150
  const projectDirectory = flags.projectDirectory ?? process.cwd();
117
151
  // Warn if project directory wasn't explicitly configured
118
152
  if (!flags.projectDirectory) {
@@ -120,7 +154,14 @@ async function detectProjectTypes(flags) {
120
154
  'MCP clients like Cursor and Claude Code often spawn servers from ~ instead of the project directory. ' +
121
155
  'Set --project-directory or SFCC_PROJECT_DIRECTORY for reliable auto-discovery.');
122
156
  }
123
- const detectionResult = await detectWorkspaceType(projectDirectory);
157
+ // Never recursively scan a home directory or filesystem root — the scan would
158
+ // be expensive and would not identify a meaningful workspace anyway.
159
+ if (isUnscannableRoot(projectDirectory)) {
160
+ logger.warn({ projectDirectory }, 'Project directory is a home or root directory; skipping workspace auto-discovery. ' +
161
+ 'Set --project-directory or SFCC_PROJECT_DIRECTORY to the project path to enable it.');
162
+ return [];
163
+ }
164
+ const detectionResult = await detectWorkspaceType(projectDirectory, { maxDepth: DISCOVERY_MAX_DEPTH });
124
165
  logger.info({ projectTypes: detectionResult.projectTypes, matchedPatterns: detectionResult.matchedPatterns }, `Workspace detection: project types: ${detectionResult.projectTypes.join(', ') || 'none'}`);
125
166
  return detectionResult.projectTypes;
126
167
  }
@@ -156,22 +197,21 @@ export async function registerToolsets(flags, server, loadServices, serverContex
156
197
  const individualTools = flags.tools ?? [];
157
198
  const allowNonGaTools = flags.allowNonGaTools ?? false;
158
199
  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
200
  // Resolve the launch-time docs topic allowlist (bounds the whole docs corpus).
165
201
  const enabledDocCategories = resolveEnabledCategories(flags.docsTopics, (invalid) => logger.warn({ invalidTopics: invalid, validTopics: DOC_CATEGORIES }, `Ignoring unknown documentation topic(s) in --docs-topics: "${invalid.join('", "')}"`));
166
202
  if (enabledDocCategories) {
167
203
  logger.info({ docsTopics: enabledDocCategories }, `Documentation restricted to topics: ${enabledDocCategories.join(', ')}`);
168
204
  }
169
- // Create the tool registry (all available tools)
170
- const toolRegistry = createToolRegistry(loadServices, serverContext, detectedWorkspaces, enabledDocCategories);
171
- // Build flat list of all tools for lookup
172
- const allTools = Object.values(toolRegistry).flat();
173
- const allToolsByName = new Map(allTools.map((tool) => [tool.name, tool]));
174
- const existingToolNames = new Set(allToolsByName.keys());
205
+ // Build the tool registry to validate flag input. The set of available tools
206
+ // (names, toolsets) does NOT depend on the detected workspace — only the docs
207
+ // tool descriptions do so we can resolve flag validity before deciding
208
+ // whether workspace detection is even needed. If auto-discovery later runs,
209
+ // we rebuild the registry with the detected workspaces so the docs tools pick
210
+ // up the workspace hint.
211
+ let toolRegistry = createToolRegistry(loadServices, serverContext, [], enabledDocCategories);
212
+ const existingToolNames = new Set(Object.values(toolRegistry)
213
+ .flat()
214
+ .map((tool) => tool.name));
175
215
  // Determine valid individual tools
176
216
  const invalidTools = individualTools.filter((name) => !existingToolNames.has(name));
177
217
  const validIndividualTools = individualTools.filter((name) => existingToolNames.has(name));
@@ -190,16 +230,24 @@ export async function registerToolsets(flags, server, loadServices, serverContex
190
230
  const validToolsets = toolsets.filter((t) => TOOLSETS.includes(t));
191
231
  const allNonDeprecatedToolsets = TOOLSETS.filter((t) => !DEPRECATED_TOOLSETS.includes(t));
192
232
  const toolsetsToEnable = new Set(toolsets.includes(ALL_TOOLSETS) ? allNonDeprecatedToolsets : validToolsets);
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.
233
+ // Auto-discovery: only when no valid toolsets AND no valid individual tools
234
+ // were provided. This handles both (1) no flags provided, and (2) all
235
+ // provided flags are invalid. Skip workspace detection entirely otherwise —
236
+ // when the user has explicitly chosen toolsets/tools, the filesystem scan
237
+ // cannot change which tools are registered, so paying for it (and risking a
238
+ // costly recursive walk) would be pointless.
197
239
  if (toolsetsToEnable.size === 0 && validIndividualTools.length === 0) {
240
+ const detectedWorkspaces = await detectProjectTypes(flags);
198
241
  const discoveredToolsets = getToolsetsForProjectTypes(detectedWorkspaces);
199
242
  logger.info({ projectTypes: detectedWorkspaces, enabledToolsets: discoveredToolsets }, `Auto-discovery: enabling toolsets ${discoveredToolsets.join(', ')}`);
200
243
  for (const toolset of discoveredToolsets) {
201
244
  toolsetsToEnable.add(toolset);
202
245
  }
246
+ // Rebuild so the docs tools reflect the detected workspace in their
247
+ // descriptions and default workspace resolution.
248
+ if (detectedWorkspaces.length > 0) {
249
+ toolRegistry = createToolRegistry(loadServices, serverContext, detectedWorkspaces, enabledDocCategories);
250
+ }
203
251
  }
204
252
  // Build the set of tools to register:
205
253
  // 1. Start with tools from enabled toolsets
@@ -215,7 +263,11 @@ export async function registerToolsets(flags, server, loadServices, serverContex
215
263
  }
216
264
  }
217
265
  }
218
- // Step 2: Add individual tools from --tools (can be from any toolset)
266
+ // Step 2: Add individual tools from --tools (can be from any toolset).
267
+ // Look up against the final registry (which may have been rebuilt above).
268
+ const allToolsByName = new Map(Object.values(toolRegistry)
269
+ .flat()
270
+ .map((tool) => [tool.name, tool]));
219
271
  for (const toolName of validIndividualTools) {
220
272
  const tool = allToolsByName.get(toolName);
221
273
  if (tool && !registeredToolNames.has(toolName)) {
@@ -1,4 +1,5 @@
1
1
  import { type DocCategory } from '@salesforce/b2c-tooling-sdk/docs';
2
+ import type { ProjectType } from '@salesforce/b2c-tooling-sdk/discovery';
2
3
  import type { McpTool } from '../../utils/index.js';
3
4
  import type { Services } from '../../services.js';
4
- export declare function createDocsReadTool(loadServices: () => Promise<Services> | Services, enabledCategories?: readonly DocCategory[]): McpTool;
5
+ export declare function createDocsReadTool(loadServices: () => Promise<Services> | Services, enabledCategories?: readonly DocCategory[], detectedWorkspaces?: readonly ProjectType[]): McpTool;
@@ -9,14 +9,15 @@ import { createToolAdapter, errorResult, jsonResult } from '../adapter.js';
9
9
  import { enabledCategoriesNote } from './topics.js';
10
10
  /** Default maximum characters of content returned per read call, to bound the inline payload. */
11
11
  const DEFAULT_MAX_LENGTH = 12_000;
12
- export function createDocsReadTool(loadServices, enabledCategories) {
12
+ export function createDocsReadTool(loadServices, enabledCategories, detectedWorkspaces = []) {
13
13
  return createToolAdapter({
14
14
  name: 'docs_read',
15
- description: 'Read B2C Commerce documentation (markdown) for a class, module, job step, or guide. ' +
15
+ description: 'Read B2C Commerce documentation (markdown) for a class, module, job step, guide, or Help article. ' +
16
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 ' +
17
+ 'query — best match wins (a fuzzy query favors the detected workspace, matching docs_search). ' +
18
+ 'Job-step content is bundled; Script API, Developer Center guide, and Salesforce Help content is ' +
19
+ 'fetched from its published URL on demand and cached locally (with a summary/headings fallback if ' +
20
+ 'the network is unavailable). If you do not know the id, call docs_search first. Long docs are ' +
20
21
  'truncated to maxLength chars; page with offset when truncated=true. The returned entry ' +
21
22
  'includes the canonical url for citation.' +
22
23
  enabledCategoriesNote(enabledCategories),
@@ -40,7 +41,12 @@ export function createDocsReadTool(loadServices, enabledCategories) {
40
41
  .describe(`Maximum characters of content to return. Defaults to ${DEFAULT_MAX_LENGTH}.`),
41
42
  },
42
43
  async execute(args) {
43
- const found = await readDocByQuery(args.query, { enabledCategories });
44
+ // Favor the detected workspace when resolving a fuzzy query so a read
45
+ // picks the same top hit docs_search ranks first. Exact id matches
46
+ // short-circuit inside readDocByQuery before searching, so the boost
47
+ // only affects fuzzy resolution.
48
+ const workspace = detectedWorkspaces.length > 0 ? [...detectedWorkspaces] : undefined;
49
+ const found = await readDocByQuery(args.query, { enabledCategories, workspace });
44
50
  if (!found)
45
51
  return null;
46
52
  const totalLength = found.content.length;
@@ -16,7 +16,7 @@ export function createDocsTools(loadServices, context = {}) {
16
16
  const { detectedWorkspaces = [], enabledCategories } = context;
17
17
  return [
18
18
  createDocsSearchTool(loadServices, detectedWorkspaces, enabledCategories),
19
- createDocsReadTool(loadServices, enabledCategories),
19
+ createDocsReadTool(loadServices, enabledCategories, detectedWorkspaces),
20
20
  createDocsListTool(loadServices, detectedWorkspaces, enabledCategories),
21
21
  createDocsSchemaSearchTool(loadServices),
22
22
  createDocsSchemaReadTool(loadServices),
@@ -403,7 +403,7 @@
403
403
  "type": "option"
404
404
  },
405
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.",
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, help-admin, help-merchant. Bounds the whole docs corpus; per-call category/storefront narrow within it. Unknown names are ignored.",
407
407
  "env": "SFCC_DOCS_TOPICS",
408
408
  "name": "docs-topics",
409
409
  "hasDynamicHelp": false,
@@ -428,5 +428,5 @@
428
428
  "enableJsonFlag": false
429
429
  }
430
430
  },
431
- "version": "1.6.0"
431
+ "version": "1.8.0"
432
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.6.0",
4
+ "version": "1.8.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.19.0"
83
+ "@salesforce/b2c-tooling-sdk": "1.20.0"
84
84
  },
85
85
  "devDependencies": {
86
86
  "@eslint/compat": "^1",