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 diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index 4b323cef..166a9b4d 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -121,7 +121,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`):** @@ -138,6 +138,14 @@ 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 (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." +- ✅ "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`):** 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. @@ -282,6 +290,7 @@ PWA Kit v3 development tools for building headless storefronts. |------|-------------| | `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 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 @@ -292,6 +301,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 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. @@ -303,6 +313,7 @@ Storefront Next development tools for building modern storefronts. | `storefront_next_page_designer_decorator` | Add Page Designer decorators to 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 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/tools/scapi/index.ts b/packages/b2c-dx-mcp/src/tools/scapi/index.ts index db5b253f..700e175e 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-custom-api-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-custom-api-scaffold.ts b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts new file mode 100644 index 00000000..c3002add --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/scapi/scapi-custom-api-scaffold.ts @@ -0,0 +1,254 @@ +/* + * 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). + * + * @module tools/scapi/scapi-custom-api-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 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. + */ +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; + /** Project root for cartridge discovery and output. Default: MCP working directory */ + projectRoot?: string; + /** Output directory override. Default: scaffold default or project root */ + outputDir?: string; +} + +/** + * Output schema for scapi_custom_api_scaffold tool. + */ +interface ScaffoldCustomApiOutput { + scaffold: string; + outputDir: string; + dryRun: boolean; + files: Array<{ + path: string; + action: string; + skipReason?: string; + }>; + postInstructions?: string; + 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, + executeOverrides?: ScaffoldCustomApiExecuteOverrides, +): 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. \ +Required: apiName (kebab-case). Optional: cartridgeName (defaults to first cartridge found in project), apiType (shopper|admin) default to shopper, \ +apiDescription, projectRoot, outputDir.`, + 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 project root).', + ), + 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.'), + projectRoot: z + .string() + .nullish() + .describe( + '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'), + }, + async execute(args, {services}) { + return executeScaffoldCustomApi(args, services, executeOverrides); + }, + 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 38aa261d..2d9819f5 100644 --- a/packages/b2c-dx-mcp/test/registry.test.ts +++ b/packages/b2c-dx-mcp/test/registry.test.ts @@ -86,6 +86,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_custom_api_scaffold'); }); it('should create STOREFRONTNEXT tools', () => { @@ -303,6 +304,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_custom_api_scaffold'); }); it('should trigger auto-discovery when all individual tools are invalid', async () => { @@ -319,6 +321,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_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-custom-api-scaffold.test.ts b/packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-api-scaffold.test.ts new file mode 100644 index 00000000..05e38747 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-api-scaffold.test.ts @@ -0,0 +1,311 @@ +/* + * 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, + 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'; + +/** + * 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-custom-api-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({projectDirectory: 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'), '', 'utf8'); + + 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'), '', 'utf8'); + + 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'), '', 'utf8'); + + 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, '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'), '', 'utf8'); + + 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, 'utf8'); + 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'), '', 'utf8'); + + 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}); + } + }); + + 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'); + }); + + 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([]); + }); + }); +}); 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;