diff --git a/packages/b2c-dx-mcp/.mocharc.json b/packages/b2c-dx-mcp/.mocharc.json index 9392045..5eda487 100644 --- a/packages/b2c-dx-mcp/.mocharc.json +++ b/packages/b2c-dx-mcp/.mocharc.json @@ -9,4 +9,3 @@ "import=tsx" ] } - diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index e88b475..877a001 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -22,6 +22,26 @@ Since the package is not yet published to npm, see the [Development](#developmen | `--tools` | Comma-separated individual tools to enable (case-insensitive) | | `--allow-non-ga-tools` | Enable experimental (non-GA) tools | +#### MRT Flags (inherited from MrtCommand) + +| Flag | Env Variable | Description | +|------|--------------|-------------| +| `--api-key` | `SFCC_MRT_API_KEY` | MRT API key for Managed Runtime operations | +| `--project` | `SFCC_MRT_PROJECT` | MRT project slug (required for MRT tools) | +| `--environment` | `SFCC_MRT_ENVIRONMENT` | MRT environment (e.g., staging, production) | +| `--cloud-origin` | `SFCC_MRT_CLOUD_ORIGIN` | MRT cloud origin URL (default: https://cloud.mobify.com). See [Environment-Specific Config](#environment-specific-config) | + +#### B2C Instance Flags (inherited from InstanceCommand) + +| Flag | Env Variable | Description | +|------|--------------|-------------| +| `--server` | `SFCC_SERVER` | B2C instance hostname | +| `--code-version` | `SFCC_CODE_VERSION` | Code version for deployments | +| `--username` | `SFCC_USERNAME` | Username for Basic auth (WebDAV) | +| `--password` | `SFCC_PASSWORD` | Password/access key for Basic auth | +| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID | +| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret | + #### Global Flags (inherited from SDK) | Flag | Description | @@ -48,6 +68,23 @@ Since the package is not yet published to npm, see the [Development](#developmen // Explicit config file path "args": ["--toolsets", "all", "--config", "/path/to/dw.json"] +// B2C instance tools with Basic auth (preferred for WebDAV tools like cartridge_deploy) +"args": ["--toolsets", "CARTRIDGES", "--server", "your-sandbox.demandware.net", "--username", "your.username", "--password", "your-access-key"] + +// B2C instance tools with OAuth (for OCAPI/SCAPI tools, or WebDAV fallback) +"args": ["--toolsets", "SCAPI", "--server", "your-sandbox.demandware.net", "--client-id", "your-client-id", "--client-secret", "your-client-secret"] + +// B2C instance tools with env vars (Basic auth) +"args": ["--toolsets", "CARTRIDGES"], +"env": { "SFCC_SERVER": "your-sandbox.demandware.net", "SFCC_USERNAME": "your.username", "SFCC_PASSWORD": "your-access-key" } + +// MRT tools with project, environment, and API key +"args": ["--toolsets", "MRT", "--project", "my-project", "--environment", "staging", "--api-key", "your-api-key"] + +// Or use environment variables in mcp.json +"args": ["--toolsets", "MRT"], +"env": { "SFCC_MRT_API_KEY": "your-api-key", "SFCC_MRT_PROJECT": "my-project", "SFCC_MRT_ENVIRONMENT": "staging" } + // Enable experimental tools (required for placeholder tools) "args": ["--toolsets", "all", "--allow-non-ga-tools"] @@ -204,13 +241,20 @@ Configure your IDE to use the local MCP server. Add this to your IDE's MCP confi { "mcpServers": { "b2c-dx-local": { - "command": "node", - "args": ["--conditions", "development", "/full/path/to/packages/b2c-dx-mcp/bin/dev.js", "--toolsets", "all", "--allow-non-ga-tools"] + "command": "/full/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": [ + "--toolsets", "all", + "--allow-non-ga-tools" + ] } } } ``` +> **Note:** Make sure the script is executable: `chmod +x /full/path/to/packages/b2c-dx-mcp/bin/dev.js` +> +> The script's shebang (`#!/usr/bin/env -S node --conditions development`) handles Node.js setup automatically. + > **Note:** Restart the MCP server in your IDE to pick up code changes. #### 3. JSON-RPC via stdin @@ -229,30 +273,176 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"cartridge_ > **Note:** Configuration is not currently required as all tools are placeholder implementations. This section will be relevant once tools are fully implemented. -Tools that interact with B2C Commerce instances (e.g., MRT, SCAPI, cartridge deployment) require credentials, which can be provided via **environment variables**, a **`.env` file**, a **`dw.json` file**, or the **`--config`** flag. Local tools (e.g., scaffolding, development guidelines) work without configuration. +Different tools require different types of configuration: + +| Tool Type | Configuration Required | +|-----------|----------------------| +| **MRT tools** (e.g., `mrt_bundle_push`) | API key + project | +| **B2C instance tools** (e.g., `cartridge_deploy`) | dw.json or instance flags | +| **Local tools** (e.g., scaffolding) | None | + +#### MRT Configuration + +MRT tools require an **API key** and **project**. The **environment** is optional (for deployments). + +| Setting | Flag | Env Variable | Fallback | +|---------|------|--------------|----------| +| API key | `--api-key` | `SFCC_MRT_API_KEY` | `~/.mobify` | +| Project | `--project` | `SFCC_MRT_PROJECT` | — | +| Environment | `--environment` | `SFCC_MRT_ENVIRONMENT` | — | + +> Priority: Flag > Env variable > `~/.mobify` file + +**Example:** + +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": [ + "--toolsets", "MRT", + "--project", "my-project", + "--environment", "staging", + "--api-key", "your-api-key" + ] + } + } +} +``` + +Or use environment variables instead of flags: + +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": ["--toolsets", "MRT"], + "env": { + "SFCC_MRT_API_KEY": "your-api-key", + "SFCC_MRT_PROJECT": "my-project", + "SFCC_MRT_ENVIRONMENT": "staging" + } + } + } +} +``` + +> **Note:** Make sure the script is executable: `chmod +x /path/to/packages/b2c-dx-mcp/bin/dev.js` + +#### Environment-Specific Config + +If you have a `~/.mobify` file from the `b2c` CLI, the MCP server will use it as a fallback for API key: + +```json +{ + "api_key": "your-api-key" +} +``` + +For non-production environments, use `--cloud-origin` to select an environment-specific config file: + +| `--cloud-origin` | Config File | +|------------------|-------------| +| (not set) | `~/.mobify` | +| `https://cloud-staging.mobify.com` | `~/.mobify--cloud-staging.mobify.com` | +| `https://cloud-dev.mobify.com` | `~/.mobify--cloud-dev.mobify.com` | + +#### B2C Instance Config (dw.json) + +Tools that interact with B2C Commerce instances (e.g., `cartridge_deploy`, SCAPI tools) require instance credentials. + +**Authentication Methods:** + +| Method | Credentials | Used By | +|--------|-------------|---------| +| **Basic auth** | `--username` + `--password` | WebDAV tools (`cartridge_deploy`) | +| **OAuth** | `--client-id` + `--client-secret` | OCAPI tools, SCAPI tools | + +> **Recommendation:** Use Basic auth (username/password) for WebDAV tools like `cartridge_deploy`. OAuth credentials (client-id/client-secret) are required for OCAPI/SCAPI tools. If you need both WebDAV and OCAPI tools, configure all four credentials. **Priority order** (highest to lowest): -1. Environment variables (`SFCC_*`) — includes `.env` file if present (shell env vars override `.env`) -2. `dw.json` file (auto-discovered or via `--config`) +1. Flags (`--server`, `--username`, `--password`, `--client-id`, `--client-secret`) +2. Environment variables (`SFCC_*`) +3. `dw.json` file (via `--config` flag or auto-discovery) -#### Option 1: Environment Variables +**Option A: Flags with Basic auth (for WebDAV tools like cartridge_deploy)** + +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": [ + "--toolsets", "CARTRIDGES", + "--server", "your-sandbox.demandware.net", + "--username", "your.username", + "--password", "your-access-key" + ] + } + } +} +``` -Set environment variables directly or create a `.env` file in your project root: +**Option B: Flags with OAuth (for OCAPI/SCAPI tools, or WebDAV fallback)** -```bash -# .env file or shell exports -SFCC_HOSTNAME="your-sandbox.demandware.net" -SFCC_USERNAME="your.username" -SFCC_PASSWORD="your-access-key" -SFCC_CLIENT_ID="your-client-id" -SFCC_CLIENT_SECRET="your-client-secret" -SFCC_CODE_VERSION="version1" +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": [ + "--toolsets", "SCAPI", + "--server", "your-sandbox.demandware.net", + "--client-id", "your-client-id", + "--client-secret", "your-client-secret" + ] + } + } +} ``` -#### Option 2: dw.json File +**Option C: Environment variables (all credentials)** + +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": ["--toolsets", "CARTRIDGES"], + "env": { + "SFCC_SERVER": "your-sandbox.demandware.net", + "SFCC_USERNAME": "your.username", + "SFCC_PASSWORD": "your-access-key", + "SFCC_CLIENT_ID": "your-client-id", + "SFCC_CLIENT_SECRET": "your-client-secret", + "SFCC_CODE_VERSION": "version1" + } + } + } +} +``` + +**Option D: dw.json with explicit path** + +```json +{ + "mcpServers": { + "b2c-dx": { + "command": "/path/to/packages/b2c-dx-mcp/bin/dev.js", + "args": ["--toolsets", "CARTRIDGES", "--config", "/path/to/dw.json"] + } + } +} +``` + +**Option E: dw.json with auto-discovery** + +When `--config` is not provided, the MCP server searches upward from `~/` for a `dw.json` file. -Create a `dw.json` file in your project root (auto-discovered by searching upward from current working directory): +> **Note:** Auto-discovery starts from the home directory, so it won't find project-level `dw.json` files. Use `--config` with an explicit path instead. ```json { @@ -265,7 +455,7 @@ Create a `dw.json` file in your project root (auto-discovered by searching upwar } ``` -> **Note:** Environment variables take precedence over `dw.json` values. +> **Note:** Flags override environment variables, and environment variables override `dw.json`. You can mix sources (e.g., secrets via env vars, other settings via dw.json). ## License diff --git a/packages/b2c-dx-mcp/bin/run.cmd b/packages/b2c-dx-mcp/bin/run.cmd index 05e479b..968fc30 100644 --- a/packages/b2c-dx-mcp/bin/run.cmd +++ b/packages/b2c-dx-mcp/bin/run.cmd @@ -1,4 +1,3 @@ @echo off node "%~dp0\run" %* - diff --git a/packages/b2c-dx-mcp/bin/run.js b/packages/b2c-dx-mcp/bin/run.js old mode 100644 new mode 100755 index b3cfcc7..48bbdbc --- a/packages/b2c-dx-mcp/bin/run.js +++ b/packages/b2c-dx-mcp/bin/run.js @@ -13,8 +13,6 @@ * - Uses compiled JavaScript from dist/ * - Loads .env file if present for local configuration * - * Run directly: ./bin/run.js mcp --toolsets all - * Or with node: node bin/run.js mcp --toolsets all */ // Load .env file if present (Node.js native support) diff --git a/packages/b2c-dx-mcp/src/commands/mcp.ts b/packages/b2c-dx-mcp/src/commands/mcp.ts index 69027e5..de846b4 100644 --- a/packages/b2c-dx-mcp/src/commands/mcp.ts +++ b/packages/b2c-dx-mcp/src/commands/mcp.ts @@ -20,6 +20,24 @@ * | `--tools` | Comma-separated individual tools to enable (case-insensitive) | * | `--allow-non-ga-tools` | Enable experimental/non-GA tools | * + * ### MRT Flags (from MrtCommand.baseFlags) + * | Flag | Env Variable | Description | + * |------|--------------|-------------| + * | `--api-key` | `SFCC_MRT_API_KEY` | MRT API key for Managed Runtime operations | + * | `--project` | `SFCC_MRT_PROJECT` | MRT project slug (required for MRT tools) | + * | `--environment` | `SFCC_MRT_ENVIRONMENT` | MRT environment (e.g., staging, production) | + * | `--cloud-origin` | `SFCC_MRT_CLOUD_ORIGIN` | MRT cloud origin URL for environment-specific ~/.mobify config | + * + * ### B2C Instance Flags (from InstanceCommand.baseFlags) + * | Flag | Env Variable | Description | + * |------|--------------|-------------| + * | `--server` | `SFCC_SERVER` | B2C instance hostname | + * | `--code-version` | `SFCC_CODE_VERSION` | Code version for deployments | + * | `--username` | `SFCC_USERNAME` | Username for Basic auth (WebDAV) | + * | `--password` | `SFCC_PASSWORD` | Password/access key for Basic auth | + * | `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID | + * | `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret | + * * ### Global Flags (inherited from BaseCommand) * | Flag | Description | * |------|-------------| @@ -30,50 +48,78 @@ * | `--json` | Output logs as JSON lines | * | `--lang` | Language for messages | * - * ## Configuration Priority + * ## Configuration + * + * Different tools require different configuration: + * - **MRT tools** (e.g., `mrt_bundle_push`) → MRT flags (--project, --api-key) + * - **B2C instance tools** (e.g., `cartridge_deploy`, SCAPI) → Instance flags or dw.json + * - **Local tools** (e.g., scaffolding) → None * - * 1. Environment variables (SFCC_*) - highest priority, override dw.json - * 2. dw.json file (explicit path via --config, or auto-discovered) - * 3. Auto-discovery (searches upward from cwd) + * ### B2C Instance Configuration + * Priority (highest to lowest): + * 1. Flags (`--server`, `--username`, `--password`, `--client-id`, `--client-secret`, `--code-version`) + * 2. Environment variables (via oclif flag env support) + * 3. dw.json file (via `--config` flag or auto-discovered) + * + * ### MRT API Key + * Priority (highest to lowest): + * 1. `--api-key` flag + * 2. `SFCC_MRT_API_KEY` environment variable + * 3. `~/.mobify` config file (or `~/.mobify--[hostname]` if `--cloud-origin` is set) * * ## Toolset Validation * * - Invalid toolsets are ignored with a warning (server still starts) * - If all toolsets are invalid, auto-discovery kicks in * - * @example Start with all toolsets - * ```bash - * b2c-dx-mcp --toolsets all + * @example mcp.json - All toolsets + * ```json + * { "args": ["--toolsets", "all", "--allow-non-ga-tools"] } + * ``` + * + * @example mcp.json - Specific toolsets + * ```json + * { "args": ["--toolsets", "CARTRIDGES,MRT", "--allow-non-ga-tools"] } * ``` * - * @example Start with specific toolsets - * ```bash - * b2c-dx-mcp --toolsets CARTRIDGES,JOBS + * @example mcp.json - MRT tools with project, environment, and API key + * ```json + * { + * "args": ["--toolsets", "MRT", "--project", "my-project", "--environment", "staging", "--allow-non-ga-tools"], + * "env": { "SFCC_MRT_API_KEY": "your-api-key" } + * } * ``` * - * @example Start with specific individual tools - * ```bash - * b2c-dx-mcp --tools cartridge_deploy,cartridge_activate + * @example mcp.json - MRT tools with staging cloud origin (uses ~/.mobify--cloud-staging.mobify.com) + * ```json + * { "args": ["--toolsets", "MRT", "--project", "my-project", "--cloud-origin", "https://cloud-staging.mobify.com", "--allow-non-ga-tools"] } * ``` * - * @example Combine toolsets and specific tools - * ```bash - * b2c-dx-mcp --toolsets SCAPI --tools cartridge_deploy + * @example mcp.json - Cartridge tools with dw.json config + * ```json + * { "args": ["--toolsets", "CARTRIDGES", "--config", "/path/to/dw.json", "--allow-non-ga-tools"] } * ``` * - * @example Specify config file location - * ```bash - * b2c-dx-mcp --toolsets all --config /path/to/dw.json + * @example mcp.json - Cartridge tools with env vars + * ```json + * { + * "args": ["--toolsets", "CARTRIDGES", "--allow-non-ga-tools"], + * "env": { + * "SFCC_HOSTNAME": "your-sandbox.demandware.net", + * "SFCC_CLIENT_ID": "your-client-id", + * "SFCC_CLIENT_SECRET": "your-client-secret" + * } + * } * ``` * - * @example Enable debug logging - * ```bash - * b2c-dx-mcp --toolsets all --debug + * @example mcp.json - Enable debug logging + * ```json + * { "args": ["--toolsets", "all", "--allow-non-ga-tools", "--debug"] } * ``` */ import {Flags} from '@oclif/core'; -import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {BaseCommand, MrtCommand, InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; import {B2CDxMcpServer} from '../server.js'; import {Services} from '../services.js'; @@ -95,16 +141,39 @@ export default class McpServerCommand extends BaseCommand <%= command.id %> --toolsets all', - '<%= config.bin %> <%= command.id %> --toolsets STOREFRONTNEXT,MRT', - '<%= config.bin %> <%= command.id %> --tools sfnext_deploy,mrt_bundle_push', - '<%= config.bin %> <%= command.id %> --toolsets STOREFRONTNEXT --tools sfnext_deploy', - '<%= config.bin %> <%= command.id %> --toolsets MRT --config /path/to/dw.json', - '<%= config.bin %> <%= command.id %> --toolsets all --debug', + { + description: 'All toolsets', + command: '<%= config.bin %> --toolsets all --allow-non-ga-tools', + }, + { + description: 'MRT tools with project and API key', + command: '<%= config.bin %> --toolsets MRT --project my-project --api-key your-api-key --allow-non-ga-tools', + }, + { + description: 'MRT tools with project, environment, and API key', + command: + '<%= config.bin %> --toolsets MRT --project my-project --environment staging --api-key your-api-key --allow-non-ga-tools', + }, + { + description: 'Cartridge tools with explicit config', + command: '<%= config.bin %> --toolsets CARTRIDGES --config /path/to/dw.json --allow-non-ga-tools', + }, + { + description: 'Debug logging', + command: '<%= config.bin %> --toolsets all --allow-non-ga-tools --debug', + }, ]; static flags = { - // Toolset selection flags + // Inherit MRT flags (api-key, cloud-origin, project, environment) + // Also includes BaseCommand flags (config, debug, log-level, etc.) - safe to re-spread + ...MrtCommand.baseFlags, + + // Inherit Instance flags (server, code-version, username, password, client-id, client-secret) + // These provide B2C instance configuration for tools like cartridge_deploy + ...InstanceCommand.baseFlags, + + // MCP-specific toolset selection flags toolsets: Flags.string({ description: `Toolsets to enable (comma-separated). Options: all, ${TOOLSETS.join(', ')}`, env: 'SFCC_TOOLSETS', @@ -131,7 +200,7 @@ export default class McpServerCommand extends BaseCommand [tool.name, tool])); const existingToolNames = new Set(allToolsByName.keys()); + const logger = getLogger(); + // Warn about invalid --tools names (but continue with valid ones) const invalidTools = individualTools.filter((name) => !existingToolNames.has(name)); if (invalidTools.length > 0) { - console.error( - `⚠️ Ignoring invalid tool name(s): "${invalidTools.join('", "')}"${EOL}` + - ` Valid tools: ${[...existingToolNames].join(', ')}`, + logger.warn( + {invalidTools, validTools: [...existingToolNames]}, + `Ignoring invalid tool name(s): "${invalidTools.join('", "')}"`, ); } - // Validate --toolsets names + // Warn about invalid --toolsets names (but continue with valid ones) const invalidToolsets = toolsets.filter( (t) => !VALID_TOOLSET_NAMES.includes(t as (typeof VALID_TOOLSET_NAMES)[number]), ); if (invalidToolsets.length > 0) { - console.error( - `⚠️ Ignoring invalid toolset(s): "${invalidToolsets.join('", "')}"\n` + - ` Valid toolsets: ${VALID_TOOLSET_NAMES.join(', ')}`, + logger.warn( + {invalidToolsets, validToolsets: VALID_TOOLSET_NAMES}, + `Ignoring invalid toolset(s): "${invalidToolsets.join('", "')}"`, ); } diff --git a/packages/b2c-dx-mcp/src/server.ts b/packages/b2c-dx-mcp/src/server.ts index 0f94d34..1503a0d 100644 --- a/packages/b2c-dx-mcp/src/server.ts +++ b/packages/b2c-dx-mcp/src/server.ts @@ -14,6 +14,7 @@ import type { import type {ServerOptions} from '@modelcontextprotocol/sdk/server/index.js'; import type {RequestHandlerExtra} from '@modelcontextprotocol/sdk/shared/protocol.js'; import type {Transport} from '@modelcontextprotocol/sdk/shared/transport.js'; +import type {ZodRawShape} from 'zod'; /** * Extended server options. @@ -60,24 +61,24 @@ export class B2CDxMcpServer extends McpServer { public addTool( name: string, description: string, - inputSchema: Record, + inputSchema: ZodRawShape, handler: (args: Record) => Promise, ): void { const wrappedHandler = async ( args: Record, _extra: RequestHandlerExtra, ): Promise => { - const startTime = Date.now(); + // TODO: Telemetry - Track timing and send TOOL_CALLED event + // const startTime = Date.now(); const result = await handler(args); - // TODO: Telemetry - Send TOOL_CALLED event with { name, runtimeMs, isError: result.isError } - const runtimeMs = Date.now() - startTime; - void runtimeMs; // Silence unused variable until telemetry is implemented + // const runtimeMs = Date.now() - startTime; + // telemetry.sendEvent('TOOL_CALLED', { name, runtimeMs, isError: result.isError }); return result; }; - // Use the base server.tool method which handles the registration - this.tool(name, description, inputSchema, wrappedHandler); + // Use the new registerTool API (tool() is deprecated) + this.registerTool(name, {description, inputSchema}, wrappedHandler); } /** diff --git a/packages/b2c-dx-mcp/src/services.ts b/packages/b2c-dx-mcp/src/services.ts index 7fe9914..29c0c10 100644 --- a/packages/b2c-dx-mcp/src/services.ts +++ b/packages/b2c-dx-mcp/src/services.ts @@ -4,26 +4,231 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +/** + * Services module providing dependency injection for MCP tools. + * + * The {@link Services} class is the central dependency container for tools, + * providing: + * - Pre-resolved B2CInstance for WebDAV/OCAPI operations + * - Pre-resolved MRT authentication for Managed Runtime operations + * - MRT project/environment configuration + * - File system utilities for local operations + * + * ## Creating Services + * + * Use {@link Services.create} to create an instance with all configuration + * resolved eagerly at startup: + * + * ```typescript + * const services = Services.create({ + * b2cInstance: { + * configPath: flags.config, + * hostname: flags.server, + * }, + * mrt: { + * apiKey: flags['api-key'], + * project: flags.project, + * }, + * }); + * ``` + * + * ## Resolution Pattern + * + * Both B2CInstance and MRT auth are resolved once at server startup (not on each tool call). + * This provides fail-fast behavior and consistent performance. + * + * **B2C Instance** (for WebDAV/OCAPI tools): + * - Flags (highest priority) merged with dw.json (auto-discovered or via --config) + * + * **MRT Auth** (for Managed Runtime tools): + * 1. `--api-key` flag (oclif also checks `SFCC_MRT_API_KEY` env var) + * 2. `~/.mobify` config file (or `~/.mobify--[hostname]` if `--cloud-origin` is set) + * + * @module services + */ + import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import {B2CInstance, type B2CInstanceOptions} from '@salesforce/b2c-tooling-sdk'; +import {ApiKeyStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import {loadMobifyConfig} from '@salesforce/b2c-tooling-sdk/cli'; + +/** + * MRT (Managed Runtime) configuration. + * Groups auth, project, and environment settings. + */ +export interface MrtConfig { + /** Pre-resolved auth strategy for MRT API operations */ + auth?: AuthStrategy; + /** MRT project slug from --project flag or SFCC_MRT_PROJECT env var */ + project?: string; + /** MRT environment from --environment flag or SFCC_MRT_ENVIRONMENT env var */ + environment?: string; +} + +/** + * MRT input options for Services.create(). + */ +export interface MrtCreateOptions { + /** MRT API key from --api-key flag */ + apiKey?: string; + /** MRT cloud origin URL for environment-specific config files */ + cloudOrigin?: string; + /** MRT project slug from --project flag */ + project?: string; + /** MRT environment from --environment flag */ + environment?: string; +} + +/** + * Options for Services.create() factory method. + */ +export interface ServicesCreateOptions { + /** B2C instance configuration (from InstanceCommand.baseFlags) */ + b2cInstance?: B2CInstanceOptions; + /** MRT configuration (from MrtCommand.baseFlags) */ + mrt?: MrtCreateOptions; +} + +/** + * Options for Services constructor (internal). + */ +export interface ServicesOptions { + /** Pre-resolved B2C instance (if configured) */ + b2cInstance?: B2CInstance; + /** Pre-resolved MRT configuration (auth, project, environment) */ + mrtConfig?: MrtConfig; +} /** * Services class that provides utilities for MCP tools. * - * Tools should use `@salesforce/b2c-tooling-sdk` for auth and config: - * - `loadDwJson({ path: services.configPath })` for configuration - * - `resolveAuthStrategy()` for authentication + * Use the static `Services.create()` factory method to create an instance + * with all configuration resolved eagerly at startup. + * + * @example + * ```typescript + * const services = Services.create({ + * b2cInstance: { hostname: flags.server }, + * mrt: { apiKey: flags['api-key'], project: flags.project }, + * }); + * + * // Access resolved config + * services.b2cInstance; // B2CInstance | undefined + * services.mrtConfig.auth; // AuthStrategy | undefined + * services.mrtConfig.project; // string | undefined + * ``` */ export class Services { /** - * Optional explicit path to config file (dw.json format). - * If undefined, SDK's `loadDwJson()` will auto-discover it. + * Pre-resolved B2C instance for WebDAV/OCAPI operations. + * Resolved once at server startup from InstanceCommand flags and dw.json. + * Undefined if no B2C instance configuration was available. + */ + public readonly b2cInstance?: B2CInstance; + + /** + * Pre-resolved MRT configuration (auth, project, environment). + * Resolved once at server startup from MrtCommand flags and ~/.mobify. */ - public readonly configPath?: string; + public readonly mrtConfig: MrtConfig; + + public constructor(opts: ServicesOptions = {}) { + this.b2cInstance = opts.b2cInstance; + this.mrtConfig = opts.mrtConfig ?? {}; + } + + /** + * Creates a Services instance with all configuration resolved eagerly. + * + * **MRT auth resolution** (matches CLI behavior): + * 1. `mrt.apiKey` option (from --api-key flag, which includes SFCC_MRT_API_KEY env var via oclif) + * 2. `~/.mobify` config file (or `~/.mobify--[hostname]` if `mrt.cloudOrigin` is set) + * + * **B2C instance resolution**: + * - `b2cInstance` options merged with dw.json (auto-discovered or via configPath) + * + * @param options - Configuration options + * @returns Services instance with resolved config + * + * @example + * ```typescript + * const services = Services.create({ + * b2cInstance: { configPath: flags.config, hostname: flags.server }, + * mrt: { apiKey: flags['api-key'], project: flags.project }, + * }); + * ``` + */ + public static create(options: ServicesCreateOptions = {}): Services { + // Resolve MRT config (auth from flag/env via oclif → ~/.mobify, plus project/environment) + const mrtConfig: MrtConfig = { + auth: Services.resolveMrtAuth({ + apiKey: options.mrt?.apiKey, + cloudOrigin: options.mrt?.cloudOrigin, + }), + project: options.mrt?.project, + environment: options.mrt?.environment, + }; + + // Resolve B2C instance from options (B2CInstanceOptions passed directly) + const b2cInstance = Services.resolveB2CInstance(options.b2cInstance); + + return new Services({ + b2cInstance, + mrtConfig, + }); + } + + /** + * Resolves B2C instance from available sources. + * + * Resolution merges: + * 1. Explicit flag values (highest priority) + * 2. dw.json file (via configPath or auto-discovery) + * + * @param options - Resolution options (same as B2CInstance.fromEnvironment) + * @returns B2CInstance if configured, undefined if resolution fails + */ + public static resolveB2CInstance(options: B2CInstanceOptions = {}): B2CInstance | undefined { + try { + return B2CInstance.fromEnvironment(options); + } catch { + // B2C instance resolution failed (no config available) + // This is not an error - tools that don't need B2C instance will work fine + return undefined; + } + } + + /** + * Resolves MRT auth strategy from available sources. + * + * Resolution order: + * 1. apiKey option (from --api-key flag, which includes SFCC_MRT_API_KEY env var via oclif) + * 2. ~/.mobify config file (or ~/.mobify--[hostname] if cloudOrigin is set) + * + * Note: The --api-key flag in MrtCommand.baseFlags has `env: 'SFCC_MRT_API_KEY'`, + * so oclif automatically falls back to the env var during flag parsing. + * + * @param options - Resolution options + * @param options.apiKey - MRT API key from --api-key flag (includes env var via oclif) + * @param options.cloudOrigin - MRT cloud origin URL for environment-specific config + * @returns AuthStrategy if configured, undefined otherwise + */ + public static resolveMrtAuth(options: {apiKey?: string; cloudOrigin?: string} = {}): AuthStrategy | undefined { + // 1. Check apiKey option (oclif handles --api-key flag → SFCC_MRT_API_KEY env var fallback) + if (options.apiKey?.trim()) { + return new ApiKeyStrategy(options.apiKey, 'Authorization'); + } + + // 2. Check ~/.mobify config file (or ~/.mobify--[hostname] if cloud origin specified) + const mobifyConfig = loadMobifyConfig(options.cloudOrigin); + if (mobifyConfig.apiKey) { + return new ApiKeyStrategy(mobifyConfig.apiKey, 'Authorization'); + } - public constructor(opts: {configPath?: string} = {}) { - this.configPath = opts.configPath; + return undefined; } // ============================================ diff --git a/packages/b2c-dx-mcp/src/tools/adapter.ts b/packages/b2c-dx-mcp/src/tools/adapter.ts new file mode 100644 index 0000000..e580309 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/adapter.ts @@ -0,0 +1,336 @@ +/* + * 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 + */ + +/** + * Tool Adapter for wrapping b2c-tooling-sdk functions as MCP tools. + * + * This module provides utilities for creating standardized MCP tools that: + * - Validate input using Zod schemas + * - Inject pre-resolved B2CInstance for WebDAV/OCAPI operations (requiresInstance) + * - Inject pre-resolved MRT auth for MRT API operations (requiresMrtAuth) + * - Format output consistently (textResult, jsonResult, errorResult) + * + * ## Configuration Resolution + * + * Both B2C instance and MRT auth are resolved once at server startup via + * {@link Services.create} and reused for all tool calls: + * + * - **B2CInstance**: Resolved from flags + dw.json. Available when `requiresInstance: true`. + * - **MRT Auth**: Resolved from --api-key → SFCC_MRT_API_KEY → ~/.mobify. Available when `requiresMrtAuth: true`. + * + * This "resolve eagerly at startup" pattern provides: + * - Fail-fast behavior (configuration errors surface at startup) + * - Consistent mental model (both resolved the same way) + * - Better performance (no resolution on each tool call) + * + * @module tools/adapter + * + * @example B2C Instance tool (WebDAV/OCAPI) + * ```typescript + * const myTool = createToolAdapter({ + * name: 'my_tool', + * description: 'Does something useful', + * toolsets: ['CARTRIDGES'], + * requiresInstance: true, + * inputSchema: { + * cartridgeName: z.string().describe('Name of the cartridge'), + * }, + * execute: async (args, { b2cInstance }) => { + * const result = await b2cInstance.webdav.get(`Cartridges/${args.cartridgeName}`); + * return result; + * }, + * formatOutput: (output) => textResult(`Fetched: ${output}`), + * }, services); + * ``` + * + * @example MRT tool (MRT API) + * ```typescript + * // Services created with auth resolved at startup + * const services = Services.create({ + * mrtApiKey: flags['api-key'], + * mrtCloudOrigin: flags['cloud-origin'], + * }); + * + * const mrtTool = createToolAdapter({ + * name: 'mrt_bundle_push', + * description: 'Push bundle to MRT', + * toolsets: ['MRT'], + * requiresMrtAuth: true, + * inputSchema: { + * projectSlug: z.string().describe('MRT project slug'), + * }, + * execute: async (args, { mrtAuth }) => { + * const result = await pushBundle({ projectSlug: args.projectSlug }, mrtAuth); + * return result; + * }, + * formatOutput: (output) => jsonResult(output), + * }, services); + * ``` + */ + +import {z, type ZodRawShape, type ZodObject, type ZodType} from 'zod'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk'; +import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import type {McpTool, ToolResult, Toolset} from '../utils/index.js'; +import type {Services} from '../services.js'; + +/** + * Context provided to tool execute functions. + * Contains the B2CInstance and/or MRT config based on tool requirements. + */ +export interface ToolExecutionContext { + /** + * B2CInstance configured with authentication from dw.json and flags. + * Provides access to typed API clients (webdav, ocapi). + * Only available when requiresInstance is true. + */ + b2cInstance?: B2CInstance; + + /** + * MRT configuration (auth, project, environment). + * Pre-resolved at server startup. + * Only populated when requiresMrtAuth is true. + */ + mrtConfig?: { + /** Auth strategy for MRT API operations */ + auth: AuthStrategy; + /** MRT project slug */ + project?: string; + /** MRT environment */ + environment?: string; + }; + + /** + * Services instance for file system access and other utilities. + */ + services: Services; +} + +/** + * Options for creating a tool adapter. + * + * @template TInput - The validated input type (inferred from inputSchema) + * @template TOutput - The output type from the execute function + */ +export interface ToolAdapterOptions { + /** Tool name (used in MCP protocol) */ + name: string; + + /** Human-readable description */ + description: string; + + /** Zod schema for input validation */ + inputSchema: ZodRawShape; + + /** Toolsets this tool belongs to */ + toolsets: Toolset[]; + + /** Whether this tool is GA (generally available). Defaults to true. */ + isGA?: boolean; + + /** + * Whether this tool requires a B2CInstance. + * Set to false for tools that don't need B2C instance connectivity (e.g., local scaffolding tools). + * Defaults to false. + */ + requiresInstance?: boolean; + + /** + * Whether this tool requires MRT API authentication. + * When true, creates an ApiKeyStrategy from SFCC_MRT_API_KEY environment variable. + * Defaults to false. + */ + requiresMrtAuth?: boolean; + + /** + * Execute function that performs the tool's operation. + * Receives validated input and a context with B2CInstance and/or auth based on requirements. + */ + execute: (args: TInput, context: ToolExecutionContext) => Promise; + + /** + * Format function that converts the execute output to a ToolResult. + * Use textResult(), jsonResult(), or errorResult() helpers. + */ + formatOutput: (output: TOutput) => ToolResult; +} + +/** + * Creates a text-only success result. + * + * @param text - The text content to return + * @returns A ToolResult with text content + * + * @example + * ```typescript + * return textResult('Operation completed successfully'); + * ``` + */ +export function textResult(text: string): ToolResult { + return { + content: [{type: 'text', text}], + }; +} + +/** + * Creates an error result. + * + * @param message - The error message + * @returns A ToolResult marked as an error + * + * @example + * ```typescript + * return errorResult('Failed to connect to instance'); + * ``` + */ +export function errorResult(message: string): ToolResult { + return { + content: [{type: 'text', text: message}], + isError: true, + }; +} + +/** + * Creates a JSON result with formatted output. + * + * @param data - The data to serialize as JSON + * @param indent - Number of spaces for indentation (default: 2) + * @returns A ToolResult with JSON-formatted text content + * + * @example + * ```typescript + * return jsonResult({ status: 'success', items: ['a', 'b', 'c'] }); + * ``` + */ +export function jsonResult(data: unknown, indent = 2): ToolResult { + return { + content: [{type: 'text', text: JSON.stringify(data, null, indent)}], + }; +} + +/** + * Formats Zod validation errors into a human-readable string. + * + * @param error - The Zod error object + * @returns Formatted error message + */ +function formatZodErrors(error: z.ZodError): string { + return error.errors.map((e) => `${e.path.join('.') || 'input'}: ${e.message}`).join('; '); +} + +/** + * Creates an MCP tool from a b2c-tooling function. + * + * This adapter provides: + * - Input validation via Zod schemas + * - B2CInstance creation from dw.json with environment variable overrides + * - Consistent error handling + * - Output formatting utilities + * + * @template TInput - The validated input type (inferred from inputSchema) + * @template TOutput - The output type from the execute function + * @param options - Tool adapter configuration + * @param services - Services instance for dependency injection + * @returns An McpTool ready for registration + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { createToolAdapter, jsonResult, errorResult } from './adapter.js'; + * + * const listCodeVersionsTool = createToolAdapter({ + * name: 'code_version_list', + * description: 'List all code versions on the instance', + * toolsets: ['CARTRIDGES'], + * inputSchema: {}, + * execute: async (_args, { instance }) => { + * const result = await instance.ocapi.GET('/code_versions', {}); + * if (result.error) throw new Error(result.error.message); + * return result.data; + * }, + * formatOutput: (data) => jsonResult(data), + * }, services); + * ``` + */ +export function createToolAdapter( + options: ToolAdapterOptions, + services: Services, +): McpTool { + const { + name, + description, + inputSchema, + toolsets, + isGA = true, + requiresInstance = false, + requiresMrtAuth = false, + execute, + formatOutput, + } = options; + + // Create Zod schema from inputSchema definition + const zodSchema = z.object(inputSchema) as ZodObject; + + return { + name, + description, + inputSchema, + toolsets, + isGA, + + async handler(rawArgs: Record): Promise { + // 1. Validate input with Zod + const parseResult = zodSchema.safeParse(rawArgs); + if (!parseResult.success) { + return errorResult(`Invalid input: ${formatZodErrors(parseResult.error)}`); + } + const args = parseResult.data as TInput; + + try { + // 2. Get B2CInstance if required (pre-resolved at startup) + let b2cInstance: B2CInstance | undefined; + if (requiresInstance) { + if (!services.b2cInstance) { + return errorResult( + 'B2C instance error: Instance configuration required. Provide --server flag, set SFCC_SERVER environment variable, or configure dw.json', + ); + } + b2cInstance = services.b2cInstance; + } + + // 3. Get MRT config if required (pre-resolved at startup) + let mrtConfig: ToolExecutionContext['mrtConfig']; + if (requiresMrtAuth) { + if (!services.mrtConfig.auth) { + return errorResult( + 'MRT auth error: MRT API key required. Provide --api-key, set SFCC_MRT_API_KEY environment variable, or configure ~/.mobify', + ); + } + mrtConfig = { + auth: services.mrtConfig.auth, + project: services.mrtConfig.project, + environment: services.mrtConfig.environment, + }; + } + + // 4. Execute the operation + const context: ToolExecutionContext = { + b2cInstance, + mrtConfig, + services, + }; + const output = await execute(args, context); + + // 5. Format output + return formatOutput(output); + } catch (error) { + // Handle execution errors + const message = error instanceof Error ? error.message : String(error); + return errorResult(`Execution error: ${message}`); + } + }, + }; +} diff --git a/packages/b2c-dx-mcp/src/tools/cartridges/index.ts b/packages/b2c-dx-mcp/src/tools/cartridges/index.ts index 6df104b..675d059 100644 --- a/packages/b2c-dx-mcp/src/tools/cartridges/index.ts +++ b/packages/b2c-dx-mcp/src/tools/cartridges/index.ts @@ -9,49 +9,110 @@ * * This toolset provides MCP tools for cartridge and code version management. * + * > ⚠️ **PLACEHOLDER - ACTIVE DEVELOPMENT** + * > This tool is a placeholder implementation that returns mock responses. + * > Actual implementation is coming soon. Use `--allow-non-ga-tools` flag to enable. + * * @module tools/cartridges */ import {z} from 'zod'; import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getLogger} from '@salesforce/b2c-tooling-sdk/logging'; /** - * Creates a placeholder tool that logs and returns a mock response. + * Input type for cartridge_deploy tool. */ -function createPlaceholderTool(name: string, description: string, _services: Services): McpTool { - return { - name, - description: `[PLACEHOLDER] ${description}`, - inputSchema: { - message: z.string().optional().describe('Optional message to echo'), - }, - toolsets: ['CARTRIDGES'], - isGA: false, - async handler(args) { - const timestamp = new Date().toISOString(); - console.error(`[${timestamp}] CARTRIDGES tool '${name}' called with:`, args); +interface CartridgeDeployInput { + /** Path to directory containing cartridges (default: current directory) */ + directory?: string; + /** Only deploy these cartridge names */ + cartridges?: string[]; + /** Exclude these cartridge names */ + exclude?: string[]; + /** Delete existing cartridges before upload */ + delete?: boolean; + /** Reload code version after deploy */ + reload?: boolean; +} + +/** + * Output type for cartridge_deploy tool (placeholder). + */ +interface CartridgeDeployOutput { + tool: string; + status: string; + message: string; + input: CartridgeDeployInput; + timestamp: string; +} + +/** + * Creates the cartridge_deploy tool. + * + * Deploys cartridges to a B2C Commerce instance via WebDAV: + * 1. Finds cartridges by `.project` files in the specified directory + * 2. Creates a zip archive of all cartridge directories + * 3. Uploads the zip to WebDAV and triggers server-side unzip + * 4. Optionally deletes existing cartridges before upload + * 5. Optionally reloads the code version after deploy + * + * @param services - MCP services + * @returns The cartridge_deploy tool + */ +function createCartridgeDeployTool(services: Services): McpTool { + return createToolAdapter( + { + name: 'cartridge_deploy', + description: '[PLACEHOLDER] Deploy cartridges to a B2C Commerce instance', + toolsets: ['CARTRIDGES'], + isGA: false, + requiresInstance: true, + inputSchema: { + directory: z + .string() + .optional() + .describe('Path to directory containing cartridges (default: current directory)'), + cartridges: z.array(z.string()).optional().describe('Only deploy these cartridge names'), + exclude: z.array(z.string()).optional().describe('Exclude these cartridge names'), + delete: z.boolean().optional().describe('Delete existing cartridges before upload'), + reload: z.boolean().optional().describe('Reload code version after deploy'), + }, + async execute(args, context) { + // Placeholder implementation + const timestamp = new Date().toISOString(); + + // TODO: Remove this log when implementing + const logger = getLogger(); + logger.debug({context}, 'cartridge_deploy context'); + + // TODO: When implementing, use context.b2cInstance: + // import { findAndDeployCartridges } from '@salesforce/b2c-tooling-sdk/operations/code'; + // + // const directory = args.directory || '.'; + // const result = await findAndDeployCartridges(context.b2cInstance!, directory, { + // include: args.cartridges, + // exclude: args.exclude, + // delete: args.delete, + // reload: args.reload, + // }); + // return result; - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - tool: name, - status: 'placeholder', - message: `This is a placeholder implementation for '${name}'. The actual implementation is coming soon.`, - input: args, - timestamp, - }, - null, - 2, - ), - }, - ], - }; + return { + tool: 'cartridge_deploy', + status: 'placeholder', + message: + "This is a placeholder implementation for 'cartridge_deploy'. The actual implementation is coming soon.", + input: args, + timestamp, + }; + }, + formatOutput: (output) => jsonResult(output), }, - }; + services, + ); } /** @@ -61,5 +122,5 @@ function createPlaceholderTool(name: string, description: string, _services: Ser * @returns Array of MCP tools */ export function createCartridgesTools(services: Services): McpTool[] { - return [createPlaceholderTool('cartridge_deploy', 'Deploy cartridges to a B2C Commerce instance', services)]; + return [createCartridgeDeployTool(services)]; } diff --git a/packages/b2c-dx-mcp/src/tools/index.ts b/packages/b2c-dx-mcp/src/tools/index.ts index 8ca8b7d..f8fd197 100644 --- a/packages/b2c-dx-mcp/src/tools/index.ts +++ b/packages/b2c-dx-mcp/src/tools/index.ts @@ -10,9 +10,16 @@ * This module exports all available tools and utilities. * Tools use the @salesforce/b2c-tooling-sdk operations layer directly. * + * > ⚠️ **PLACEHOLDER - ACTIVE DEVELOPMENT** + * > Tools are currently placeholder implementations that return mock responses. + * > Actual implementations are coming soon. Use `--allow-non-ga-tools` flag to enable. + * * @module tools */ +// Tool adapter utilities +export * from './adapter.js'; + // Toolset exports export * from './cartridges/index.js'; export * from './mrt/index.js'; diff --git a/packages/b2c-dx-mcp/src/tools/mrt/index.ts b/packages/b2c-dx-mcp/src/tools/mrt/index.ts index 22d003e..bfe44f1 100644 --- a/packages/b2c-dx-mcp/src/tools/mrt/index.ts +++ b/packages/b2c-dx-mcp/src/tools/mrt/index.ts @@ -9,58 +9,127 @@ * * This toolset provides MCP tools for Managed Runtime operations. * + * > ⚠️ **PLACEHOLDER - ACTIVE DEVELOPMENT** + * > This tool is a placeholder implementation that returns mock responses. + * > Actual implementation is coming soon. Use `--allow-non-ga-tools` flag to enable. + * * @module tools/mrt */ import {z} from 'zod'; import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getLogger} from '@salesforce/b2c-tooling-sdk/logging'; + +/** + * Input type for mrt_bundle_push tool. + */ +interface MrtBundlePushInput { + /** Path to build directory (default: ./build) */ + buildDirectory?: string; + /** Deployment message */ + message?: string; + /** Glob patterns for server-only files (default: ssr.js,ssr.mjs,server/**\/*) */ + ssrOnly?: string; + /** Glob patterns for shared files (default: static/**\/*,client/**\/*) */ + ssrShared?: string; +} + +/** + * Output type for mrt_bundle_push tool. + */ +interface MrtBundlePushOutput { + tool: string; + status: string; + message: string; + input: MrtBundlePushInput; + timestamp: string; +} /** * Creates the mrt_bundle_push tool. * - * This tool deploys a bundle to Managed Runtime and is shared across - * MRT, PWAV3, and STOREFRONTNEXT toolsets. + * Creates a bundle from a pre-built PWA Kit project and pushes it to + * Managed Runtime (MRT). Optionally deploys to a target environment after push. + * Expects the project to already be built (e.g., `npm run build` completed). + * Shared across MRT, PWAV3, and STOREFRONTNEXT toolsets. * - * @param _services - MCP services (unused in placeholder) + * @param services - MCP services * @returns The mrt_bundle_push tool */ -function createMrtBundlePushTool(_services: Services): McpTool { - return { - name: 'mrt_bundle_push', - description: '[PLACEHOLDER] Build, push bundle (optionally deploy)', - inputSchema: { - projectId: z.string().optional().describe('MRT project ID'), - environmentId: z.string().optional().describe('Target environment ID'), - message: z.string().optional().describe('Deployment message'), - }, - toolsets: ['MRT', 'PWAV3', 'STOREFRONTNEXT'], - isGA: false, - async handler(args) { - const timestamp = new Date().toISOString(); - console.error(`[${timestamp}] mrt_bundle_push called with:`, args); +function createMrtBundlePushTool(services: Services): McpTool { + return createToolAdapter( + { + name: 'mrt_bundle_push', + description: + '[PLACEHOLDER] Bundle a pre-built PWA Kit project and push to Managed Runtime. Optionally deploy to a target environment.', + toolsets: ['MRT', 'PWAV3', 'STOREFRONTNEXT'], + isGA: false, + // MRT operations use ApiKeyStrategy from SFCC_MRT_API_KEY or ~/.mobify + requiresMrtAuth: true, + inputSchema: { + buildDirectory: z.string().optional().describe('Path to build directory (default: ./build)'), + message: z.string().optional().describe('Deployment message'), + ssrOnly: z + .string() + .optional() + .describe('Glob patterns for server-only files, comma-separated (default: ssr.js,ssr.mjs,server/**/*)'), + ssrShared: z + .string() + .optional() + .describe('Glob patterns for shared files, comma-separated (default: static/**/*,client/**/*)'), + }, + async execute(args, context) { + // Get project from --project flag (required) + const project = context.mrtConfig?.project; + if (!project) { + throw new Error( + 'MRT project error: Project is required. Provide --project flag or set SFCC_MRT_PROJECT environment variable.', + ); + } + + // Get environment from --environment flag (optional) + const environment = context.mrtConfig?.environment; + + // Placeholder implementation + const timestamp = new Date().toISOString(); + + // TODO: Remove this log when implementing + const logger = getLogger(); + logger.debug({mrtConfig: context.mrtConfig, project, environment}, 'mrt_bundle_push context'); + + // TODO: When implementing, use context.mrtConfig.auth: + // + // import { pushBundle } from '@salesforce/b2c-tooling-sdk/operations/mrt'; + // + // // Parse comma-separated glob patterns (same as CLI defaults) + // const ssrOnly = (args.ssrOnly || 'ssr.js,ssr.mjs,server/**/*').split(',').map(s => s.trim()); + // const ssrShared = (args.ssrShared || 'static/**/*,client/**/*').split(',').map(s => s.trim()); + // + // const result = await pushBundle({ + // project, + // buildDirectory: args.buildDirectory || './build', + // ssrOnly, // files that run only on SSR server (never sent to browser) + // ssrShared, // files served from CDN and also available to SSR + // message: args.message, + // environment, + // }, context.mrtConfig!.auth); + // return result; - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - tool: 'mrt_bundle_push', - status: 'placeholder', - message: - "This is a placeholder implementation for 'mrt_bundle_push'. The actual implementation is coming soon.", - input: args, - timestamp, - }, - null, - 2, - ), - }, - ], - }; + return { + tool: 'mrt_bundle_push', + status: 'placeholder', + message: + "This is a placeholder implementation for 'mrt_bundle_push'. The actual implementation is coming soon.", + input: {...args, project, environment}, + timestamp, + }; + }, + formatOutput: (output) => jsonResult(output), }, - }; + services, + ); } /** diff --git a/packages/b2c-dx-mcp/src/tools/pwav3/index.ts b/packages/b2c-dx-mcp/src/tools/pwav3/index.ts index e20db13..1aa34f3 100644 --- a/packages/b2c-dx-mcp/src/tools/pwav3/index.ts +++ b/packages/b2c-dx-mcp/src/tools/pwav3/index.ts @@ -9,49 +9,75 @@ * * This toolset provides MCP tools for PWA Kit v3 development. * + * > ⚠️ **PLACEHOLDER - ACTIVE DEVELOPMENT** + * > Tools in this module are placeholder implementations that return mock responses. + * > Actual implementations are coming soon. Use `--allow-non-ga-tools` flag to enable. + * * @module tools/pwav3 */ import {z} from 'zod'; import type {McpTool, Toolset} from '../../utils/index.js'; import type {Services} from '../../services.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; /** - * Creates a placeholder tool that logs and returns a mock response. + * Common input type for placeholder tools. */ -function createPlaceholderTool(name: string, description: string, toolsets: Toolset[], _services: Services): McpTool { - return { - name, - description: `[PLACEHOLDER] ${description}`, - inputSchema: { - message: z.string().optional().describe('Optional message to echo'), - }, - toolsets, - isGA: false, - async handler(args) { - const timestamp = new Date().toISOString(); - console.error(`[${timestamp}] Tool '${name}' called with:`, args); +interface PlaceholderInput { + message?: string; +} + +/** + * Common output type for placeholder tools. + */ +interface PlaceholderOutput { + tool: string; + status: string; + message: string; + input: PlaceholderInput; + timestamp: string; +} + +/** + * Creates a placeholder tool for PWA Kit development. + * + * Placeholder tools log invocations and return mock responses until + * the actual implementation is available. + * + * @param name - Tool name + * @param description - Tool description + * @param toolsets - Toolsets this tool belongs to + * @param services - MCP services + * @returns The configured MCP tool + */ +function createPlaceholderTool(name: string, description: string, toolsets: Toolset[], services: Services): McpTool { + return createToolAdapter( + { + name, + description: `[PLACEHOLDER] ${description}`, + toolsets, + isGA: false, + requiresInstance: false, + inputSchema: { + message: z.string().optional().describe('Optional message to echo'), + }, + async execute(args) { + // Placeholder implementation + const timestamp = new Date().toISOString(); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - tool: name, - status: 'placeholder', - message: `This is a placeholder implementation for '${name}'. The actual implementation is coming soon.`, - input: args, - timestamp, - }, - null, - 2, - ), - }, - ], - }; + return { + tool: name, + status: 'placeholder', + message: `This is a placeholder implementation for '${name}'. The actual implementation is coming soon.`, + input: args, + timestamp, + }; + }, + formatOutput: (output) => jsonResult(output), }, - }; + services, + ); } /** diff --git a/packages/b2c-dx-mcp/src/tools/scapi/index.ts b/packages/b2c-dx-mcp/src/tools/scapi/index.ts index f0d302b..9dd165f 100644 --- a/packages/b2c-dx-mcp/src/tools/scapi/index.ts +++ b/packages/b2c-dx-mcp/src/tools/scapi/index.ts @@ -9,49 +9,75 @@ * * This toolset provides MCP tools for Salesforce Commerce API (SCAPI) discovery and exploration. * + * > ⚠️ **PLACEHOLDER - ACTIVE DEVELOPMENT** + * > Tools in this module are placeholder implementations that return mock responses. + * > Actual implementations are coming soon. Use `--allow-non-ga-tools` flag to enable. + * * @module tools/scapi */ import {z} from 'zod'; import type {McpTool, Toolset} from '../../utils/index.js'; import type {Services} from '../../services.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; /** - * Creates a placeholder tool that logs and returns a mock response. + * Common input type for placeholder tools. */ -function createPlaceholderTool(name: string, description: string, toolsets: Toolset[], _services: Services): McpTool { - return { - name, - description: `[PLACEHOLDER] ${description}`, - inputSchema: { - message: z.string().optional().describe('Optional message to echo'), - }, - toolsets, - isGA: false, - async handler(args) { - const timestamp = new Date().toISOString(); - console.error(`[${timestamp}] SCAPI tool '${name}' called with:`, args); +interface PlaceholderInput { + message?: string; +} + +/** + * Common output type for placeholder tools. + */ +interface PlaceholderOutput { + tool: string; + status: string; + message: string; + input: PlaceholderInput; + timestamp: string; +} + +/** + * Creates a placeholder tool for SCAPI operations. + * + * Placeholder tools log invocations and return mock responses until + * the actual implementation is available. + * + * @param name - Tool name + * @param description - Tool description + * @param toolsets - Toolsets this tool belongs to + * @param services - MCP services + * @returns The configured MCP tool + */ +function createPlaceholderTool(name: string, description: string, toolsets: Toolset[], services: Services): McpTool { + return createToolAdapter( + { + name, + description: `[PLACEHOLDER] ${description}`, + toolsets, + isGA: false, + requiresInstance: true, + inputSchema: { + message: z.string().optional().describe('Optional message to echo'), + }, + async execute(args) { + // Placeholder implementation + const timestamp = new Date().toISOString(); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - tool: name, - status: 'placeholder', - message: `This is a placeholder implementation for '${name}'. The actual implementation is coming soon.`, - input: args, - timestamp, - }, - null, - 2, - ), - }, - ], - }; + return { + tool: name, + status: 'placeholder', + message: `This is a placeholder implementation for '${name}'. The actual implementation is coming soon.`, + input: args, + timestamp, + }; + }, + formatOutput: (output) => jsonResult(output), }, - }; + services, + ); } /** diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts index 24f809a..e72be0b 100644 --- a/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts @@ -9,49 +9,74 @@ * * This toolset provides MCP tools for Storefront Next development. * + * > ⚠️ **PLACEHOLDER - ACTIVE DEVELOPMENT** + * > Tools in this module are placeholder implementations that return mock responses. + * > Actual implementations are coming soon. Use `--allow-non-ga-tools` flag to enable. + * * @module tools/storefrontnext */ import {z} from 'zod'; import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; /** - * Creates a placeholder tool that logs and returns a mock response. + * Common input type for placeholder tools. */ -function createPlaceholderTool(name: string, description: string, _services: Services): McpTool { - return { - name, - description: `[PLACEHOLDER] ${description}`, - inputSchema: { - message: z.string().optional().describe('Optional message to echo'), - }, - toolsets: ['STOREFRONTNEXT'], - isGA: false, - async handler(args) { - const timestamp = new Date().toISOString(); - console.error(`[${timestamp}] STOREFRONTNEXT tool '${name}' called with:`, args); +interface PlaceholderInput { + message?: string; +} + +/** + * Common output type for placeholder tools. + */ +interface PlaceholderOutput { + tool: string; + status: string; + message: string; + input: PlaceholderInput; + timestamp: string; +} + +/** + * Creates a placeholder tool for Storefront Next development. + * + * Placeholder tools log invocations and return mock responses until + * the actual implementation is available. + * + * @param name - Tool name + * @param description - Tool description + * @param services - MCP services + * @returns The configured MCP tool + */ +function createPlaceholderTool(name: string, description: string, services: Services): McpTool { + return createToolAdapter( + { + name, + description: `[PLACEHOLDER] ${description}`, + toolsets: ['STOREFRONTNEXT'], + isGA: false, + requiresInstance: false, + inputSchema: { + message: z.string().optional().describe('Optional message to echo'), + }, + async execute(args) { + // Placeholder implementation + const timestamp = new Date().toISOString(); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - tool: name, - status: 'placeholder', - message: `This is a placeholder implementation for '${name}'. The actual implementation is coming soon.`, - input: args, - timestamp, - }, - null, - 2, - ), - }, - ], - }; + return { + tool: name, + status: 'placeholder', + message: `This is a placeholder implementation for '${name}'. The actual implementation is coming soon.`, + input: args, + timestamp, + }; + }, + formatOutput: (output) => jsonResult(output), }, - }; + services, + ); } /** diff --git a/packages/b2c-dx-mcp/test/commands/mcp.test.ts b/packages/b2c-dx-mcp/test/commands/mcp.test.ts index 7c48159..03944fc 100644 --- a/packages/b2c-dx-mcp/test/commands/mcp.test.ts +++ b/packages/b2c-dx-mcp/test/commands/mcp.test.ts @@ -58,6 +58,12 @@ describe('McpServerCommand', () => { // config flag env is inherited from BaseCommand expect(McpServerCommand.baseFlags.config.env).to.equal('SFCC_CONFIG'); }); + + it('should define api-key flag with env var support', () => { + const flag = McpServerCommand.flags['api-key']; + expect(flag).to.not.be.undefined; + expect(flag.env).to.equal('SFCC_MRT_API_KEY'); + }); }); describe('flag parse functions', () => { diff --git a/packages/b2c-dx-mcp/test/registry.test.ts b/packages/b2c-dx-mcp/test/registry.test.ts index cc0f38c..639f1e3 100644 --- a/packages/b2c-dx-mcp/test/registry.test.ts +++ b/packages/b2c-dx-mcp/test/registry.test.ts @@ -11,8 +11,8 @@ import {B2CDxMcpServer} from '../src/server.js'; import type {StartupFlags} from '../src/utils/types.js'; // Create a mock services instance for testing -function createMockServices(configPath?: string): Services { - return new Services({configPath}); +function createMockServices(): Services { + return new Services({}); } // Create a mock server that tracks registered tools diff --git a/packages/b2c-dx-mcp/test/server.test.ts b/packages/b2c-dx-mcp/test/server.test.ts index d8965d3..4db512a 100644 --- a/packages/b2c-dx-mcp/test/server.test.ts +++ b/packages/b2c-dx-mcp/test/server.test.ts @@ -5,8 +5,17 @@ */ import {expect} from 'chai'; +import {z} from 'zod'; import {B2CDxMcpServer} from '../src/server.js'; +// Handlers extracted to module scope to reduce callback nesting depth +const simpleHandler = async () => ({content: [{type: 'text' as const, text: 'ok'}]}); +const toolOneHandler = async () => ({content: [{type: 'text' as const, text: 'one'}]}); +const toolTwoHandler = async () => ({content: [{type: 'text' as const, text: 'two'}]}); +const paramHandler = async (args: Record) => ({ + content: [{type: 'text' as const, text: `Hello ${args.name}`}], +}); + describe('B2CDxMcpServer', () => { describe('constructor', () => { it('should create server with name and version', () => { @@ -37,41 +46,25 @@ describe('B2CDxMcpServer', () => { it('should register a tool without throwing', () => { expect(() => { - server.addTool('test_tool', 'A test tool', {type: 'object', properties: {}}, async () => ({ - content: [{type: 'text', text: 'ok'}], - })); + server.addTool('test_tool', 'A test tool', {}, simpleHandler); }).to.not.throw(); }); it('should register multiple tools', () => { expect(() => { - server.addTool('tool_one', 'First tool', {type: 'object', properties: {}}, async () => ({ - content: [{type: 'text', text: 'one'}], - })); - - server.addTool('tool_two', 'Second tool', {type: 'object', properties: {}}, async () => ({ - content: [{type: 'text', text: 'two'}], - })); + server.addTool('tool_one', 'First tool', {}, toolOneHandler); + server.addTool('tool_two', 'Second tool', {}, toolTwoHandler); }).to.not.throw(); }); it('should accept tools with input schema', () => { + // Use Zod schema (ZodRawShape format) + const inputSchema = { + name: z.string().describe('Name parameter'), + count: z.number().optional().describe('Count parameter'), + }; expect(() => { - server.addTool( - 'parameterized_tool', - 'A tool with parameters', - { - type: 'object', - properties: { - name: {type: 'string', description: 'Name parameter'}, - count: {type: 'number', description: 'Count parameter'}, - }, - required: ['name'], - }, - async (args) => ({ - content: [{type: 'text', text: `Hello ${args.name}`}], - }), - ); + server.addTool('parameterized_tool', 'A tool with parameters', inputSchema, paramHandler); }).to.not.throw(); }); }); diff --git a/packages/b2c-dx-mcp/test/tools/adapter.test.ts b/packages/b2c-dx-mcp/test/tools/adapter.test.ts new file mode 100644 index 0000000..cd09829 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/adapter.test.ts @@ -0,0 +1,734 @@ +/* + * 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 {z} from 'zod'; +import {createToolAdapter, textResult, jsonResult, errorResult} from '../../src/tools/adapter.js'; +import {Services} from '../../src/services.js'; +import type {ToolExecutionContext} from '../../src/tools/adapter.js'; +import type {ToolResult} from '../../src/utils/types.js'; +import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +// Create a mock services instance for testing +function createMockServices(options?: {mrtAuth?: AuthStrategy}): Services { + return new Services({ + mrtConfig: options?.mrtAuth ? {auth: options.mrtAuth} : undefined, + }); +} + +/** + * Helper to extract text from a ToolResult. + * Throws if the first content item is not a text type. + */ +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; +} + +describe('tools/adapter', () => { + describe('textResult', () => { + it('should create a text result with the provided message', () => { + const result = textResult('Hello, world!'); + + expect(result).to.deep.equal({ + content: [{type: 'text', text: 'Hello, world!'}], + }); + }); + + it('should not have isError property', () => { + const result = textResult('Success'); + + expect(result).to.not.have.property('isError'); + }); + }); + + describe('errorResult', () => { + it('should create an error result with the provided message', () => { + const result = errorResult('Something went wrong'); + + expect(result).to.deep.equal({ + content: [{type: 'text', text: 'Something went wrong'}], + isError: true, + }); + }); + + it('should have isError set to true', () => { + const result = errorResult('Error message'); + + expect(result.isError).to.be.true; + }); + }); + + describe('jsonResult', () => { + it('should create a JSON result with default indentation', () => { + const data = {name: 'test', value: 123}; + const result = jsonResult(data); + + expect(result).to.deep.equal({ + content: [{type: 'text', text: JSON.stringify(data, null, 2)}], + }); + }); + + it('should respect custom indentation', () => { + const data = {key: 'value'}; + const result = jsonResult(data, 4); + + expect(result).to.deep.equal({ + content: [{type: 'text', text: JSON.stringify(data, null, 4)}], + }); + }); + + it('should handle arrays', () => { + const data = ['a', 'b', 'c']; + const result = jsonResult(data); + const text = getResultText(result); + + expect(text).to.include('[\n'); + expect(text).to.include('"a"'); + }); + + it('should handle nested objects', () => { + const data = {outer: {inner: {value: true}}}; + const result = jsonResult(data); + const text = getResultText(result); + + expect(text).to.include('"outer"'); + expect(text).to.include('"inner"'); + }); + + it('should not have isError property', () => { + const result = jsonResult({success: true}); + + expect(result).to.not.have.property('isError'); + }); + }); + + describe('createToolAdapter', () => { + it('should create a tool with correct metadata', () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'test_tool', + description: 'A test tool', + toolsets: ['CARTRIDGES'], + isGA: true, + requiresInstance: false, + inputSchema: { + message: z.string().describe('A message'), + }, + execute: async () => 'result', + formatOutput: (output) => textResult(output), + }, + services, + ); + + expect(tool.name).to.equal('test_tool'); + expect(tool.description).to.equal('A test tool'); + expect(tool.toolsets).to.deep.equal(['CARTRIDGES']); + expect(tool.isGA).to.be.true; + }); + + it('should default isGA to true', () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'test_tool', + description: 'A test tool', + toolsets: ['MRT'], + requiresInstance: false, + inputSchema: {}, + execute: async () => 'result', + formatOutput: (output) => textResult(output), + }, + services, + ); + + expect(tool.isGA).to.be.true; + }); + + it('should validate input using Zod schema', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'validator_tool', + description: 'Validates input', + toolsets: ['CARTRIDGES'], + requiresInstance: false, + inputSchema: { + name: z.string().min(1).describe('Name is required'), + count: z.number().positive().describe('Count must be positive'), + }, + execute: async (args: {name: string; count: number}) => `Received: ${args.name}, ${args.count}`, + formatOutput: (output) => textResult(output), + }, + services, + ); + + // Test with valid input + const validResult = await tool.handler({name: 'test', count: 5}); + expect(validResult.isError).to.be.undefined; + expect(getResultText(validResult)).to.equal('Received: test, 5'); + + // Test with missing required field + const missingResult = await tool.handler({count: 5}); + expect(missingResult.isError).to.be.true; + expect(getResultText(missingResult)).to.include('Invalid input'); + expect(getResultText(missingResult)).to.include('name'); + + // Test with invalid type + const invalidTypeResult = await tool.handler({name: 'test', count: 'not-a-number'}); + expect(invalidTypeResult.isError).to.be.true; + expect(getResultText(invalidTypeResult)).to.include('Invalid input'); + }); + + it('should return error for invalid input', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'strict_tool', + description: 'Has strict validation', + toolsets: ['SCAPI'], + requiresInstance: false, + inputSchema: { + email: z.string().email().describe('Must be a valid email'), + }, + execute: async (args: {email: string}) => args.email, + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({email: 'not-an-email'}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('Invalid input'); + expect(getResultText(result)).to.include('email'); + }); + + it('should handle execution errors', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'error_tool', + description: 'Throws an error', + toolsets: ['MRT'], + requiresInstance: false, + inputSchema: {}, + async execute() { + throw new Error('Something bad happened'); + }, + formatOutput: () => textResult('This should not be reached'), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('Execution error'); + expect(getResultText(result)).to.include('Something bad happened'); + }); + + it('should handle thrown errors gracefully', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'string_error_tool', + description: 'Throws an error with a custom message', + toolsets: ['PWAV3'], + requiresInstance: false, + inputSchema: {}, + async execute() { + throw new Error('A custom error message'); + }, + formatOutput: () => textResult('This should not be reached'), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('Execution error'); + expect(getResultText(result)).to.include('A custom error message'); + }); + + it('should pass services to execute function', async () => { + const services = createMockServices(); + let receivedServices: Services | undefined; + + const tool = createToolAdapter( + { + name: 'services_tool', + description: 'Uses services', + toolsets: ['STOREFRONTNEXT'], + requiresInstance: false, + inputSchema: {}, + async execute(_args, context) { + receivedServices = context.services; + return 'done'; + }, + formatOutput: (output) => textResult(output), + }, + services, + ); + + await tool.handler({}); + + expect(receivedServices).to.equal(services); + }); + + it('should support tools that do not require instance', async () => { + const services = createMockServices(); + let contextReceived: ToolExecutionContext | undefined; + + const tool = createToolAdapter( + { + name: 'no_instance_tool', + description: 'Does not need B2CInstance', + toolsets: ['PWAV3'], + requiresInstance: false, + inputSchema: { + projectName: z.string().describe('Project name'), + }, + async execute(args: {projectName: string}, context) { + contextReceived = context; + return `Created project: ${args.projectName}`; + }, + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({projectName: 'my-storefront'}); + + expect(result.isError).to.be.undefined; + expect(getResultText(result)).to.equal('Created project: my-storefront'); + expect(contextReceived).to.not.be.undefined; + expect(contextReceived?.b2cInstance).to.be.undefined; + }); + + it('should use jsonResult for complex output', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'json_output_tool', + description: 'Returns JSON', + toolsets: ['SCAPI'], + requiresInstance: false, + inputSchema: {}, + execute: async () => ({ + status: 'success', + items: [{id: 1}, {id: 2}], + }), + formatOutput: (output) => jsonResult(output), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.undefined; + const parsed = JSON.parse(getResultText(result)); + expect(parsed.status).to.equal('success'); + expect(parsed.items).to.have.lengthOf(2); + }); + + it('should support multiple toolsets', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'multi_toolset_tool', + description: 'Belongs to multiple toolsets', + toolsets: ['PWAV3', 'STOREFRONTNEXT'], + requiresInstance: false, + inputSchema: {}, + execute: async () => 'multi-toolset result', + formatOutput: (output) => textResult(output), + }, + services, + ); + + expect(tool.toolsets).to.include('PWAV3'); + expect(tool.toolsets).to.include('STOREFRONTNEXT'); + }); + + it('should handle optional schema fields', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'optional_fields_tool', + description: 'Has optional fields', + toolsets: ['MRT'], + requiresInstance: false, + inputSchema: { + required: z.string().describe('Required field'), + optional: z.string().optional().describe('Optional field'), + }, + execute: async (args: {required: string; optional?: string}) => + `required: ${args.required}, optional: ${args.optional || 'not provided'}`, + formatOutput: (output) => textResult(output), + }, + services, + ); + + // Without optional field + const result1 = await tool.handler({required: 'value'}); + expect(result1.isError).to.be.undefined; + expect(getResultText(result1)).to.equal('required: value, optional: not provided'); + + // With optional field + const result2 = await tool.handler({required: 'value', optional: 'present'}); + expect(result2.isError).to.be.undefined; + expect(getResultText(result2)).to.equal('required: value, optional: present'); + }); + + it('should handle array schema fields', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'array_tool', + description: 'Accepts array input', + toolsets: ['CARTRIDGES'], + requiresInstance: false, + inputSchema: { + items: z.array(z.string()).describe('Array of strings'), + }, + execute: async (args: {items: string[]}) => args.items.join(', '), + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({items: ['a', 'b', 'c']}); + + expect(result.isError).to.be.undefined; + expect(getResultText(result)).to.equal('a, b, c'); + }); + + it('should provide detailed validation error messages', async () => { + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'detailed_errors_tool', + description: 'Provides detailed errors', + toolsets: ['SCAPI'], + requiresInstance: false, + inputSchema: { + name: z.string().min(3, 'Name must be at least 3 characters'), + age: z.number().min(0, 'Age must be non-negative').max(150, 'Age must be at most 150'), + }, + execute: async () => 'success', + formatOutput: (output) => textResult(output), + }, + services, + ); + + // Test with too short name + const result = await tool.handler({name: 'ab', age: 25}); + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('Name must be at least 3 characters'); + }); + + describe('requiresInstance', () => { + it('should default requiresInstance to false', async () => { + const services = createMockServices(); + let contextReceived: ToolExecutionContext | undefined; + + const tool = createToolAdapter( + { + name: 'default_instance_tool', + description: 'Default behavior', + toolsets: ['CARTRIDGES'], + inputSchema: {}, + async execute(_args, context) { + contextReceived = context; + return 'result'; + }, + formatOutput: (output) => textResult(output), + }, + services, + ); + + // Default is now false, so tool should execute without instance + const result = await tool.handler({}); + + expect(result.isError).to.be.undefined; + expect(contextReceived?.b2cInstance).to.be.undefined; + }); + + it('should return error when B2C instance is not configured', async () => { + // Services with no b2cInstance (resolution failed or not configured) + const services = createMockServices(); + + const tool = createToolAdapter( + { + name: 'bad_config_tool', + description: 'Has bad config', + toolsets: ['CARTRIDGES'], + requiresInstance: true, + inputSchema: {}, + execute: async () => 'should not reach here', + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('B2C instance error'); + }); + }); + + describe('requiresMrtAuth', () => { + const ORIGINAL_ENV = {...process.env}; + + afterEach(() => { + // Restore original env + process.env = {...ORIGINAL_ENV}; + }); + + it('should default requiresMrtAuth to false', async () => { + const services = createMockServices(); + let contextReceived: ToolExecutionContext | undefined; + + const tool = createToolAdapter( + { + name: 'default_mrt_auth_tool', + description: 'Default MRT auth behavior', + toolsets: ['MRT'], + inputSchema: {}, + async execute(_args, context) { + contextReceived = context; + return 'result'; + }, + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.undefined; + expect(contextReceived?.mrtConfig?.auth).to.be.undefined; + }); + + it('should provide mrtConfig in context when auth is configured', async () => { + // Use Services.create() to resolve auth (simulating what mcp.ts does at startup) + const services = Services.create({mrt: {apiKey: 'test-api-key-12345'}}); + let contextReceived: ToolExecutionContext | undefined; + + const tool = createToolAdapter( + { + name: 'mrt_auth_success_tool', + description: 'Uses MRT auth', + toolsets: ['MRT'], + requiresMrtAuth: true, + inputSchema: {}, + async execute(_args, context) { + contextReceived = context; + return 'success'; + }, + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.undefined; + expect(contextReceived?.mrtConfig?.auth).to.not.be.undefined; + // Verify auth has fetch method (AuthStrategy interface) + expect(contextReceived?.mrtConfig?.auth).to.have.property('fetch'); + }); + + it('should support mrtCloudOrigin option in Services.create()', async () => { + // Services.create() accepts cloudOrigin for environment-specific config + // Note: oclif handles env var fallback for --api-key flag, so we pass apiKey explicitly here + const services = Services.create({ + mrt: {apiKey: 'staging-api-key', cloudOrigin: 'https://cloud-staging.mobify.com'}, + }); + let contextReceived: ToolExecutionContext | undefined; + + const tool = createToolAdapter( + { + name: 'mrt_cloud_origin_tool', + description: 'Tests cloud origin support', + toolsets: ['MRT'], + requiresMrtAuth: true, + inputSchema: {}, + async execute(_args, context) { + contextReceived = context; + return 'success'; + }, + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.undefined; + expect(contextReceived?.mrtConfig?.auth).to.not.be.undefined; + }); + + it('should support both requiresInstance and requiresMrtAuth being false', async () => { + const services = createMockServices(); + let contextReceived: ToolExecutionContext | undefined; + + const tool = createToolAdapter( + { + name: 'no_auth_tool', + description: 'Local tool without auth', + toolsets: ['PWAV3'], + requiresInstance: false, + requiresMrtAuth: false, + inputSchema: {}, + async execute(_args, context) { + contextReceived = context; + return 'local operation'; + }, + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.undefined; + expect(contextReceived?.b2cInstance).to.be.undefined; + expect(contextReceived?.mrtConfig?.auth).to.be.undefined; + expect(contextReceived?.services).to.equal(services); + }); + + it('should return error when requiresMrtAuth is true but no auth configured', async () => { + // No mrtConfig.auth provided to Services + const services = createMockServices({}); + const tool = createToolAdapter( + { + name: 'mrt_no_auth_tool', + description: 'Requires MRT auth but none configured', + toolsets: ['MRT'], + requiresMrtAuth: true, + inputSchema: {}, + async execute() { + return 'should not reach here'; + }, + formatOutput: (output) => textResult(output), + }, + services, + ); + + const result = await tool.handler({}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('MRT auth error'); + expect(getResultText(result)).to.include('MRT API key required'); + }); + }); + + describe('formatOutput variations', () => { + it('should allow custom formatOutput logic', async () => { + const services = createMockServices(); + + interface Item { + id: number; + name: string; + } + + const tool = createToolAdapter( + { + name: 'custom_format_tool', + description: 'Has custom formatting', + toolsets: ['MRT'], + requiresInstance: false, + inputSchema: { + items: z.array(z.object({id: z.number(), name: z.string()})), + }, + execute: async (args: {items: Item[]}) => args.items, + formatOutput(items: Item[]) { + if (items.length === 0) { + return textResult('No items found.'); + } + const list = items.map((item) => `- ${item.id}: ${item.name}`).join('\n'); + return textResult(`Found ${items.length} items:\n${list}`); + }, + }, + services, + ); + + // Empty list + const emptyResult = await tool.handler({items: []}); + expect(getResultText(emptyResult)).to.equal('No items found.'); + + // With items + const itemsResult = await tool.handler({ + items: [ + {id: 1, name: 'First'}, + {id: 2, name: 'Second'}, + ], + }); + expect(getResultText(itemsResult)).to.include('Found 2 items:'); + expect(getResultText(itemsResult)).to.include('1: First'); + expect(getResultText(itemsResult)).to.include('2: Second'); + }); + + it('should allow conditional error/success in formatOutput', async () => { + const services = createMockServices(); + + type OperationResult = {success: boolean; message: string}; + + const tool = createToolAdapter<{operation: string}, OperationResult>( + { + name: 'conditional_format_tool', + description: 'Conditionally formats output', + toolsets: ['SCAPI'], + requiresInstance: false, + inputSchema: { + operation: z.string(), + }, + async execute(args): Promise { + if (args.operation === 'fail') { + return {success: false, message: 'Operation failed'}; + } + return {success: true, message: 'Operation succeeded'}; + }, + formatOutput(result: OperationResult): ToolResult { + if (!result.success) { + return errorResult(result.message); + } + return textResult(result.message); + }, + }, + services, + ); + + const successResult = await tool.handler({operation: 'succeed'}); + expect(successResult.isError).to.be.undefined; + expect(getResultText(successResult)).to.equal('Operation succeeded'); + + const failResult = await tool.handler({operation: 'fail'}); + expect(failResult.isError).to.be.true; + expect(getResultText(failResult)).to.equal('Operation failed'); + }); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index c3debd2..360009b 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -103,8 +103,8 @@ export {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS} from './webdav-command.js'; export type {WebDavRootKey} from './webdav-command.js'; // Config utilities -export {loadConfig, findDwJson} from './config.js'; -export type {ResolvedConfig, LoadConfigOptions} from './config.js'; +export {loadConfig, findDwJson, loadMobifyConfig} from './config.js'; +export type {ResolvedConfig, LoadConfigOptions, MobifyConfigResult} from './config.js'; // Table rendering utilities export {TableRenderer, createTable} from './table.js';