Skip to content

Commit c865e2d

Browse files
@W-21249660.mcp mrt cartridge fixes (#162)
* @W-21249660 MCP cartridge deploy defaults to active version * @W-21249660 MCP cartridge deploy improve error reporting * @W-21249660 MCP mrt push description updated to include sfnext * @W-21249660 MCP mrt push support optional deploy to configured environment * @W-21249660 MCP mrt and cartridge tool relative path fix
1 parent 03b2b84 commit c865e2d

File tree

6 files changed

+690
-47
lines changed

6 files changed

+690
-47
lines changed

packages/b2c-dx-mcp/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Get registration status of custom API endpoints deployed on the instance (remote
165165
**Good prompts:**
166166
- ✅ "Use the MCP tool to build and push my Storefront Next bundle to staging."
167167
- ✅ "Use the MCP tool to push the bundle from ./build directory to Managed Runtime."
168-
- ✅ "Use the MCP tool to deploy my PWA Kit bundle to production with a deployment message."
168+
- ✅ "Use the MCP tool to deploy my PWA Kit or Storefront Next bundle to production with a deployment message."
169169

170170
#### Tips for Better Results
171171

packages/b2c-dx-mcp/src/tools/cartridges/index.ts

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
* @module tools/cartridges
1313
*/
1414

15+
import path from 'node:path';
1516
import {z} from 'zod';
1617
import type {McpTool} from '../../utils/index.js';
1718
import type {Services} from '../../services.js';
1819
import {createToolAdapter, jsonResult} from '../adapter.js';
19-
import {findAndDeployCartridges} from '@salesforce/b2c-tooling-sdk/operations/code';
20-
import type {DeployResult, DeployOptions} from '@salesforce/b2c-tooling-sdk/operations/code';
20+
import {findAndDeployCartridges, getActiveCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code';
21+
import type {DeployResult, DeployOptions, CodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code';
2122
import type {B2CInstance} from '@salesforce/b2c-tooling-sdk';
2223
import {getLogger} from '@salesforce/b2c-tooling-sdk/logging';
2324

@@ -41,6 +42,8 @@ interface CartridgeDeployInput {
4142
interface CartridgeToolInjections {
4243
/** Mock findAndDeployCartridges function for testing */
4344
findAndDeployCartridges?: (instance: B2CInstance, directory: string, options: DeployOptions) => Promise<DeployResult>;
45+
/** Mock getActiveCodeVersion function for testing */
46+
getActiveCodeVersion?: (instance: B2CInstance) => Promise<CodeVersion | undefined>;
4447
}
4548

4649
/**
@@ -58,6 +61,7 @@ interface CartridgeToolInjections {
5861
*/
5962
function createCartridgeDeployTool(loadServices: () => Services, injections?: CartridgeToolInjections): McpTool {
6063
const findAndDeployCartridgesFn = injections?.findAndDeployCartridges || findAndDeployCartridges;
64+
const getActiveCodeVersionFn = injections?.getActiveCodeVersion || getActiveCodeVersion;
6165
return createToolAdapter<CartridgeDeployInput, DeployResult>(
6266
{
6367
name: 'cartridge_deploy',
@@ -104,33 +108,65 @@ function createCartridgeDeployTool(loadServices: () => Services, injections?: Ca
104108
async execute(args, context) {
105109
// Get instance from context (guaranteed by adapter when requiresInstance is true)
106110
const instance = context.b2cInstance!;
111+
const logger = getLogger();
107112

108-
// Default directory to current directory
109-
const directory = args.directory || context.services.getWorkingDirectory();
113+
try {
114+
// If no code version specified, get the active one
115+
let codeVersion = instance.config.codeVersion;
116+
if (!codeVersion) {
117+
logger.debug('No code version specified, getting active version...');
118+
const active = await getActiveCodeVersionFn(instance);
119+
if (!active?.id) {
120+
throw new Error(
121+
'No code version specified and no active code version found. ' +
122+
'Specify a code version using one of: ' +
123+
'--code-version flag, SFCC_CODE_VERSION environment variable, ' +
124+
'or code-version field in dw.json configuration file.',
125+
);
126+
}
127+
codeVersion = active.id;
128+
instance.config.codeVersion = codeVersion;
129+
}
110130

111-
// Parse options
112-
const options: DeployOptions = {
113-
include: args.cartridges,
114-
exclude: args.exclude,
115-
reload: args.reload,
116-
};
131+
// Resolve directory path: relative paths are resolved relative to working directory, absolute paths are used as-is
132+
const directory = args.directory
133+
? path.isAbsolute(args.directory)
134+
? args.directory
135+
: path.resolve(context.services.getWorkingDirectory(), args.directory)
136+
: context.services.getWorkingDirectory();
117137

118-
// Log all computed variables before deploying
119-
const logger = getLogger();
120-
logger.debug(
121-
{
122-
directory,
123-
include: options.include,
124-
exclude: options.exclude,
125-
reload: options.reload,
126-
},
127-
'[Cartridges] Deploying cartridges with computed options',
128-
);
138+
// Parse options
139+
const options: DeployOptions = {
140+
include: args.cartridges,
141+
exclude: args.exclude,
142+
reload: args.reload,
143+
};
144+
145+
// Log all computed variables before deploying
146+
logger.debug(
147+
{
148+
directory,
149+
codeVersion,
150+
include: options.include,
151+
exclude: options.exclude,
152+
reload: options.reload,
153+
},
154+
'[Cartridges] Deploying cartridges with computed options',
155+
);
129156

130-
// Deploy cartridges
131-
const result = await findAndDeployCartridgesFn(instance, directory, options);
157+
// Deploy cartridges
158+
const result = await findAndDeployCartridgesFn(instance, directory, options);
132159

133-
return result;
160+
return result;
161+
} catch (error) {
162+
// Handle communication and authentication errors
163+
const errorMessage = error instanceof Error ? error.message : String(error);
164+
throw new Error(
165+
`Failed to communicate with B2C instance. Check your authentication credentials and network connection. ` +
166+
`If no code version is specified, ensure the instance is accessible and has an active code version. ` +
167+
`Original error: ${errorMessage}`,
168+
);
169+
}
134170
},
135171
formatOutput: (output) => jsonResult(output),
136172
},

packages/b2c-dx-mcp/src/tools/mrt/index.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ interface MrtBundlePushInput {
3434
ssrOnly?: string;
3535
/** Glob patterns for shared files (default: static/**\/*,client/**\/*) */
3636
ssrShared?: string;
37+
/** Whether to deploy to an environment after push (default: false) */
38+
deploy?: boolean;
3739
}
3840

3941
/**
@@ -47,7 +49,7 @@ interface MrtToolInjections {
4749
/**
4850
* Creates the mrt_bundle_push tool.
4951
*
50-
* Creates a bundle from a pre-built PWA Kit project and pushes it to
52+
* Creates a bundle from a pre-built PWA Kit or Storefront Next project and pushes it to
5153
* Managed Runtime (MRT). Optionally deploys to a target environment after push.
5254
* Expects the project to already be built (e.g., `npm run build` completed).
5355
* Shared across MRT, PWAV3, and STOREFRONTNEXT toolsets.
@@ -62,7 +64,7 @@ function createMrtBundlePushTool(loadServices: () => Services, injections?: MrtT
6264
{
6365
name: 'mrt_bundle_push',
6466
description:
65-
'Bundle a pre-built PWA Kit project and push to Managed Runtime. Optionally deploy to a target environment.',
67+
'Bundle a pre-built PWA Kit or Storefront Next project and push to Managed Runtime. Optionally deploy to a target environment.',
6668
toolsets: ['MRT', 'PWAV3', 'STOREFRONTNEXT'],
6769
isGA: false,
6870
// MRT operations use ApiKeyStrategy from SFCC_MRT_API_KEY or ~/.mobify
@@ -78,6 +80,11 @@ function createMrtBundlePushTool(loadServices: () => Services, injections?: MrtT
7880
.string()
7981
.optional()
8082
.describe('Glob patterns for shared files, comma-separated (default: static/**/*,client/**/*)'),
83+
deploy: z
84+
.boolean()
85+
.optional()
86+
.default(false)
87+
.describe('Whether to deploy to an environment after push (default: false)'),
8188
},
8289
async execute(args, context) {
8390
// Get project from --project flag (required)
@@ -89,15 +96,30 @@ function createMrtBundlePushTool(loadServices: () => Services, injections?: MrtT
8996
}
9097

9198
// Get environment from --environment flag (optional)
92-
const environment = context.mrtConfig?.environment;
99+
// When deploy is false, environment is undefined (bundle push only, no deployment)
100+
// When deploy is true, environment is required
101+
let environment: string | undefined;
102+
if (args.deploy) {
103+
environment = context.mrtConfig?.environment;
104+
if (!environment) {
105+
throw new Error(
106+
'MRT deployment error: Environment is required when deploy=true. ' +
107+
'Provide --environment flag, set SFCC_MRT_ENVIRONMENT environment variable, or set mrtEnvironment in dw.json.',
108+
);
109+
}
110+
}
93111

94112
// Get origin from --cloud-origin flag or mrtOrigin config (optional)
95113
const origin = context.mrtConfig?.origin;
96114

97115
// Parse comma-separated glob patterns (same as CLI defaults)
98116
const ssrOnly = (args.ssrOnly || 'ssr.js,ssr.mjs,server/**/*').split(',').map((s) => s.trim());
99117
const ssrShared = (args.ssrShared || 'static/**/*,client/**/*').split(',').map((s) => s.trim());
100-
const buildDirectory = args.buildDirectory || path.join(context.services.getWorkingDirectory(), 'build');
118+
const buildDirectory = args.buildDirectory
119+
? path.isAbsolute(args.buildDirectory)
120+
? args.buildDirectory
121+
: path.resolve(context.services.getWorkingDirectory(), args.buildDirectory)
122+
: path.join(context.services.getWorkingDirectory(), 'build');
101123

102124
// Log all computed variables before pushing bundle
103125
const logger = getLogger();

packages/b2c-dx-mcp/test/commands/mcp.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {createSandbox, type SinonStub, type SinonSandbox} from 'sinon';
99
import {Telemetry} from '@salesforce/b2c-tooling-sdk/telemetry';
1010
import McpServerCommand from '../../src/commands/mcp.js';
1111
import {B2CDxMcpServer} from '../../src/server.js';
12+
import {Services} from '../../src/services.js';
13+
import {createMockResolvedConfig} from '../test-helpers.js';
1214

1315
describe('McpServerCommand', () => {
1416
describe('static properties', () => {
@@ -505,6 +507,118 @@ describe('McpServerCommand', () => {
505507
});
506508
});
507509

510+
describe('loadServices', () => {
511+
let sandbox: SinonSandbox;
512+
let command: McpServerCommand;
513+
let loadConfigurationStub: SinonStub;
514+
let fromResolvedConfigStub: SinonStub;
515+
516+
beforeEach(() => {
517+
sandbox = createSandbox();
518+
command = new McpServerCommand([], {
519+
name: 'test',
520+
version: '1.0.0',
521+
root: process.cwd(),
522+
dataDir: '/tmp/test-data',
523+
} as never);
524+
});
525+
526+
afterEach(() => {
527+
sandbox.restore();
528+
});
529+
530+
it('should call loadConfiguration and Services.fromResolvedConfig', () => {
531+
const mockConfig = createMockResolvedConfig();
532+
const mockServices = new Services({
533+
resolvedConfig: mockConfig,
534+
});
535+
536+
// Stub loadConfiguration to return mock config
537+
loadConfigurationStub = sandbox
538+
.stub(command as unknown as Record<string, unknown>, 'loadConfiguration')
539+
.returns(mockConfig);
540+
541+
// Stub Services.fromResolvedConfig to return mock services
542+
fromResolvedConfigStub = sandbox.stub(Services, 'fromResolvedConfig').returns(mockServices);
543+
544+
// Call loadServices via protected access
545+
const services = (command as unknown as {loadServices(): Services}).loadServices();
546+
547+
// Verify loadConfiguration was called
548+
expect(loadConfigurationStub.calledOnce).to.be.true;
549+
550+
// Verify Services.fromResolvedConfig was called with the config from loadConfiguration
551+
expect(fromResolvedConfigStub.calledOnce).to.be.true;
552+
expect(fromResolvedConfigStub.firstCall.args[0]).to.equal(mockConfig);
553+
554+
// Verify the returned services instance
555+
expect(services).to.equal(mockServices);
556+
});
557+
558+
it('should return Services instance created from resolved config', () => {
559+
const mockConfig = createMockResolvedConfig({
560+
hostname: 'test-server',
561+
mrtProject: 'test-project',
562+
});
563+
const mockServices = new Services({
564+
resolvedConfig: mockConfig,
565+
});
566+
567+
// Stub loadConfiguration
568+
sandbox.stub(command as unknown as Record<string, unknown>, 'loadConfiguration').returns(mockConfig);
569+
570+
// Stub Services.fromResolvedConfig to return mock services
571+
sandbox.stub(Services, 'fromResolvedConfig').returns(mockServices);
572+
573+
// Call loadServices
574+
const services = (command as unknown as {loadServices(): Services}).loadServices();
575+
576+
// Verify the returned services instance
577+
expect(services).to.equal(mockServices);
578+
expect(services).to.be.instanceOf(Services);
579+
});
580+
581+
it('should reload configuration on each call', () => {
582+
const mockConfig1 = createMockResolvedConfig({hostname: 'server1'});
583+
const mockConfig2 = createMockResolvedConfig({hostname: 'server2'});
584+
const mockServices1 = new Services({resolvedConfig: mockConfig1});
585+
const mockServices2 = new Services({resolvedConfig: mockConfig2});
586+
587+
// Stub loadConfiguration to return different configs on each call
588+
const loadConfigurationStub = sandbox
589+
.stub(command as unknown as Record<string, unknown>, 'loadConfiguration')
590+
.onFirstCall()
591+
.returns(mockConfig1)
592+
.onSecondCall()
593+
.returns(mockConfig2);
594+
595+
// Stub Services.fromResolvedConfig to return different services
596+
const fromResolvedConfigStub = sandbox
597+
.stub(Services, 'fromResolvedConfig')
598+
.onFirstCall()
599+
.returns(mockServices1)
600+
.onSecondCall()
601+
.returns(mockServices2);
602+
603+
// Call loadServices twice
604+
const services1 = (command as unknown as {loadServices(): Services}).loadServices();
605+
const services2 = (command as unknown as {loadServices(): Services}).loadServices();
606+
607+
// Verify loadConfiguration was called twice
608+
expect(loadConfigurationStub.calledTwice).to.be.true;
609+
610+
// Verify Services.fromResolvedConfig was called with correct configs
611+
expect(fromResolvedConfigStub.calledTwice).to.be.true;
612+
expect(fromResolvedConfigStub.firstCall.args[0]).to.equal(mockConfig1);
613+
expect(fromResolvedConfigStub.secondCall.args[0]).to.equal(mockConfig2);
614+
615+
// Verify different services instances were returned
616+
expect(services1).to.equal(mockServices1);
617+
expect(services2).to.equal(mockServices2);
618+
expect(services1).to.not.equal(services2);
619+
});
620+
});
621+
508622
describe('finally', () => {
509623
let sandbox: SinonSandbox;
510624
let command: McpServerCommand;

0 commit comments

Comments
 (0)