From 3a017bb92b92a2181a32a867c6540513e7121061 Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 18 Feb 2026 23:35:18 -0500 Subject: [PATCH 01/11] initial changes for custom api --- packages/b2c-dx-mcp/README.md | 12 +- packages/b2c-dx-mcp/src/commands/mcp.ts | 3 +- packages/b2c-dx-mcp/src/tools/scapi/index.ts | 9 +- .../tools/scapi/scapi-customapi-scaffold.ts | 241 ++++++++++++++++++ packages/b2c-dx-mcp/test/registry.test.ts | 3 + .../src/operations/code/cartridges.ts | 14 +- .../src/scaffold/parameter-resolver.ts | 23 +- 7 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index a9262c65..8da876ca 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -123,7 +123,7 @@ The `storefront_next_development_guidelines` tool provides critical architecture ##### SCAPI Discovery -Use **scapi_schemas_list** for both standard SCAPI (Shop, Admin, Shopper APIs) and custom APIs. Use **scapi_custom_apis_status** for endpoint-level registration status (active/not_registered). +Use **scapi_schemas_list** for both standard SCAPI (Shop, Admin, Shopper APIs) and custom APIs. Use **scapi_custom_apis_status** for endpoint-level registration status (active/not_registered). Use **scapi_customapi_scaffold** to generate a new custom API in an existing cartridge. **SCAPI Schemas (tool: `scapi_schemas_list`):** @@ -140,6 +140,12 @@ Discover schema metadata and fetch OpenAPI specs for both standard and custom SC - ✅ "Use the MCP tool to list custom API definitions." → list with apiFamily: custom. - ✅ "Use the MCP tool to show me the loyalty-points custom API schema." → apiFamily: custom, apiName: loyalty-points, apiVersion: v1, includeSchemas: true. +**Custom API Scaffold (tool: `scapi_customapi_scaffold`):** + +Generate a new custom SCAPI endpoint in an existing cartridge (schema.yaml, api.json, script.js). Requires `apiName` (kebab-case) and `cartridgeName` (must exist in project). Optional: apiType (shopper|admin), apiDescription, includeExampleEndpoints, projectRoot, outputDir, dryRun, force. Set `--working-directory` (or SFCC_WORKING_DIRECTORY) so the MCP server discovers cartridges in your project. + +- ✅ "Use the MCP tool to scaffold a new custom API named my-products in cartridge app_custom." + **Custom API Endpoint Status (tool: `scapi_custom_apis_status`):** Get registration status of custom API endpoints deployed on the instance (remote only). Returns individual HTTP endpoints (e.g., GET /hello, POST /items/{id}) with registration status (active/not_registered), one row per endpoint per site. Requires OAuth with `sfcc.custom-apis` scope. @@ -289,6 +295,7 @@ PWA Kit v3 development tools for building headless storefronts. | `pwakit_install_agent_rules` | Install AI agent rules for PWA Kit development | | `scapi_schemas_list` | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | | `scapi_custom_apis_status` | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | +| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS schema, api.json, script.js) in an existing cartridge. | | `mrt_bundle_push` | Build, push bundle (optionally deploy) | #### SCAPI @@ -299,7 +306,7 @@ Salesforce Commerce API discovery and exploration. |------|-------------| | `scapi_schemas_list` | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | | `scapi_custom_apis_status` | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | -| `scapi_customapi_scaffold` | Scaffold a new custom SCAPI API (not yet implemented) | +| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS schema, api.json, script.js) in an existing cartridge. | #### STOREFRONTNEXT Storefront Next development tools for building modern storefronts. @@ -316,6 +323,7 @@ Storefront Next development tools for building modern storefronts. | `storefront_next_generate_page_designer_metadata` | Generate Page Designer metadata for Storefront Next components | | `scapi_schemas_list` | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | | `scapi_custom_apis_status` | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | +| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS schema, api.json, script.js) in an existing cartridge. | | `mrt_bundle_push` | Build, push bundle (optionally deploy) | > **Note:** Some tools appear in multiple toolsets (e.g., `mrt_bundle_push`, `scapi_schemas_list`, `scapi_custom_apis_status`). When using multiple toolsets, tools are automatically deduplicated. diff --git a/packages/b2c-dx-mcp/src/commands/mcp.ts b/packages/b2c-dx-mcp/src/commands/mcp.ts index 00943a21..66e4e36a 100644 --- a/packages/b2c-dx-mcp/src/commands/mcp.ts +++ b/packages/b2c-dx-mcp/src/commands/mcp.ts @@ -268,10 +268,11 @@ export default class McpServerCommand extends BaseCommand), ...mrt.config, + workingDirectory: this.flags['working-directory'], }; return loadConfig(flagConfig, options); diff --git a/packages/b2c-dx-mcp/src/tools/scapi/index.ts b/packages/b2c-dx-mcp/src/tools/scapi/index.ts index db5b253f..8e584ca8 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/index.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/index.ts @@ -8,7 +8,7 @@ * SCAPI toolset for B2C Commerce. * * This toolset provides MCP tools for Salesforce Commerce API (SCAPI) discovery and exploration. - * Includes both standard SCAPI schemas and custom API status tools. + * Includes standard SCAPI schemas, custom API status, and custom API scaffold tools. * * @module tools/scapi */ @@ -17,6 +17,7 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import {createScapiSchemasListTool} from './scapi-schemas-list.js'; import {createScapiCustomApisStatusTool} from './scapi-custom-apis-status.js'; +import {createScaffoldCustomApiTool} from './scapi-customapi-scaffold.js'; /** * Creates all tools for the SCAPI toolset. @@ -25,5 +26,9 @@ import {createScapiCustomApisStatusTool} from './scapi-custom-apis-status.js'; * @returns Array of MCP tools */ export function createScapiTools(loadServices: () => Services): McpTool[] { - return [createScapiSchemasListTool(loadServices), createScapiCustomApisStatusTool(loadServices)]; + return [ + createScapiSchemasListTool(loadServices), + createScapiCustomApisStatusTool(loadServices), + createScaffoldCustomApiTool(loadServices), + ]; } diff --git a/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts b/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts new file mode 100644 index 00000000..25479cfb --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * SCAPI Custom API Scaffold tool. + * + * Generates a new custom SCAPI endpoint using the SDK's custom-api scaffold + * (schema.yaml, api.json, script.js). Mirrors CLI: b2c scaffold generate custom-api. + * + * @module tools/scapi/scapi-customapi-scaffold + */ + +import path from 'node:path'; +import {z} from 'zod'; +import {createToolAdapter, jsonResult, errorResult} from '../adapter.js'; +import type {Services} from '../../services.js'; +import type {McpTool} from '../../utils/index.js'; +import { + createScaffoldRegistry, + generateFromScaffold, + resolveScaffoldParameters, + resolveOutputDirectory, +} from '@salesforce/b2c-tooling-sdk/scaffold'; +import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; + +const CUSTOM_API_SCAFFOLD_ID = 'custom-api'; + +/** + * Input schema for scapi_customapi_scaffold tool. + * Parameters match the custom-api scaffold: apiName, apiType, cartridgeName, etc. + */ +interface ScaffoldCustomApiInput { + /** API name (kebab-case, e.g. my-products). Required. */ + apiName: string; + /** Cartridge name that will contain the API. Optional; defaults to first cartridge found in project. */ + cartridgeName?: string; + /** API type: admin (no siteId) or shopper (siteId, customer-facing). Default: shopper */ + apiType?: 'admin' | 'shopper'; + /** Short description of the API. Default: "A custom B2C Commerce API" */ + apiDescription?: string; + /** Include example GET/POST endpoints in schema and script. Default: true */ + includeExampleEndpoints?: boolean; + /** Project root for cartridge discovery and output. Default: MCP working directory */ + projectRoot?: string; + /** Output directory override. Default: scaffold default or project root */ + outputDir?: string; + /** If true, preview only (no files written). Default: false */ + dryRun?: boolean; + /** If true, overwrite existing files. Default: false */ + force?: boolean; +} + +/** + * Output schema for scapi_customapi_scaffold tool. + */ +interface ScaffoldCustomApiOutput { + scaffold: string; + outputDir: string; + dryRun: boolean; + files: Array<{ + path: string; + action: string; + skipReason?: string; + }>; + postInstructions?: string; + error?: string; +} + +/** + * Creates the scapi_customapi_scaffold tool. + * + * Uses @salesforce/b2c-tooling-sdk scaffold: registry, resolveScaffoldParameters, + * resolveOutputDirectory, generateFromScaffold. cartridgeName must be a cartridge + * discovered under projectRoot (e.g. from .project or cartridges/). CLI: b2c scaffold generate custom-api. + */ +export function createScaffoldCustomApiTool(loadServices: () => Services): McpTool { + return createToolAdapter( + { + name: 'scapi_customapi_scaffold', + description: `Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. \ + Uses the same scaffold as CLI: b2c scaffold generate custom-api. \ + Required: apiName (kebab-case). Optional: cartridgeName (defaults to first cartridge found in project), apiType (shopper|admin), apiDescription, includeExampleEndpoints, projectRoot, outputDir, dryRun, force. \ + cartridgeName must be one of the cartridges discovered under projectRoot (--working-directory or SFCC_WORKING_DIRECTORY). \ + Set projectRoot to override the working directory. \ + For faster runs, set --working-directory to your cartridge project root (same as where you would run the CLI from). \ + CLI: b2c scaffold generate custom-api.`, + toolsets: ['PWAV3', 'SCAPI', 'STOREFRONTNEXT'], + isGA: false, + requiresInstance: false, + inputSchema: { + apiName: z + .string() + .min(1) + .describe( + 'API name in kebab-case (e.g. my-products). Must start with lowercase letter, only letters, numbers, hyphens.', + ), + cartridgeName: z + .string() + .min(1) + .nullish() + .describe( + 'Cartridge name that will contain the API. Optional; omit to use the first cartridge found under working directory (--working-directory or SFCC_WORKING_DIRECTORY).', + ), + apiType: z + .enum(['admin', 'shopper']) + .optional() + .describe('Admin (no siteId) or shopper (siteId, customer-facing). Default: shopper'), + apiDescription: z.string().optional().describe('Short description of the API.'), + includeExampleEndpoints: z.boolean().optional().describe('Include example GET/POST endpoints. Default: true'), + projectRoot: z + .string() + .nullish() + .describe( + 'Project root for cartridge discovery. Default: working directory. Set to override the working directory.', + ), + outputDir: z.string().optional().describe('Output directory override. Default: project root'), + dryRun: z.boolean().optional().describe('If true, preview only (no files written). Default: false'), + force: z.boolean().optional().describe('If true, overwrite existing files. Default: false'), + }, + async execute(args, {services}) { + const projectRoot = path.resolve(args.projectRoot ?? services.getWorkingDirectory()); + + const registry = createScaffoldRegistry(); + const scaffold = await registry.getScaffold(CUSTOM_API_SCAFFOLD_ID, { + projectRoot, + }); + + if (!scaffold) { + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir: projectRoot, + dryRun: args.dryRun ?? false, + files: [], + error: `Scaffold not found: ${CUSTOM_API_SCAFFOLD_ID}. Ensure @salesforce/b2c-tooling-sdk is installed.`, + }; + } + + let cartridgeName = args.cartridgeName; + // If cartridgeName is not provided, use the first cartridge found in project directory. + if (!cartridgeName) { + const cartridges = findCartridges(projectRoot); + if (cartridges.length === 0) { + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir: projectRoot, + dryRun: args.dryRun ?? false, + files: [], + error: + 'No cartridges found in project. Add a cartridge (directory with .project file) or pass cartridgeName explicitly.', + }; + } + cartridgeName = cartridges[0].name; + } + + const providedVariables: Record = { + apiName: args.apiName, + cartridgeName, + }; + if (args.apiType !== undefined) providedVariables.apiType = args.apiType; + if (args.apiDescription !== undefined) providedVariables.apiDescription = args.apiDescription; + if (args.includeExampleEndpoints !== undefined) { + providedVariables.includeExampleEndpoints = args.includeExampleEndpoints; + } + + const resolved = await resolveScaffoldParameters(scaffold, { + providedVariables, + projectRoot, + useDefaults: true, + }); + + if (resolved.errors.length > 0) { + const message = resolved.errors.map((e) => `${e.parameter}: ${e.message}`).join('; '); + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir: projectRoot, + dryRun: args.dryRun ?? false, + files: [], + error: `Parameter validation failed: ${message}`, + }; + } + + const missingRequired = resolved.missingParameters.filter((p) => p.required); + if (missingRequired.length > 0) { + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir: projectRoot, + dryRun: args.dryRun ?? false, + files: [], + error: `Missing required parameter: ${missingRequired[0].name}. For cartridgeName, ensure the cartridge exists in the project (under projectRoot).`, + }; + } + + const outputDir = resolveOutputDirectory({ + outputDir: args.outputDir, + scaffold, + projectRoot, + }); + + try { + const result = await generateFromScaffold(scaffold, { + outputDir, + variables: resolved.variables as Record, + dryRun: args.dryRun ?? false, + force: args.force ?? false, + }); + + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir, + dryRun: result.dryRun, + files: result.files.map((f) => ({ + path: f.path, + action: f.action, + skipReason: f.skipReason, + })), + postInstructions: result.postInstructions, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir, + dryRun: args.dryRun ?? false, + files: [], + error: `Scaffold generation failed: ${message}`, + }; + } + }, + formatOutput(output) { + if (output.error) { + return errorResult(output.error); + } + return jsonResult(output); + }, + }, + loadServices, + ); +} diff --git a/packages/b2c-dx-mcp/test/registry.test.ts b/packages/b2c-dx-mcp/test/registry.test.ts index a8c41fb1..c7fcc6e7 100644 --- a/packages/b2c-dx-mcp/test/registry.test.ts +++ b/packages/b2c-dx-mcp/test/registry.test.ts @@ -91,6 +91,7 @@ describe('registry', () => { const toolNames = registry.SCAPI.map((t) => t.name); expect(toolNames).to.include('scapi_schemas_list'); expect(toolNames).to.include('scapi_custom_apis_status'); + expect(toolNames).to.include('scapi_customapi_scaffold'); }); it('should create STOREFRONTNEXT tools', () => { @@ -310,6 +311,7 @@ describe('registry', () => { // Auto-discovery always includes BASE_TOOLSET (SCAPI), even if no project type detected expect(server.registeredTools).to.include('scapi_schemas_list'); expect(server.registeredTools).to.include('scapi_custom_apis_status'); + expect(server.registeredTools).to.include('scapi_customapi_scaffold'); }); it('should trigger auto-discovery when all individual tools are invalid', async () => { @@ -326,6 +328,7 @@ describe('registry', () => { // Auto-discovery always includes BASE_TOOLSET (SCAPI), even if no project type detected expect(server.registeredTools).to.include('scapi_schemas_list'); expect(server.registeredTools).to.include('scapi_custom_apis_status'); + expect(server.registeredTools).to.include('scapi_customapi_scaffold'); }); it('should skip non-GA tools when allowNonGaTools is false', async () => { diff --git a/packages/b2c-tooling-sdk/src/operations/code/cartridges.ts b/packages/b2c-tooling-sdk/src/operations/code/cartridges.ts index d3dd7161..fe4f6b65 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/cartridges.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/cartridges.ts @@ -56,10 +56,20 @@ export interface FindCartridgesOptions { export function findCartridges(directory?: string, options: FindCartridgesOptions = {}): CartridgeMapping[] { const searchDir = directory ? path.resolve(directory) : process.cwd(); - // Find all .project files (Eclipse project markers) + // Find all .project files (Eclipse project markers). + // Ignore common non-cartridge dirs to keep discovery fast when projectRoot is broad (e.g. MCP working directory). const projectFiles = globSync('**/.project', { cwd: searchDir, - ignore: ['**/node_modules/**'], + ignore: [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/coverage/**', + '**/.cache/**', + '**/tmp/**', + '**/temp/**', + ], }); let cartridges = projectFiles.map((f) => { diff --git a/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts b/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts index 49da97f9..1d617d42 100644 --- a/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts +++ b/packages/b2c-tooling-sdk/src/scaffold/parameter-resolver.ts @@ -4,6 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +import path from 'node:path'; import type {B2CInstance} from '../instance/index.js'; import type {Scaffold, ScaffoldParameter, ScaffoldChoice} from './types.js'; import {evaluateCondition} from './validators.js'; @@ -63,6 +64,22 @@ export interface ResolvedParameterSchema { warning?: string; } +/** + * Path to use for scaffold destination so files are generated under outputDir (e.g. working directory). + * Returns a path relative to projectRoot when the cartridge is under projectRoot, so the executor + * joins with outputDir instead of ignoring it. Otherwise returns the absolute path. + */ +function cartridgePathForDestination(absolutePath: string, projectRoot: string): string { + const normalizedRoot = path.resolve(projectRoot); + const normalizedPath = path.resolve(absolutePath); + const relative = path.relative(normalizedRoot, normalizedPath); + // Use relative path only when cartridge is under projectRoot (no leading '..') + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return relative; + } + return absolutePath; +} + /** * Resolve scaffold parameters by: * 1. Validating provided variables against sources @@ -109,7 +126,7 @@ export async function resolveScaffoldParameters( continue; } - // Set companion path variable for cartridges source + // Set companion path variable for cartridges source (relative to projectRoot when under it so outputDir is used) if (param.source === 'cartridges') { if (!cartridgePathMap) { const result = resolveLocalSource('cartridges', projectRoot); @@ -117,7 +134,7 @@ export async function resolveScaffoldParameters( } const cartridgePath = cartridgePathMap?.get(providedValue); if (cartridgePath) { - variables[`${param.name}Path`] = cartridgePath; + variables[`${param.name}Path`] = cartridgePathForDestination(cartridgePath, projectRoot); } } continue; @@ -140,7 +157,7 @@ export async function resolveScaffoldParameters( } const cartridgePath = cartridgePathMap?.get(param.default); if (cartridgePath) { - variables[`${param.name}Path`] = cartridgePath; + variables[`${param.name}Path`] = cartridgePathForDestination(cartridgePath, projectRoot); } } continue; From 90afd003533ff2e04800c1eac34f0a2b94778bd0 Mon Sep 17 00:00:00 2001 From: wei-liu Date: Tue, 24 Feb 2026 18:15:26 -0500 Subject: [PATCH 02/11] remove two parameters --- .../tools/scapi/scapi-customapi-scaffold.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts b/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts index 25479cfb..9660591c 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts @@ -47,10 +47,6 @@ interface ScaffoldCustomApiInput { projectRoot?: string; /** Output directory override. Default: scaffold default or project root */ outputDir?: string; - /** If true, preview only (no files written). Default: false */ - dryRun?: boolean; - /** If true, overwrite existing files. Default: false */ - force?: boolean; } /** @@ -82,7 +78,7 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo name: 'scapi_customapi_scaffold', description: `Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. \ Uses the same scaffold as CLI: b2c scaffold generate custom-api. \ - Required: apiName (kebab-case). Optional: cartridgeName (defaults to first cartridge found in project), apiType (shopper|admin), apiDescription, includeExampleEndpoints, projectRoot, outputDir, dryRun, force. \ + Required: apiName (kebab-case). Optional: cartridgeName (defaults to first cartridge found in project), apiType (shopper|admin), apiDescription, includeExampleEndpoints, projectRoot, outputDir. \ cartridgeName must be one of the cartridges discovered under projectRoot (--working-directory or SFCC_WORKING_DIRECTORY). \ Set projectRoot to override the working directory. \ For faster runs, set --working-directory to your cartridge project root (same as where you would run the CLI from). \ @@ -117,8 +113,6 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo 'Project root for cartridge discovery. Default: working directory. Set to override the working directory.', ), outputDir: z.string().optional().describe('Output directory override. Default: project root'), - dryRun: z.boolean().optional().describe('If true, preview only (no files written). Default: false'), - force: z.boolean().optional().describe('If true, overwrite existing files. Default: false'), }, async execute(args, {services}) { const projectRoot = path.resolve(args.projectRoot ?? services.getWorkingDirectory()); @@ -132,7 +126,7 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo return { scaffold: CUSTOM_API_SCAFFOLD_ID, outputDir: projectRoot, - dryRun: args.dryRun ?? false, + dryRun: false, files: [], error: `Scaffold not found: ${CUSTOM_API_SCAFFOLD_ID}. Ensure @salesforce/b2c-tooling-sdk is installed.`, }; @@ -146,7 +140,7 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo return { scaffold: CUSTOM_API_SCAFFOLD_ID, outputDir: projectRoot, - dryRun: args.dryRun ?? false, + dryRun: false, files: [], error: 'No cartridges found in project. Add a cartridge (directory with .project file) or pass cartridgeName explicitly.', @@ -176,7 +170,7 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo return { scaffold: CUSTOM_API_SCAFFOLD_ID, outputDir: projectRoot, - dryRun: args.dryRun ?? false, + dryRun: false, files: [], error: `Parameter validation failed: ${message}`, }; @@ -187,7 +181,7 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo return { scaffold: CUSTOM_API_SCAFFOLD_ID, outputDir: projectRoot, - dryRun: args.dryRun ?? false, + dryRun: false, files: [], error: `Missing required parameter: ${missingRequired[0].name}. For cartridgeName, ensure the cartridge exists in the project (under projectRoot).`, }; @@ -203,8 +197,8 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo const result = await generateFromScaffold(scaffold, { outputDir, variables: resolved.variables as Record, - dryRun: args.dryRun ?? false, - force: args.force ?? false, + dryRun: false, + force: false, }); return { @@ -223,7 +217,7 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo return { scaffold: CUSTOM_API_SCAFFOLD_ID, outputDir, - dryRun: args.dryRun ?? false, + dryRun: false, files: [], error: `Scaffold generation failed: ${message}`, }; From 3dff85fdcced979e03644aa71a98555857e31502 Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 15:43:19 -0500 Subject: [PATCH 03/11] address review comments --- packages/b2c-dx-mcp/README.md | 12 +- packages/b2c-dx-mcp/src/commands/mcp.ts | 1 - .../tools/scapi/scapi-customapi-scaffold.ts | 17 +- packages/b2c-dx-mcp/test/registry.test.ts | 6 +- .../scapi/scapi-customapi-scaffold.test.ts | 235 ++++++++++++++++++ 5 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index 8da876ca..c4becbc4 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -142,9 +142,11 @@ Discover schema metadata and fetch OpenAPI specs for both standard and custom SC **Custom API Scaffold (tool: `scapi_customapi_scaffold`):** -Generate a new custom SCAPI endpoint in an existing cartridge (schema.yaml, api.json, script.js). Requires `apiName` (kebab-case) and `cartridgeName` (must exist in project). Optional: apiType (shopper|admin), apiDescription, includeExampleEndpoints, projectRoot, outputDir, dryRun, force. Set `--working-directory` (or SFCC_WORKING_DIRECTORY) so the MCP server discovers cartridges in your project. +Generate a new custom SCAPI endpoint in an existing cartridge (OAS 3.0 schema.yaml, api.json, script.js with example GET endpoints). Requires **apiName** (kebab-case). Optional: **cartridgeName** (omit to use the first cartridge found under the working directory), **apiType** (shopper | admin; default shopper), **apiDescription**, **projectRoot**, **outputDir**. Set `--working-directory` (or SFCC_WORKING_DIRECTORY) so the server discovers cartridges in your project. Files are always generated (no dry run) and existing files are never overwritten. -- ✅ "Use the MCP tool to scaffold a new custom API named my-products in cartridge app_custom." +- ✅ "Use the MCP tool to scaffold a new custom API named my-products." +- ✅ "Use the MCP tool to create a custom admin API called customer-trips." +- ✅ "Use the MCP tool to scaffold a new shopper custom API gift-registry-list in cartridge app_custom." **Custom API Endpoint Status (tool: `scapi_custom_apis_status`):** @@ -295,7 +297,7 @@ PWA Kit v3 development tools for building headless storefronts. | `pwakit_install_agent_rules` | Install AI agent rules for PWA Kit development | | `scapi_schemas_list` | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | | `scapi_custom_apis_status` | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | -| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS schema, api.json, script.js) in an existing cartridge. | +| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. Required: apiName. Optional: cartridgeName (defaults to first cartridge), apiType, apiDescription, projectRoot, outputDir. | | `mrt_bundle_push` | Build, push bundle (optionally deploy) | #### SCAPI @@ -306,7 +308,7 @@ Salesforce Commerce API discovery and exploration. |------|-------------| | `scapi_schemas_list` | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | | `scapi_custom_apis_status` | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | -| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS schema, api.json, script.js) in an existing cartridge. | +| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. Required: apiName. Optional: cartridgeName (defaults to first cartridge), apiType, apiDescription, projectRoot, outputDir. | #### STOREFRONTNEXT Storefront Next development tools for building modern storefronts. @@ -323,7 +325,7 @@ Storefront Next development tools for building modern storefronts. | `storefront_next_generate_page_designer_metadata` | Generate Page Designer metadata for Storefront Next components | | `scapi_schemas_list` | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | | `scapi_custom_apis_status` | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | -| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS schema, api.json, script.js) in an existing cartridge. | +| `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. Required: apiName. Optional: cartridgeName (defaults to first cartridge), apiType, apiDescription, projectRoot, outputDir. | | `mrt_bundle_push` | Build, push bundle (optionally deploy) | > **Note:** Some tools appear in multiple toolsets (e.g., `mrt_bundle_push`, `scapi_schemas_list`, `scapi_custom_apis_status`). When using multiple toolsets, tools are automatically deduplicated. diff --git a/packages/b2c-dx-mcp/src/commands/mcp.ts b/packages/b2c-dx-mcp/src/commands/mcp.ts index 66e4e36a..2cf4e047 100644 --- a/packages/b2c-dx-mcp/src/commands/mcp.ts +++ b/packages/b2c-dx-mcp/src/commands/mcp.ts @@ -272,7 +272,6 @@ export default class McpServerCommand extends BaseCommand), ...mrt.config, - workingDirectory: this.flags['working-directory'], }; return loadConfig(flagConfig, options); diff --git a/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts b/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts index 9660591c..95109259 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts @@ -29,7 +29,7 @@ import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; const CUSTOM_API_SCAFFOLD_ID = 'custom-api'; /** - * Input schema for scapi_customapi_scaffold tool. + * Input schema for scapi_custom_api_scaffold tool. * Parameters match the custom-api scaffold: apiName, apiType, cartridgeName, etc. */ interface ScaffoldCustomApiInput { @@ -41,8 +41,6 @@ interface ScaffoldCustomApiInput { apiType?: 'admin' | 'shopper'; /** Short description of the API. Default: "A custom B2C Commerce API" */ apiDescription?: string; - /** Include example GET/POST endpoints in schema and script. Default: true */ - includeExampleEndpoints?: boolean; /** Project root for cartridge discovery and output. Default: MCP working directory */ projectRoot?: string; /** Output directory override. Default: scaffold default or project root */ @@ -50,7 +48,7 @@ interface ScaffoldCustomApiInput { } /** - * Output schema for scapi_customapi_scaffold tool. + * Output schema for scapi_custom_api_scaffold tool. */ interface ScaffoldCustomApiOutput { scaffold: string; @@ -66,7 +64,7 @@ interface ScaffoldCustomApiOutput { } /** - * Creates the scapi_customapi_scaffold tool. + * Creates the scapi_custom_api_scaffold tool. * * Uses @salesforce/b2c-tooling-sdk scaffold: registry, resolveScaffoldParameters, * resolveOutputDirectory, generateFromScaffold. cartridgeName must be a cartridge @@ -75,7 +73,7 @@ interface ScaffoldCustomApiOutput { export function createScaffoldCustomApiTool(loadServices: () => Services): McpTool { return createToolAdapter( { - name: 'scapi_customapi_scaffold', + name: 'scapi_custom_api_scaffold', description: `Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. \ Uses the same scaffold as CLI: b2c scaffold generate custom-api. \ Required: apiName (kebab-case). Optional: cartridgeName (defaults to first cartridge found in project), apiType (shopper|admin), apiDescription, includeExampleEndpoints, projectRoot, outputDir. \ @@ -105,7 +103,6 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo .optional() .describe('Admin (no siteId) or shopper (siteId, customer-facing). Default: shopper'), apiDescription: z.string().optional().describe('Short description of the API.'), - includeExampleEndpoints: z.boolean().optional().describe('Include example GET/POST endpoints. Default: true'), projectRoot: z .string() .nullish() @@ -143,7 +140,7 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo dryRun: false, files: [], error: - 'No cartridges found in project. Add a cartridge (directory with .project file) or pass cartridgeName explicitly.', + 'No cartridges found in project. Custom API scaffold requires an existing cartridge. Create a cartridge (directory with .project file) first. You can use the `b2c scaffold cartridge` command to create a cartridge.', }; } cartridgeName = cartridges[0].name; @@ -152,12 +149,10 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo const providedVariables: Record = { apiName: args.apiName, cartridgeName, + includeExampleEndpoints: true, }; if (args.apiType !== undefined) providedVariables.apiType = args.apiType; if (args.apiDescription !== undefined) providedVariables.apiDescription = args.apiDescription; - if (args.includeExampleEndpoints !== undefined) { - providedVariables.includeExampleEndpoints = args.includeExampleEndpoints; - } const resolved = await resolveScaffoldParameters(scaffold, { providedVariables, diff --git a/packages/b2c-dx-mcp/test/registry.test.ts b/packages/b2c-dx-mcp/test/registry.test.ts index c7fcc6e7..e8dff3b3 100644 --- a/packages/b2c-dx-mcp/test/registry.test.ts +++ b/packages/b2c-dx-mcp/test/registry.test.ts @@ -91,7 +91,7 @@ describe('registry', () => { const toolNames = registry.SCAPI.map((t) => t.name); expect(toolNames).to.include('scapi_schemas_list'); expect(toolNames).to.include('scapi_custom_apis_status'); - expect(toolNames).to.include('scapi_customapi_scaffold'); + expect(toolNames).to.include('scapi_custom_api_scaffold'); }); it('should create STOREFRONTNEXT tools', () => { @@ -311,7 +311,7 @@ describe('registry', () => { // Auto-discovery always includes BASE_TOOLSET (SCAPI), even if no project type detected expect(server.registeredTools).to.include('scapi_schemas_list'); expect(server.registeredTools).to.include('scapi_custom_apis_status'); - expect(server.registeredTools).to.include('scapi_customapi_scaffold'); + expect(server.registeredTools).to.include('scapi_custom_api_scaffold'); }); it('should trigger auto-discovery when all individual tools are invalid', async () => { @@ -328,7 +328,7 @@ describe('registry', () => { // Auto-discovery always includes BASE_TOOLSET (SCAPI), even if no project type detected expect(server.registeredTools).to.include('scapi_schemas_list'); expect(server.registeredTools).to.include('scapi_custom_apis_status'); - expect(server.registeredTools).to.include('scapi_customapi_scaffold'); + expect(server.registeredTools).to.include('scapi_custom_api_scaffold'); }); it('should skip non-GA tools when allowNonGaTools is false', async () => { diff --git a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts b/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts new file mode 100644 index 00000000..9a2523ef --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {describe, it, beforeEach, afterEach} from 'mocha'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {createScaffoldCustomApiTool} from '../../../src/tools/scapi/scapi-customapi-scaffold.js'; +import {Services} from '../../../src/services.js'; +import {createMockResolvedConfig} from '../../test-helpers.js'; +import type {ToolResult} from '../../../src/utils/types.js'; + +/** + * Parse JSON from a ToolResult (success case). + */ +function getResultJson(result: ToolResult): T { + const content = result.content[0]; + if (content.type !== 'text') { + throw new Error(`Expected text content, got ${content.type}`); + } + return JSON.parse(content.text) as T; +} + +/** + * Get raw text from a ToolResult (error case). + */ +function getResultText(result: ToolResult): string { + const content = result.content[0]; + if (content.type !== 'text') { + throw new Error(`Expected text content, got ${content.type}`); + } + return content.text; +} + +interface ScaffoldOutput { + scaffold: string; + outputDir: string; + dryRun: boolean; + files: Array<{path: string; action: string; skipReason?: string}>; + postInstructions?: string; + error?: string; +} + +describe('tools/scapi/scapi-customapi-scaffold', () => { + let services: Services; + let tempDir: string; + let loadServices: () => Services; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-mcp-scaffold-test-')); + services = new Services({ + resolvedConfig: createMockResolvedConfig({workingDirectory: tempDir}), + }); + loadServices = () => services; + }); + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + describe('createScaffoldCustomApiTool', () => { + it('should create scapi_custom_api_scaffold tool with correct metadata', () => { + const tool = createScaffoldCustomApiTool(loadServices); + + expect(tool).to.exist; + expect(tool.name).to.equal('scapi_custom_api_scaffold'); + expect(tool.description).to.include('custom SCAPI'); + expect(tool.description).to.include('apiName'); + expect(tool.inputSchema).to.exist; + expect(tool.handler).to.be.a('function'); + expect(tool.toolsets).to.deep.equal(['PWAV3', 'SCAPI', 'STOREFRONTNEXT']); + expect(tool.isGA).to.be.false; + }); + + it('should have required apiName and optional cartridgeName, apiType, apiDescription, projectRoot, outputDir', () => { + const tool = createScaffoldCustomApiTool(loadServices); + + expect(tool.inputSchema).to.have.property('apiName'); + expect(tool.inputSchema).to.have.property('cartridgeName'); + expect(tool.inputSchema).to.have.property('apiType'); + expect(tool.inputSchema).to.have.property('apiDescription'); + expect(tool.inputSchema).to.have.property('projectRoot'); + expect(tool.inputSchema).to.have.property('outputDir'); + }); + }); + + describe('handler', () => { + it('should return error when no cartridges found in project (no .project file)', async () => { + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({apiName: 'my-api'}); + + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.include('No cartridges found'); + expect(text).to.include('.project'); + }); + + it('should validate apiName is required', async () => { + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({}); + + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.include('Invalid input'); + }); + + it('should validate apiName is non-empty', async () => { + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({apiName: ''}); + + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.include('Invalid input'); + }); + + it('should validate apiType when provided', async () => { + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({apiName: 'my-api', apiType: 'invalid'}); + + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.include('Invalid input'); + }); + + it('should generate custom API files when cartridge exists (first cartridge used by default)', async () => { + const cartridgeDir = path.join(tempDir, 'app_custom'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({apiName: 'test-api'}); + + expect(result.isError).to.be.undefined; + const output = getResultJson(result); + expect(output.scaffold).to.equal('custom-api'); + expect(output.error).to.be.undefined; + expect(output.files).to.be.an('array').with.lengthOf(3); + expect(output.dryRun).to.be.false; + + const paths = output.files.map((f) => f.path); + expect(paths.some((p) => p.includes('test-api') && p.endsWith('schema.yaml'))).to.be.true; + expect(paths.some((p) => p.includes('test-api') && p.endsWith('api.json'))).to.be.true; + expect(paths.some((p) => p.includes('test-api') && p.endsWith('script.js'))).to.be.true; + expect(output.postInstructions).to.include('test-api'); + expect(output.postInstructions).to.include('app_custom'); + + const schemaPath = path.join(tempDir, 'app_custom', 'cartridge', 'rest-apis', 'test-api', 'schema.yaml'); + expect(fs.existsSync(schemaPath)).to.be.true; + }); + + it('should use provided cartridgeName when given', async () => { + const cartridgeDir = path.join(tempDir, 'app_my_cartridge'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({ + apiName: 'my-endpoints', + cartridgeName: 'app_my_cartridge', + }); + + expect(result.isError).to.be.undefined; + const output = getResultJson(result); + expect(output.files).to.have.lengthOf(3); + expect(output.postInstructions).to.include('app_my_cartridge'); + + const scriptPath = path.join(tempDir, 'app_my_cartridge', 'cartridge', 'rest-apis', 'my-endpoints', 'script.js'); + expect(fs.existsSync(scriptPath)).to.be.true; + }); + + it('should pass apiType admin and include in generated schema', async () => { + const cartridgeDir = path.join(tempDir, 'int_admin'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({ + apiName: 'admin-only', + cartridgeName: 'int_admin', + apiType: 'admin', + }); + + expect(result.isError).to.be.undefined; + const schemaPath = path.join(tempDir, 'int_admin', 'cartridge', 'rest-apis', 'admin-only', 'schema.yaml'); + expect(fs.existsSync(schemaPath)).to.be.true; + const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); + expect(schemaContent).to.include('AmOAuth2'); + }); + + it('should pass apiDescription and include in generated schema', async () => { + const cartridgeDir = path.join(tempDir, 'app_custom'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + + const tool = createScaffoldCustomApiTool(loadServices); + await tool.handler({ + apiName: 'described-api', + apiDescription: 'My custom description for the API', + }); + + const schemaPath = path.join(tempDir, 'app_custom', 'cartridge', 'rest-apis', 'described-api', 'schema.yaml'); + const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); + expect(schemaContent).to.include('My custom description for the API'); + }); + + it('should use projectRoot when provided', async () => { + const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-mcp-scaffold-other-')); + const cartridgeDir = path.join(otherDir, 'app_other'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + + try { + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({ + apiName: 'other-api', + projectRoot: otherDir, + }); + + expect(result.isError).to.be.undefined; + const output = getResultJson(result); + expect(output.outputDir).to.equal(otherDir); + const schemaPath = path.join(otherDir, 'app_other', 'cartridge', 'rest-apis', 'other-api', 'schema.yaml'); + expect(fs.existsSync(schemaPath)).to.be.true; + } finally { + fs.rmSync(otherDir, {recursive: true, force: true}); + } + }); + }); +}); From e8b0ba5c1da16a28833aa542dc65aeef72a90b45 Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 16:16:14 -0500 Subject: [PATCH 04/11] fix comment --- packages/b2c-dx-mcp/src/commands/mcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/b2c-dx-mcp/src/commands/mcp.ts b/packages/b2c-dx-mcp/src/commands/mcp.ts index 2cf4e047..00943a21 100644 --- a/packages/b2c-dx-mcp/src/commands/mcp.ts +++ b/packages/b2c-dx-mcp/src/commands/mcp.ts @@ -268,7 +268,7 @@ export default class McpServerCommand extends BaseCommand), ...mrt.config, From 61a74d3362e3fd61fe0ae7fcaf6713612dba9448 Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 19:20:15 -0500 Subject: [PATCH 05/11] clean up --- packages/b2c-dx-mcp/src/tools/scapi/index.ts | 2 +- ...api-scaffold.ts => scapi-custom-api-scaffold.ts} | 13 +++++-------- .../tools/scapi/scapi-customapi-scaffold.test.ts | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) rename packages/b2c-dx-mcp/src/tools/scapi/{scapi-customapi-scaffold.ts => scapi-custom-api-scaffold.ts} (91%) diff --git a/packages/b2c-dx-mcp/src/tools/scapi/index.ts b/packages/b2c-dx-mcp/src/tools/scapi/index.ts index 8e584ca8..700e175e 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/index.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/index.ts @@ -17,7 +17,7 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import {createScapiSchemasListTool} from './scapi-schemas-list.js'; import {createScapiCustomApisStatusTool} from './scapi-custom-apis-status.js'; -import {createScaffoldCustomApiTool} from './scapi-customapi-scaffold.js'; +import {createScaffoldCustomApiTool} from './scapi-custom-api-scaffold.js'; /** * Creates all tools for the SCAPI toolset. diff --git a/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts similarity index 91% rename from packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts rename to packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts index 95109259..f2e6d57e 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/scapi-customapi-scaffold.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts @@ -8,7 +8,7 @@ * SCAPI Custom API Scaffold tool. * * Generates a new custom SCAPI endpoint using the SDK's custom-api scaffold - * (schema.yaml, api.json, script.js). Mirrors CLI: b2c scaffold generate custom-api. + * (schema.yaml, api.json, script.js). * * @module tools/scapi/scapi-customapi-scaffold */ @@ -68,19 +68,16 @@ interface ScaffoldCustomApiOutput { * * Uses @salesforce/b2c-tooling-sdk scaffold: registry, resolveScaffoldParameters, * resolveOutputDirectory, generateFromScaffold. cartridgeName must be a cartridge - * discovered under projectRoot (e.g. from .project or cartridges/). CLI: b2c scaffold generate custom-api. + * discovered under projectRoot (e.g. from .project or cartridges/). */ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTool { return createToolAdapter( { name: 'scapi_custom_api_scaffold', description: `Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. \ - Uses the same scaffold as CLI: b2c scaffold generate custom-api. \ - Required: apiName (kebab-case). Optional: cartridgeName (defaults to first cartridge found in project), apiType (shopper|admin), apiDescription, includeExampleEndpoints, projectRoot, outputDir. \ - cartridgeName must be one of the cartridges discovered under projectRoot (--working-directory or SFCC_WORKING_DIRECTORY). \ - Set projectRoot to override the working directory. \ - For faster runs, set --working-directory to your cartridge project root (same as where you would run the CLI from). \ - CLI: b2c scaffold generate custom-api.`, +Required: apiName (kebab-case). Optional: cartridgeName (defaults to first cartridge found in project), apiType (shopper|admin) default to shopper, \ +apiDescription, projectRoot, outputDir. \ +Set projectRoot to override the default project directory.`, toolsets: ['PWAV3', 'SCAPI', 'STOREFRONTNEXT'], isGA: false, requiresInstance: false, diff --git a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts b/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts index 9a2523ef..4f99ed64 100644 --- a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts +++ b/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts @@ -9,7 +9,7 @@ import {describe, it, beforeEach, afterEach} from 'mocha'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import {createScaffoldCustomApiTool} from '../../../src/tools/scapi/scapi-customapi-scaffold.js'; +import {createScaffoldCustomApiTool} from '../../../src/tools/scapi/scapi-custom-api-scaffold.js'; import {Services} from '../../../src/services.js'; import {createMockResolvedConfig} from '../../test-helpers.js'; import type {ToolResult} from '../../../src/utils/types.js'; From b16fa6c4c5f36db8020f456e5bf4a5d16e22c23f Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 19:55:21 -0500 Subject: [PATCH 06/11] add docs --- docs/mcp/tools/scapi-custom-api-scaffold.md | 96 +++++++++++++++++++++ docs/mcp/tools/scapi-custom-apis-status.md | 2 +- docs/mcp/tools/scapi-schemas-list.md | 1 + docs/mcp/toolsets.md | 5 +- 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 docs/mcp/tools/scapi-custom-api-scaffold.md diff --git a/docs/mcp/tools/scapi-custom-api-scaffold.md b/docs/mcp/tools/scapi-custom-api-scaffold.md new file mode 100644 index 00000000..943369d0 --- /dev/null +++ b/docs/mcp/tools/scapi-custom-api-scaffold.md @@ -0,0 +1,96 @@ +--- +description: Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. +--- + +# scapi_custom_api_scaffold + +Generate a new custom SCAPI endpoint in an existing cartridge. Creates `schema.yaml` (OAS 3.0 contract), `api.json` (endpoint mapping), and `script.js` (implementation) under the cartridge's `rest-apis//` directory. + +## Overview + +The `scapi_custom_api_scaffold` tool scaffolds a new custom API using the B2C tooling SDK's `custom-api` scaffold. It: + +- Creates an OpenAPI 3.0 schema, API manifest, and script stub in your project. +- Uses the first cartridge found in the project if you don't specify one. +- Supports **shopper** (siteId, customer-facing) or **admin** (no siteId) API types. + +**No instance or OAuth required** — this tool works locally and only writes files into your project. To check registration status after deployment, use [`scapi_custom_apis_status`](./scapi-custom-apis-status). + +## Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `apiName` | string | Yes | API name in kebab-case (e.g. `my-products`). Must start with a lowercase letter; only letters, numbers, and hyphens. | +| `cartridgeName` | string | No | Cartridge that will contain the API. Omit to use the first cartridge found under the project (working directory or `projectRoot`). | +| `apiType` | `"admin"` \| `"shopper"` | No | `shopper` (siteId, customer-facing) or `admin` (no siteId). Default: `shopper`. | +| `apiDescription` | string | No | Short description of the API. | +| `projectRoot` | string | No | Project root for cartridge discovery. Default: MCP working directory (`--project-directory` / `SFCC_PROJECT_DIRECTORY`). | +| `outputDir` | string | No | Output directory override. Default: project root (scaffold writes under cartridge path). | + +## Usage Examples + +### Create a custom API (default cartridge and type) + +``` +Use the MCP tool to create a custom API named my-loyalty-api. +``` + +### Create a shopper API with a description + +``` +Use the MCP tool to scaffold a custom API named product-recommendations, type shopper, with description "Product recommendations by segment". +``` + +### Create an admin API in a specific cartridge + +``` +Use the MCP tool to create a custom admin API named inventory-sync in cartridge app_custom. +``` + +## Output + +### Success + +Returns the scaffold ID, output directory, and list of created files: + +```json +{ + "scaffold": "custom-api", + "outputDir": "/path/to/project", + "dryRun": false, + "files": [ + { "path": ".../rest-apis/my-custom-api/schema.yaml", "action": "created" }, + { "path": ".../rest-apis/my-custom-api/api.json", "action": "created" }, + { "path": ".../rest-apis/my-custom-api/script.js", "action": "created" } + ], + "postInstructions": "Custom API 'my-custom-api' has been created in cartridge 'app_storefrontnext_base'. ..." +} +``` + +### Errors + +- **No cartridges found** — Project has no cartridge (e.g. no `.project` in a cartridge directory). Create a cartridge first (e.g. `b2c scaffold cartridge`). +- **Scaffold not found** — SDK `custom-api` scaffold is missing; ensure `@salesforce/b2c-tooling-sdk` is installed. +- **Parameter validation failed** — Invalid `apiName` (e.g. not kebab-case) or other parameter issue. + +## Next steps after scaffolding + +1. **Edit** `schema.yaml` to define paths, request/response schemas, and operation IDs. +2. **Edit** `script.js` to implement the endpoint logic. +3. **Deploy** the cartridge to your instance and **activate** the code version to register the API. +4. **Verify** with [`scapi_custom_apis_status`](./scapi-custom-apis-status) that endpoints show as `active`. + +Shopper APIs are available at: +`https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/v1/organizations/{organizationId}/...` and require the `siteId` query parameter and ShopperToken authentication. + +## Related Tools + +- Part of the [SCAPI](../toolsets#scapi), [PWAV3](../toolsets#pwav3), and [STOREFRONTNEXT](../toolsets#storefrontnext) toolsets +- [`scapi_custom_apis_status`](./scapi-custom-apis-status) — Check custom API endpoint registration status after deployment +- [`scapi_schemas_list`](./scapi-schemas-list) — List or fetch custom API schemas (use `apiFamily: "custom"`) + +## See Also + +- [SCAPI Toolset](../toolsets#scapi) — Overview of SCAPI tools +- [Scaffolding Guide](../../guide/scaffolding#custom-api) — CLI and SDK scaffolding (including `b2c scaffold custom-api`) +- [CLI Reference](../../cli/scaffold) — `b2c scaffold` commands diff --git a/docs/mcp/tools/scapi-custom-apis-status.md b/docs/mcp/tools/scapi-custom-apis-status.md index d8b9ec90..8661d600 100644 --- a/docs/mcp/tools/scapi-custom-apis-status.md +++ b/docs/mcp/tools/scapi-custom-apis-status.md @@ -15,7 +15,7 @@ The `scapi_custom_apis_status` tool checks the registration status of custom API - Provides per-site details (one row per endpoint per site). - Supports filtering, grouping, and column selection. -**Important:** This tool is **remote only** - it queries your live instance. For schema definitions, use [`scapi_schemas_list`](./scapi-schemas-list) with `apiFamily: "custom"`. +**Important:** This tool is **remote only** - it queries your live instance. For schema definitions, use [`scapi_schemas_list`](./scapi-schemas-list) with `apiFamily: "custom"`. To create a new custom API in your project, use [`scapi_custom_api_scaffold`](./scapi-custom-api-scaffold). ## Authentication diff --git a/docs/mcp/tools/scapi-schemas-list.md b/docs/mcp/tools/scapi-schemas-list.md index 961df486..bd2c61d5 100644 --- a/docs/mcp/tools/scapi-schemas-list.md +++ b/docs/mcp/tools/scapi-schemas-list.md @@ -164,6 +164,7 @@ Use the MCP tool to show me the full OpenAPI spec for shopper-products v1. - [SCAPI Toolset](../toolsets#scapi) - Overview of SCAPI discovery tools - [scapi_custom_apis_status](./scapi-custom-apis-status) - Check custom API endpoint registration status +- [scapi_custom_api_scaffold](./scapi-custom-api-scaffold) - Generate a new custom API in a cartridge - [Authentication Setup](../../guide/authentication#scapi-authentication) - Set up SCAPI authentication with required roles and scopes - [Configuration](../configuration) - Configure OAuth credentials - [CLI Reference](../../cli/scapi-schemas) - Equivalent CLI commands: `b2c scapi schemas list` and `b2c scapi schemas get` diff --git a/docs/mcp/toolsets.md b/docs/mcp/toolsets.md index 9a12fe3f..d7c5b286 100644 --- a/docs/mcp/toolsets.md +++ b/docs/mcp/toolsets.md @@ -62,6 +62,7 @@ PWA Kit v3 development tools for building headless storefronts. | Tool | Description | Documentation | |------|-------------|---------------| | [`scapi_schemas_list`](./tools/scapi-schemas-list) | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | [View details](./tools/scapi-schemas-list) | +| [`scapi_custom_api_scaffold`](./tools/scapi-custom-api-scaffold) | Generate a new custom SCAPI endpoint (schema, api.json, script.js) in an existing cartridge. | [View details](./tools/scapi-custom-api-scaffold) | | [`scapi_custom_apis_status`](./tools/scapi-custom-apis-status) | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | [View details](./tools/scapi-custom-apis-status) | | [`mrt_bundle_push`](./tools/mrt-bundle-push) | Build, push bundle (optionally deploy) | [View details](./tools/mrt-bundle-push) | @@ -78,6 +79,7 @@ Salesforce Commerce API discovery and exploration. | Tool | Description | Documentation | |------|-------------|---------------| | [`scapi_schemas_list`](./tools/scapi-schemas-list) | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | [View details](./tools/scapi-schemas-list) | +| [`scapi_custom_api_scaffold`](./tools/scapi-custom-api-scaffold) | Generate a new custom SCAPI endpoint (schema, api.json, script.js) in an existing cartridge. | [View details](./tools/scapi-custom-api-scaffold) | | [`scapi_custom_apis_status`](./tools/scapi-custom-apis-status) | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | [View details](./tools/scapi-custom-apis-status) | ## STOREFRONTNEXT @@ -95,12 +97,13 @@ Storefront Next development tools for building modern storefronts. | `storefront_next_development_guidelines` | Get Storefront Next development guidelines and best practices | — | | [`storefront_next_page_designer_decorator`](./tools/storefront-next-page-designer-decorator) | Add Page Designer decorators to Storefront Next components | [View details](./tools/storefront-next-page-designer-decorator) | | [`scapi_schemas_list`](./tools/scapi-schemas-list) | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | [View details](./tools/scapi-schemas-list) | +| [`scapi_custom_api_scaffold`](./tools/scapi-custom-api-scaffold) | Generate a new custom SCAPI endpoint (schema, api.json, script.js) in an existing cartridge. | [View details](./tools/scapi-custom-api-scaffold) | | [`scapi_custom_apis_status`](./tools/scapi-custom-apis-status) | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | [View details](./tools/scapi-custom-apis-status) | | [`mrt_bundle_push`](./tools/mrt-bundle-push) | Build, push bundle (optionally deploy) | [View details](./tools/mrt-bundle-push) | ## Tool Deduplication -Some tools appear in multiple toolsets (for example, `mrt_bundle_push`, `scapi_schemas_list`, `scapi_custom_apis_status`). When using multiple toolsets, tools are automatically deduplicated, so you'll only see each tool once. +Some tools appear in multiple toolsets (for example, `mrt_bundle_push`, `scapi_schemas_list`, `scapi_custom_api_scaffold`, `scapi_custom_apis_status`). When using multiple toolsets, tools are automatically deduplicated, so you'll only see each tool once. ## Next Steps From b1101d33c4928b9f92ae3370baa94edbe941c60e Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 20:26:52 -0500 Subject: [PATCH 07/11] fix test --- .../scapi/scapi-customapi-scaffold.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts b/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts index 4f99ed64..c97af037 100644 --- a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts +++ b/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts @@ -45,7 +45,7 @@ interface ScaffoldOutput { error?: string; } -describe('tools/scapi/scapi-customapi-scaffold', () => { +describe('tools/scapi/scapi-custom-api-scaffold', () => { let services: Services; let tempDir: string; let loadServices: () => Services; @@ -53,7 +53,7 @@ describe('tools/scapi/scapi-customapi-scaffold', () => { beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-mcp-scaffold-test-')); services = new Services({ - resolvedConfig: createMockResolvedConfig({workingDirectory: tempDir}), + resolvedConfig: createMockResolvedConfig({projectDirectory: tempDir}), }); loadServices = () => services; }); @@ -131,7 +131,7 @@ describe('tools/scapi/scapi-customapi-scaffold', () => { it('should generate custom API files when cartridge exists (first cartridge used by default)', async () => { const cartridgeDir = path.join(tempDir, 'app_custom'); fs.mkdirSync(cartridgeDir, {recursive: true}); - fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf8'); const tool = createScaffoldCustomApiTool(loadServices); const result = await tool.handler({apiName: 'test-api'}); @@ -157,7 +157,7 @@ describe('tools/scapi/scapi-customapi-scaffold', () => { it('should use provided cartridgeName when given', async () => { const cartridgeDir = path.join(tempDir, 'app_my_cartridge'); fs.mkdirSync(cartridgeDir, {recursive: true}); - fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf8'); const tool = createScaffoldCustomApiTool(loadServices); const result = await tool.handler({ @@ -177,7 +177,7 @@ describe('tools/scapi/scapi-customapi-scaffold', () => { it('should pass apiType admin and include in generated schema', async () => { const cartridgeDir = path.join(tempDir, 'int_admin'); fs.mkdirSync(cartridgeDir, {recursive: true}); - fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf8'); const tool = createScaffoldCustomApiTool(loadServices); const result = await tool.handler({ @@ -189,14 +189,14 @@ describe('tools/scapi/scapi-customapi-scaffold', () => { expect(result.isError).to.be.undefined; const schemaPath = path.join(tempDir, 'int_admin', 'cartridge', 'rest-apis', 'admin-only', 'schema.yaml'); expect(fs.existsSync(schemaPath)).to.be.true; - const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); + const schemaContent = fs.readFileSync(schemaPath, 'utf8'); expect(schemaContent).to.include('AmOAuth2'); }); it('should pass apiDescription and include in generated schema', async () => { const cartridgeDir = path.join(tempDir, 'app_custom'); fs.mkdirSync(cartridgeDir, {recursive: true}); - fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf8'); const tool = createScaffoldCustomApiTool(loadServices); await tool.handler({ @@ -205,7 +205,7 @@ describe('tools/scapi/scapi-customapi-scaffold', () => { }); const schemaPath = path.join(tempDir, 'app_custom', 'cartridge', 'rest-apis', 'described-api', 'schema.yaml'); - const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); + const schemaContent = fs.readFileSync(schemaPath, 'utf8'); expect(schemaContent).to.include('My custom description for the API'); }); @@ -213,7 +213,7 @@ describe('tools/scapi/scapi-customapi-scaffold', () => { const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-mcp-scaffold-other-')); const cartridgeDir = path.join(otherDir, 'app_other'); fs.mkdirSync(cartridgeDir, {recursive: true}); - fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf-8'); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf8'); try { const tool = createScaffoldCustomApiTool(loadServices); From bfc9872f0016f99e37f42e7f485022c0296aeb3d Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 20:38:27 -0500 Subject: [PATCH 08/11] improve code coverage --- .../scapi/scapi-customapi-scaffold.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts b/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts index c97af037..08f9d45c 100644 --- a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts +++ b/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts @@ -231,5 +231,41 @@ describe('tools/scapi/scapi-custom-api-scaffold', () => { fs.rmSync(otherDir, {recursive: true, force: true}); } }); + + it('should return error when parameter validation fails (invalid cartridgeName)', async () => { + const cartridgeDir = path.join(tempDir, 'app_custom'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf8'); + + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({ + apiName: 'my-api', + cartridgeName: 'nonexistent_cartridge', + }); + + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.include('Parameter validation failed'); + }); + + it('should return error when generateFromScaffold throws', async () => { + const cartridgeDir = path.join(tempDir, 'app_custom'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf8'); + // Use outputDir that is a file (not a directory) so scaffold write fails + const fileAsDir = path.join(tempDir, 'blocker'); + fs.writeFileSync(fileAsDir, '', 'utf8'); + + const tool = createScaffoldCustomApiTool(loadServices); + const result = await tool.handler({ + apiName: 'my-api', + projectRoot: tempDir, + outputDir: fileAsDir, + }); + + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.include('Scaffold generation failed'); + }); }); }); From 997e2fa607a236d91f95545b1e138daef63d070c Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 20:42:02 -0500 Subject: [PATCH 09/11] fix comment --- .../b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts index f2e6d57e..f663c962 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts @@ -10,7 +10,7 @@ * Generates a new custom SCAPI endpoint using the SDK's custom-api scaffold * (schema.yaml, api.json, script.js). * - * @module tools/scapi/scapi-customapi-scaffold + * @module tools/scapi/scapi-custom-api-scaffold */ import path from 'node:path'; From e36ec5182b55c84ae3026bd8c676fc15b247f8fa Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 20:48:32 -0500 Subject: [PATCH 10/11] fix message --- .../src/tools/scapi/scapi-custom-api-scaffold.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts index f663c962..acd1afaf 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts @@ -76,8 +76,7 @@ export function createScaffoldCustomApiTool(loadServices: () => Services): McpTo name: 'scapi_custom_api_scaffold', description: `Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. \ Required: apiName (kebab-case). Optional: cartridgeName (defaults to first cartridge found in project), apiType (shopper|admin) default to shopper, \ -apiDescription, projectRoot, outputDir. \ -Set projectRoot to override the default project directory.`, +apiDescription, projectRoot, outputDir.`, toolsets: ['PWAV3', 'SCAPI', 'STOREFRONTNEXT'], isGA: false, requiresInstance: false, @@ -93,7 +92,7 @@ Set projectRoot to override the default project directory.`, .min(1) .nullish() .describe( - 'Cartridge name that will contain the API. Optional; omit to use the first cartridge found under working directory (--working-directory or SFCC_WORKING_DIRECTORY).', + 'Cartridge name that will contain the API. Optional; omit to use the first cartridge found under project root).', ), apiType: z .enum(['admin', 'shopper']) @@ -104,7 +103,7 @@ Set projectRoot to override the default project directory.`, .string() .nullish() .describe( - 'Project root for cartridge discovery. Default: working directory. Set to override the working directory.', + 'Project root for cartridge discovery. Default: project directory. Set to override the project directory.', ), outputDir: z.string().optional().describe('Output directory override. Default: project root'), }, From bdadb7914fe192cea0a12fb7a45d209a8e5d483b Mon Sep 17 00:00:00 2001 From: wei-liu Date: Wed, 25 Feb 2026 21:01:08 -0500 Subject: [PATCH 11/11] test coverage --- .../tools/scapi/scapi-custom-api-scaffold.ts | 240 ++++++++++-------- ...t.ts => scapi-custom-api-scaffold.test.ts} | 42 ++- 2 files changed, 175 insertions(+), 107 deletions(-) rename packages/b2c-dx-mcp/test/tools/scapi/{scapi-customapi-scaffold.test.ts => scapi-custom-api-scaffold.test.ts} (85%) diff --git a/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts index acd1afaf..c3002add 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts @@ -24,10 +24,17 @@ import { resolveScaffoldParameters, resolveOutputDirectory, } from '@salesforce/b2c-tooling-sdk/scaffold'; +import type {Scaffold, ResolvedParameters, ResolveParametersOptions} from '@salesforce/b2c-tooling-sdk/scaffold'; import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; const CUSTOM_API_SCAFFOLD_ID = 'custom-api'; +/** Optional overrides for testing (scaffold not found, missing required). */ +export interface ScaffoldCustomApiExecuteOverrides { + getScaffold?: (id: string, opts: {projectRoot: string}) => Promise; + resolveScaffoldParameters?: (scaffold: Scaffold, opts: ResolveParametersOptions) => Promise; +} + /** * Input schema for scapi_custom_api_scaffold tool. * Parameters match the custom-api scaffold: apiName, apiType, cartridgeName, etc. @@ -63,14 +70,139 @@ interface ScaffoldCustomApiOutput { error?: string; } +/** + * Core execute logic for the custom API scaffold tool. + * Exported for tests so we can inject getScaffold / resolveScaffoldParameters and cover error branches. + */ +export async function executeScaffoldCustomApi( + args: ScaffoldCustomApiInput, + services: Services, + overrides?: ScaffoldCustomApiExecuteOverrides, +): Promise { + const projectRoot = path.resolve(args.projectRoot ?? services.getWorkingDirectory()); + + const getScaffold = + overrides?.getScaffold ?? + (async (id: string, opts: {projectRoot: string}) => { + const registry = createScaffoldRegistry(); + return registry.getScaffold(id, opts); + }); + const scaffold = await getScaffold(CUSTOM_API_SCAFFOLD_ID, {projectRoot}); + + if (!scaffold) { + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir: projectRoot, + dryRun: false, + files: [], + error: `Scaffold not found: ${CUSTOM_API_SCAFFOLD_ID}. Ensure @salesforce/b2c-tooling-sdk is installed.`, + }; + } + + let cartridgeName = args.cartridgeName; + if (!cartridgeName) { + const cartridges = findCartridges(projectRoot); + if (cartridges.length === 0) { + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir: projectRoot, + dryRun: false, + files: [], + error: + 'No cartridges found in project. Custom API scaffold requires an existing cartridge. Create a cartridge (directory with .project file) first. You can use the `b2c scaffold cartridge` command to create a cartridge.', + }; + } + cartridgeName = cartridges[0].name; + } + + const providedVariables: Record = { + apiName: args.apiName, + cartridgeName, + includeExampleEndpoints: true, + }; + if (args.apiType !== undefined) providedVariables.apiType = args.apiType; + if (args.apiDescription !== undefined) providedVariables.apiDescription = args.apiDescription; + + const resolveParams = overrides?.resolveScaffoldParameters ?? resolveScaffoldParameters; + const resolved = await resolveParams(scaffold, { + providedVariables, + projectRoot, + useDefaults: true, + }); + + if (resolved.errors.length > 0) { + const message = resolved.errors.map((e) => `${e.parameter}: ${e.message}`).join('; '); + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir: projectRoot, + dryRun: false, + files: [], + error: `Parameter validation failed: ${message}`, + }; + } + + const missingRequired = resolved.missingParameters.filter((p) => p.required); + if (missingRequired.length > 0) { + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir: projectRoot, + dryRun: false, + files: [], + error: `Missing required parameter: ${missingRequired[0].name}. For cartridgeName, ensure the cartridge exists in the project (under projectRoot).`, + }; + } + + const outputDir = resolveOutputDirectory({ + outputDir: args.outputDir, + scaffold, + projectRoot, + }); + + try { + const result = await generateFromScaffold(scaffold, { + outputDir, + variables: resolved.variables as Record, + dryRun: false, + force: false, + }); + + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir, + dryRun: result.dryRun, + files: result.files.map((f) => ({ + path: f.path, + action: f.action, + skipReason: f.skipReason, + })), + postInstructions: result.postInstructions, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + scaffold: CUSTOM_API_SCAFFOLD_ID, + outputDir, + dryRun: false, + files: [], + error: `Scaffold generation failed: ${message}`, + }; + } +} + /** * Creates the scapi_custom_api_scaffold tool. * * Uses @salesforce/b2c-tooling-sdk scaffold: registry, resolveScaffoldParameters, * resolveOutputDirectory, generateFromScaffold. cartridgeName must be a cartridge * discovered under projectRoot (e.g. from .project or cartridges/). + * + * @param loadServices - Function that returns Services (used by adapter on each call). + * @param executeOverrides - Optional overrides for testing (getScaffold, resolveScaffoldParameters). */ -export function createScaffoldCustomApiTool(loadServices: () => Services): McpTool { +export function createScaffoldCustomApiTool( + loadServices: () => Services, + executeOverrides?: ScaffoldCustomApiExecuteOverrides, +): McpTool { return createToolAdapter( { name: 'scapi_custom_api_scaffold', @@ -108,111 +240,7 @@ apiDescription, projectRoot, outputDir.`, outputDir: z.string().optional().describe('Output directory override. Default: project root'), }, async execute(args, {services}) { - const projectRoot = path.resolve(args.projectRoot ?? services.getWorkingDirectory()); - - const registry = createScaffoldRegistry(); - const scaffold = await registry.getScaffold(CUSTOM_API_SCAFFOLD_ID, { - projectRoot, - }); - - if (!scaffold) { - return { - scaffold: CUSTOM_API_SCAFFOLD_ID, - outputDir: projectRoot, - dryRun: false, - files: [], - error: `Scaffold not found: ${CUSTOM_API_SCAFFOLD_ID}. Ensure @salesforce/b2c-tooling-sdk is installed.`, - }; - } - - let cartridgeName = args.cartridgeName; - // If cartridgeName is not provided, use the first cartridge found in project directory. - if (!cartridgeName) { - const cartridges = findCartridges(projectRoot); - if (cartridges.length === 0) { - return { - scaffold: CUSTOM_API_SCAFFOLD_ID, - outputDir: projectRoot, - dryRun: false, - files: [], - error: - 'No cartridges found in project. Custom API scaffold requires an existing cartridge. Create a cartridge (directory with .project file) first. You can use the `b2c scaffold cartridge` command to create a cartridge.', - }; - } - cartridgeName = cartridges[0].name; - } - - const providedVariables: Record = { - apiName: args.apiName, - cartridgeName, - includeExampleEndpoints: true, - }; - if (args.apiType !== undefined) providedVariables.apiType = args.apiType; - if (args.apiDescription !== undefined) providedVariables.apiDescription = args.apiDescription; - - const resolved = await resolveScaffoldParameters(scaffold, { - providedVariables, - projectRoot, - useDefaults: true, - }); - - if (resolved.errors.length > 0) { - const message = resolved.errors.map((e) => `${e.parameter}: ${e.message}`).join('; '); - return { - scaffold: CUSTOM_API_SCAFFOLD_ID, - outputDir: projectRoot, - dryRun: false, - files: [], - error: `Parameter validation failed: ${message}`, - }; - } - - const missingRequired = resolved.missingParameters.filter((p) => p.required); - if (missingRequired.length > 0) { - return { - scaffold: CUSTOM_API_SCAFFOLD_ID, - outputDir: projectRoot, - dryRun: false, - files: [], - error: `Missing required parameter: ${missingRequired[0].name}. For cartridgeName, ensure the cartridge exists in the project (under projectRoot).`, - }; - } - - const outputDir = resolveOutputDirectory({ - outputDir: args.outputDir, - scaffold, - projectRoot, - }); - - try { - const result = await generateFromScaffold(scaffold, { - outputDir, - variables: resolved.variables as Record, - dryRun: false, - force: false, - }); - - return { - scaffold: CUSTOM_API_SCAFFOLD_ID, - outputDir, - dryRun: result.dryRun, - files: result.files.map((f) => ({ - path: f.path, - action: f.action, - skipReason: f.skipReason, - })), - postInstructions: result.postInstructions, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - scaffold: CUSTOM_API_SCAFFOLD_ID, - outputDir, - dryRun: false, - files: [], - error: `Scaffold generation failed: ${message}`, - }; - } + return executeScaffoldCustomApi(args, services, executeOverrides); }, formatOutput(output) { if (output.error) { diff --git a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts b/packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-api-scaffold.test.ts similarity index 85% rename from packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts rename to packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-api-scaffold.test.ts index 08f9d45c..05e38747 100644 --- a/packages/b2c-dx-mcp/test/tools/scapi/scapi-customapi-scaffold.test.ts +++ b/packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-api-scaffold.test.ts @@ -9,7 +9,10 @@ import {describe, it, beforeEach, afterEach} from 'mocha'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import {createScaffoldCustomApiTool} from '../../../src/tools/scapi/scapi-custom-api-scaffold.js'; +import { + createScaffoldCustomApiTool, + executeScaffoldCustomApi, +} from '../../../src/tools/scapi/scapi-custom-api-scaffold.js'; import {Services} from '../../../src/services.js'; import {createMockResolvedConfig} from '../../test-helpers.js'; import type {ToolResult} from '../../../src/utils/types.js'; @@ -267,5 +270,42 @@ describe('tools/scapi/scapi-custom-api-scaffold', () => { const text = getResultText(result); expect(text).to.include('Scaffold generation failed'); }); + + it('should return error when scaffold is not found (executeScaffoldCustomApi with getScaffold override)', async () => { + const result = await executeScaffoldCustomApi({apiName: 'my-api'}, services, {getScaffold: async () => null}); + + expect(result.error).to.be.a('string'); + expect(result.error).to.include('Scaffold not found'); + expect(result.error).to.include('custom-api'); + expect(result.files).to.deep.equal([]); + }); + + it('should return error when required parameter is missing (executeScaffoldCustomApi with resolveScaffoldParameters override)', async () => { + const cartridgeDir = path.join(tempDir, 'app_custom'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), '', 'utf8'); + + const result = await executeScaffoldCustomApi({apiName: 'my-api'}, services, { + getScaffold: async () => + ({ + id: 'custom-api', + manifest: {}, + path: '', + filesPath: '', + }) as import('@salesforce/b2c-tooling-sdk/scaffold').Scaffold, + resolveScaffoldParameters: async () => ({ + variables: {}, + errors: [], + missingParameters: [ + {name: 'cartridgeName', required: true} as import('@salesforce/b2c-tooling-sdk/scaffold').ScaffoldParameter, + ], + }), + }); + + expect(result.error).to.be.a('string'); + expect(result.error).to.include('Missing required parameter'); + expect(result.error).to.include('cartridgeName'); + expect(result.files).to.deep.equal([]); + }); }); });