Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions packages/b2c-dx-mcp/src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,20 @@ export default class McpServerCommand extends BaseCommand<typeof McpServerComman
return loadConfig(flagConfig, options);
}

/**
* Loads configuration and creates a new Services instance.
*
* This method loads configuration files (dw.json, ~/.mobify) on each call,
* allowing tools to pick up changes to configuration between invocations.
* Flags remain the same (parsed once at startup).
*
* @returns A new Services instance with loaded configuration
*/
protected loadServices(): Services {
const config = this.loadConfiguration();
return Services.fromResolvedConfig(config);
}

/**
* Main entry point - starts the MCP server.
*
Expand Down Expand Up @@ -336,11 +350,9 @@ export default class McpServerCommand extends BaseCommand<typeof McpServerComman
},
);

// Create services from already-resolved config (BaseCommand.init() already resolved it)
const services = Services.fromResolvedConfig(this.resolvedConfig);

// Register toolsets
await registerToolsets(startupFlags, server, services);
// Register toolsets with loader function that loads config and creates Services on each tool call
// This allows tools to pick up changes to config files (dw.json, ~/.mobify) between invocations
await registerToolsets(startupFlags, server, this.loadServices.bind(this));

// Connect to stdio transport
const transport = new StdioServerTransport();
Expand Down
24 changes: 14 additions & 10 deletions packages/b2c-dx-mcp/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ export type ToolRegistry = Record<Toolset, McpTool[]>;
* Tools are organized by their declared `toolsets` array, allowing
* a single tool to appear in multiple toolsets.
*
* @param services - Services instance for dependency injection
* @param loadServices - Function that loads configuration and returns Services instance
* @returns Complete tool registry
*/
export function createToolRegistry(services: Services): ToolRegistry {
export function createToolRegistry(loadServices: () => Services): ToolRegistry {
const registry: ToolRegistry = {
CARTRIDGES: [],
MRT: [],
Expand All @@ -90,11 +90,11 @@ export function createToolRegistry(services: Services): ToolRegistry {

// Collect all tools from all factories
const allTools: McpTool[] = [
...createCartridgesTools(services),
...createMrtTools(services),
...createPwav3Tools(services),
...createScapiTools(services),
...createStorefrontNextTools(services),
...createCartridgesTools(loadServices),
...createMrtTools(loadServices),
...createPwav3Tools(loadServices),
...createScapiTools(loadServices),
...createStorefrontNextTools(loadServices),
];

// Organize tools by their declared toolsets (supports multi-toolset)
Expand Down Expand Up @@ -167,16 +167,20 @@ async function performAutoDiscovery(flags: StartupFlags, reason: string): Promis
*
* @param flags - Startup flags from CLI
* @param server - B2CDxMcpServer instance
* @param services - Services instance
* @param loadServices - Function that loads configuration and returns Services instance
*/
export async function registerToolsets(flags: StartupFlags, server: B2CDxMcpServer, services: Services): Promise<void> {
export async function registerToolsets(
flags: StartupFlags,
server: B2CDxMcpServer,
loadServices: () => Services,
): Promise<void> {
const toolsets = flags.toolsets ?? [];
const individualTools = flags.tools ?? [];
const allowNonGaTools = flags.allowNonGaTools ?? false;
const logger = getLogger();

// Create the tool registry (all available tools)
const toolRegistry = createToolRegistry(services);
const toolRegistry = createToolRegistry(loadServices);

// Build flat list of all tools for lookup
const allTools = Object.values(toolRegistry).flat();
Expand Down
13 changes: 13 additions & 0 deletions packages/b2c-dx-mcp/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,19 @@ export class Services {
return this.b2cInstance.webdav;
}

/**
* Get the project working directory.
* Falls back to process.cwd() if not explicitly set.
*
* This is the directory where the project is located, which may differ from process.cwd()
* when MCP clients spawn servers from a different location (e.g., home directory).
*
* @returns Project working directory path
*/
public getWorkingDirectory(): string {
return this.resolvedConfig.values.workingDirectory ?? process.cwd();
}

/**
* Join path segments.
*
Expand Down
61 changes: 36 additions & 25 deletions packages/b2c-dx-mcp/src/tools/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,22 @@
*
* 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)
* - Inject loaded B2CInstance for WebDAV/OCAPI operations (requiresInstance)
* - Inject loaded 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.fromResolvedConfig} and reused for all tool calls:
* Both B2C instance and MRT auth are loaded before each tool call via
* a loader function that calls {@link Services.fromResolvedConfig}:
*
* - **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`.
* - **B2CInstance**: Loaded from flags + dw.json on each call. Available when `requiresInstance: true`.
* - **MRT Auth**: Loaded from --api-key → SFCC_MRT_API_KEY → ~/.mobify on each call. 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)
* This "load on each call" pattern provides:
* - Fresh configuration on each tool invocation (picks up changes to config files)
* - Consistent mental model (both loaded the same way)
* - Tools can respond to configuration changes without server restart
*
* @module tools/adapter
*
Expand All @@ -48,8 +48,11 @@
*
* @example MRT tool (MRT API)
* ```typescript
* // Services created from already-resolved config at startup
* const services = Services.fromResolvedConfig(this.resolvedConfig);
* // Loader function that loads config and creates Services on each tool call
* const loadServices = () => {
* const config = this.loadConfiguration();
* return Services.fromResolvedConfig(config);
* };
*
* const mrtTool = createToolAdapter({
* name: 'mrt_bundle_push',
Expand All @@ -59,12 +62,12 @@
* inputSchema: {
* projectSlug: z.string().describe('MRT project slug'),
* },
* execute: async (args, { mrtAuth }) => {
* const result = await pushBundle({ projectSlug: args.projectSlug }, mrtAuth);
* execute: async (args, { mrtConfig }) => {
* const result = await pushBundle({ projectSlug: args.projectSlug }, mrtConfig.auth);
* return result;
* },
* formatOutput: (output) => jsonResult(output),
* }, services);
* }, loadServices);
* ```
*/

Expand All @@ -87,7 +90,7 @@ export interface ToolExecutionContext {

/**
* MRT configuration (auth, project, environment, origin).
* Pre-resolved at server startup.
* Loaded before each tool call.
* Only populated when requiresMrtAuth is true.
*/
mrtConfig?: MrtConfig;
Expand Down Expand Up @@ -222,31 +225,36 @@ function formatZodErrors(error: z.ZodError): string {
* @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
* @param loadServices - Function that loads configuration and returns Services instance
* @returns An McpTool ready for registration
*
* @example
* ```typescript
* import { z } from 'zod';
* import { createToolAdapter, jsonResult, errorResult } from './adapter.js';
*
* const loadServices = () => {
* const config = this.loadConfiguration();
* return Services.fromResolvedConfig(config);
* };
*
* 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', {});
* execute: async (_args, { b2cInstance }) => {
* const result = await b2cInstance.ocapi.GET('/code_versions', {});
* if (result.error) throw new Error(result.error.message);
* return result.data;
* },
* formatOutput: (data) => jsonResult(data),
* }, services);
* }, loadServices);
* ```
*/
export function createToolAdapter<TInput, TOutput>(
options: ToolAdapterOptions<TInput, TOutput>,
services: Services,
loadServices: () => Services,
): McpTool {
const {
name,
Expand Down Expand Up @@ -279,7 +287,10 @@ export function createToolAdapter<TInput, TOutput>(
const args = parseResult.data as TInput;

try {
// 2. Get B2CInstance if required (pre-resolved at startup)
// 2. Load Services to get fresh configuration (re-reads config files)
const services = loadServices();

// 3. Get B2CInstance if required (loaded on each call)
let b2cInstance: B2CInstance | undefined;
if (requiresInstance) {
if (!services.b2cInstance) {
Expand All @@ -290,7 +301,7 @@ export function createToolAdapter<TInput, TOutput>(
b2cInstance = services.b2cInstance;
}

// 3. Get MRT config if required (pre-resolved at startup)
// 4. Get MRT config if required (loaded on each call)
let mrtConfig: ToolExecutionContext['mrtConfig'];
if (requiresMrtAuth) {
if (!services.mrtConfig.auth) {
Expand All @@ -306,15 +317,15 @@ export function createToolAdapter<TInput, TOutput>(
};
}

// 4. Execute the operation
// 5. Execute the operation
const context: ToolExecutionContext = {
b2cInstance,
mrtConfig,
services,
};
const output = await execute(args, context);

// 5. Format output
// 6. Format output
return formatOutput(output);
} catch (error) {
// Handle execution errors
Expand Down
14 changes: 7 additions & 7 deletions packages/b2c-dx-mcp/src/tools/cartridges/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ interface CartridgeToolInjections {
* 3. Uploads the zip to WebDAV and triggers server-side unzip
* 4. Optionally reloads the code version after deploy
*
* @param services - MCP services
* @param loadServices - Function that loads configuration and returns Services instance
* @param injections - Optional dependency injections for testing
* @returns The cartridge_deploy tool
*/
function createCartridgeDeployTool(services: Services, injections?: CartridgeToolInjections): McpTool {
function createCartridgeDeployTool(loadServices: () => Services, injections?: CartridgeToolInjections): McpTool {
const findAndDeployCartridgesFn = injections?.findAndDeployCartridges || findAndDeployCartridges;
return createToolAdapter<CartridgeDeployInput, DeployResult>(
{
Expand Down Expand Up @@ -106,7 +106,7 @@ function createCartridgeDeployTool(services: Services, injections?: CartridgeToo
const instance = context.b2cInstance!;

// Default directory to current directory
const directory = args.directory || '.';
const directory = args.directory || context.services.getWorkingDirectory();

// Parse options
const options: DeployOptions = {
Expand Down Expand Up @@ -134,17 +134,17 @@ function createCartridgeDeployTool(services: Services, injections?: CartridgeToo
},
formatOutput: (output) => jsonResult(output),
},
services,
loadServices,
);
}

/**
* Creates all tools for the CARTRIDGES toolset.
*
* @param services - MCP services
* @param loadServices - Function that loads configuration and returns Services instance
* @param injections - Optional dependency injections for testing
* @returns Array of MCP tools
*/
export function createCartridgesTools(services: Services, injections?: CartridgeToolInjections): McpTool[] {
return [createCartridgeDeployTool(services, injections)];
export function createCartridgesTools(loadServices: () => Services, injections?: CartridgeToolInjections): McpTool[] {
return [createCartridgeDeployTool(loadServices, injections)];
}
15 changes: 8 additions & 7 deletions packages/b2c-dx-mcp/src/tools/mrt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* @module tools/mrt
*/

import path from 'node:path';
import {z} from 'zod';
import type {McpTool} from '../../utils/index.js';
import type {Services} from '../../services.js';
Expand Down Expand Up @@ -51,11 +52,11 @@ interface MrtToolInjections {
* Expects the project to already be built (e.g., `npm run build` completed).
* Shared across MRT, PWAV3, and STOREFRONTNEXT toolsets.
*
* @param services - MCP services
* @param loadServices - Function that loads configuration and returns Services instance
* @param injections - Optional dependency injections for testing
* @returns The mrt_bundle_push tool
*/
function createMrtBundlePushTool(services: Services, injections?: MrtToolInjections): McpTool {
function createMrtBundlePushTool(loadServices: () => Services, injections?: MrtToolInjections): McpTool {
const pushBundleFn = injections?.pushBundle || pushBundle;
return createToolAdapter<MrtBundlePushInput, PushResult>(
{
Expand Down Expand Up @@ -96,7 +97,7 @@ function createMrtBundlePushTool(services: Services, injections?: MrtToolInjecti
// 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 buildDirectory = args.buildDirectory || './build';
const buildDirectory = args.buildDirectory || path.join(context.services.getWorkingDirectory(), 'build');

// Log all computed variables before pushing bundle
const logger = getLogger();
Expand Down Expand Up @@ -132,17 +133,17 @@ function createMrtBundlePushTool(services: Services, injections?: MrtToolInjecti
},
formatOutput: (output) => jsonResult(output),
},
services,
loadServices,
);
}

/**
* Creates all tools for the MRT toolset.
*
* @param services - MCP services
* @param loadServices - Function that loads configuration and returns Services instance
* @param injections - Optional dependency injections for testing
* @returns Array of MCP tools
*/
export function createMrtTools(services: Services, injections?: MrtToolInjections): McpTool[] {
return [createMrtBundlePushTool(services, injections)];
export function createMrtTools(loadServices: () => Services, injections?: MrtToolInjections): McpTool[] {
return [createMrtBundlePushTool(loadServices, injections)];
}
Loading
Loading