Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2b72277
feat: add ignoreConflict param to deploy
soridalac Nov 3, 2025
70b7c97
feat: add ignoreConflicts params for retrieval
soridalac Nov 4, 2025
ba6c8d2
feat: add test for retrieval
soridalac Nov 4, 2025
5a3310a
chore: update e2e for deploy
soridalac Nov 6, 2025
ca9557d
fix: add basic telemetry to `list_devops_projects` tool (#320)
pikuskd Nov 6, 2025
7a3db63
feat: Add e2e test to validate AFV server startup W-19829954 (#315)
soridalac Nov 6, 2025
06d1cfd
chore(release): mcp-provider-dx-core@0.4.0 [skip ci]
svc-cli-bot Nov 6, 2025
b92eed4
chore: bump @salesforce/mcp-provider-dx-core to 0.4.0 --no-verify
svc-cli-bot Nov 6, 2025
3b368fb
chore(release): mcp-provider-devops@0.1.9 [skip ci]
svc-cli-bot Nov 6, 2025
f26ab58
chore: bump @salesforce/mcp-provider-devops to 0.1.9 --no-verify
svc-cli-bot Nov 6, 2025
84dd32d
Merge branch 'main' into sl/W-20002028
soridalac Nov 6, 2025
69722d2
chore: update e2e for retrieval
soridalac Nov 7, 2025
e8be76d
Merge branch 'main' into sl/W-20002028
soridalac Nov 7, 2025
e12ca8f
Merge branch 'main' into sl/W-20002028
soridalac Nov 7, 2025
7a3ef12
Merge branch 'main' into sl/W-20002028
cristiand391 Nov 13, 2025
a2d5cc7
chore: remove local edit test
soridalac Nov 17, 2025
7926fdf
Merge branch 'main' into sl/W-20002028
soridalac Nov 17, 2025
b1db97f
fix: add the error message
soridalac Nov 24, 2025
ed6d52f
chore: check for retrieve conflicts
soridalac Nov 24, 2025
8a4cf85
Merge branch 'main' into sl/W-20002028
soridalac Nov 24, 2025
58e4daf
Merge branch 'main' into sl/W-20002028
soridalac Dec 4, 2025
7f5f956
chore: clear lifecycle singleton pattern
soridalac Dec 8, 2025
5aa8cb2
chore: clear old lifecycle listener for retrieve
soridalac Dec 8, 2025
7f46a9b
Merge branch 'main' into sl/W-20002028
soridalac Dec 8, 2025
0f45ec4
chore: set event listener to true
soridalac Dec 9, 2025
1fc2ce4
fix: remove redundant
soridalac Dec 9, 2025
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
18 changes: 15 additions & 3 deletions packages/mcp-provider-dx-core/src/tools/deploy_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { z } from 'zod';
import { Connection, Org, SfError, SfProject } from '@salesforce/core';
import { Connection, Lifecycle, Org, SfError, SfProject } from '@salesforce/core';
import { SourceTracking } from '@salesforce/source-tracking';
import { ComponentSet, ComponentSetBuilder } from '@salesforce/source-deploy-retrieve';
import { ensureString } from '@salesforce/ts-types';
Expand All @@ -35,12 +35,14 @@ import { textResponse } from '../shared/utils.js';
* - apexTests: Apex tests classes to run.
* - usernameOrAlias: Username or alias of the Salesforce org to deploy to.
* - directory: Directory of the local project.
* - ignoreConflicts: Ignore conflicts and deploy local files, even if they overwrite changes in the org.
*
* Returns:
* - textResponse: Deploy result.
*/

export const deployMetadataParams = z.object({
ignoreConflicts: z.boolean().describe(' Ignore conflicts and deploy local files, even if they overwrite changes in the org.').optional(),
sourceDir: z
.array(z.string())
.describe('Path to the local source files to deploy. Leave this unset if the user is vague about what to deploy.')
Expand Down Expand Up @@ -106,20 +108,22 @@ export class DeployMetadataMcpTool extends McpTool<InputArgsShape, OutputArgsSha
description: `Deploy metadata to an org from your local project.

AGENT INSTRUCTIONS:
If the user doesn't specify what to deploy exactly ("deploy my changes"), leave the "sourceDir" and "manifest" params empty so the tool calculates which files to deploy.
If the user doesn't specify what to deploy exactly ("deploy my changes"), leave the "sourceDir", "ignoreConflicts" and "manifest" params empty so the tool calculates which files to deploy.

EXAMPLE USAGE:
Deploy changes to my org
Deploy this file to my org
Deploy the manifest
Deploy X metadata to my org
Deploy X local files to my org and ignore any conflicts between the local project and org
Deploy X to my org and run A,B and C apex tests.`,
inputSchema: deployMetadataParams.shape,
outputSchema: undefined,
annotations: {
destructiveHint: true,
openWorldHint: false,
},

};
}

Expand All @@ -146,7 +150,7 @@ Deploy X to my org and run A,B and C apex tests.`,

const org = await Org.create({ connection });

if (!input.sourceDir && !input.manifest && !(await org.tracksSource())) {
if (!input.sourceDir && !input.manifest && !input.ignoreConflicts && !(await org.tracksSource())) {
return textResponse(
'This org does not have source-tracking enabled or does not support source-tracking. You should specify the files or a manifest to deploy.',
true,
Expand All @@ -155,10 +159,17 @@ Deploy X to my org and run A,B and C apex tests.`,

let jobId: string = '';
try {
// Clear old conflict listeners for force deploy
if (input.ignoreConflicts) {
const lifecycle = Lifecycle.getInstance();
lifecycle.removeAllListeners('scopedPreDeploy');
}

const stl = await SourceTracking.create({
org,
project,
subscribeSDREvents: true,
ignoreConflicts: input.ignoreConflicts ?? false,
});

const componentSet = await buildDeployComponentSet(connection, project, stl, input.sourceDir, input.manifest);
Expand Down Expand Up @@ -226,3 +237,4 @@ async function buildDeployComponentSet(
const cs = (await stl.localChangesAsComponentSet(false))[0] ?? new ComponentSet(undefined, stl.registry);
return cs;
}

24 changes: 19 additions & 5 deletions packages/mcp-provider-dx-core/src/tools/retrieve_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { z } from 'zod';
import { Connection, Org, SfProject } from '@salesforce/core';
import { Connection, Lifecycle, Org, SfProject } from '@salesforce/core';
import { SourceTracking } from '@salesforce/source-tracking';
import { ComponentSet, ComponentSetBuilder } from '@salesforce/source-deploy-retrieve';
import { ensureString } from '@salesforce/ts-types';
Expand All @@ -33,12 +33,19 @@ import { textResponse } from '../shared/utils.js';
* - manifest: Full file path for manifest (XML file) of components to retrieve.
* - usernameOrAlias: Username or alias of the Salesforce org to retrieve from.
* - directory: Directory of the local project.
* - ignoreConflicts: Ignore conflicts and retrieve and save files to your local filesystem, even if they overwrite your local changes.
*
* Returns:
* - textResponse: Retrieve result.
*/

export const retrieveMetadataParams = z.object({
ignoreConflicts: z.boolean()
.describe(
'Ignore conflicts and retrieve and save files to your local filesystem, even if they overwrite your local changes.',
)
.optional()
.default(false),
sourceDir: z
.array(z.string())
.describe(
Expand Down Expand Up @@ -77,14 +84,15 @@ export class RetrieveMetadataMcpTool extends McpTool<InputArgsShape, OutputArgsS
description: `Retrieve metadata from an org to your local project.

AGENT INSTRUCTIONS:
If the user doesn't specify what to retrieve exactly ("retrieve my changes"), leave the "sourceDir" and "manifest" params empty so the tool calculates which files to retrieve.
If the user doesn't specify what to retrieve exactly ("retrieve my changes"), leave the "sourceDir", "ignoreConflicts", and "manifest" params empty so the tool calculates which files to retrieve.

EXAMPLE USAGE:
Retrieve changes
Retrieve changes from my org
Retrieve this file from my org
Retrieve the metadata in the manifest
Retrieve X metadata from my org`,
Retrieve X metadata from my org
Retrieve X metadata from my org and ignore any conflicts between the local project and org`,
inputSchema: retrieveMetadataParams.shape,
outputSchema: undefined,
annotations: {
Expand Down Expand Up @@ -113,22 +121,28 @@ Retrieve X metadata from my org`,

const org = await Org.create({ connection });

if (!input.sourceDir && !input.manifest && !(await org.tracksSource())) {
if (!input.sourceDir && !input.manifest && !input.ignoreConflicts && !(await org.tracksSource())) {
return textResponse(
'This org does not have source-tracking enabled or does not support source-tracking. You should specify the files or a manifest to retrieve.',
true,
);
}

try {
// Clear old conflict listeners for force retrieve
if (input.ignoreConflicts) {
const lifecycle = Lifecycle.getInstance();
lifecycle.removeAllListeners('scopedPreRetrieve');
}

const stl = await SourceTracking.create({
org,
project,
subscribeSDREvents: true,
ignoreConflicts: input.ignoreConflicts ?? false,
});

const componentSet = await buildRetrieveComponentSet(connection, project, stl, input.sourceDir, input.manifest);

if (componentSet.size === 0) {
// STL found no changes
return textResponse('No remote changes to retrieve were found.');
Expand Down
84 changes: 84 additions & 0 deletions packages/mcp-provider-dx-core/test/e2e/deploy_metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { expect, assert } from 'chai';
import { McpTestClient, DxMcpTransport } from '@salesforce/mcp-test-client';
import { TestSession } from '@salesforce/cli-plugins-testkit';
import { z } from 'zod';
import { AuthInfo, Connection } from '@salesforce/core';
import { ensureString } from '@salesforce/ts-types';
import { deployMetadataParams } from '../../src/tools/deploy_metadata.js';

Expand Down Expand Up @@ -227,4 +228,87 @@ describe('deploy_metadata', () => {
expect(deployResult.numberTestsCompleted).to.equal(11);
expect(deployResult.runTestsEnabled).to.be.true;
});

it('should deploy remote edit when ignoreConflicts is set to true', async () => {
const customAppPath = path.join(
testSession.project.dir,
'force-app',
'main',
'default',
'applications',
'Dreamhouse.app-meta.xml',
);

// deploy the whole project to ensure the file exists
const fullProjectDeploy = await client.callTool(deployMetadataSchema, {
name: 'deploy_metadata',
params: {
usernameOrAlias: orgUsername,
directory: testSession.project.dir,
},
});

expect(fullProjectDeploy.isError).to.be.false;
expect(fullProjectDeploy.content.length).to.equal(1);

// Make a remote edit using Tooling API
const conn = await Connection.create({
authInfo: await AuthInfo.create({ username: orgUsername }),
});

const customApp = await conn.singleRecordQuery<{
Id: string;
Metadata: {
description: string | null;
};
}>(
"SELECT Id, Metadata FROM CustomApplication WHERE DeveloperName = 'Dreamhouse'",
{
tooling: true,
}
);

const updatedMetadata = {
...customApp.Metadata,
description: customApp.Metadata.description
? `${customApp.Metadata.description} - Remote edit via Tooling API`
: 'Remote edit via Tooling API',
};

await conn.tooling.sobject('CustomApplication').update({
Id: customApp.Id,
Metadata: updatedMetadata,
});

// Deploy with ignoreConflicts=true - should override remote edit
const deployResult = await client.callTool(deployMetadataSchema, {
name: 'deploy_metadata',
params: {
sourceDir: [customAppPath],
ignoreConflicts: true,
usernameOrAlias: orgUsername,
directory: testSession.project.dir,
},
});

expect(deployResult.isError).to.equal(false);
expect(deployResult.content.length).to.equal(1);
if (deployResult.content[0].type !== 'text') assert.fail();

const deployText = deployResult.content[0].text;
expect(deployText).to.contain('Deploy result:');

const deployMatch = deployText.match(/Deploy result: ({.*})/);
expect(deployMatch).to.not.be.null;

const result = JSON.parse(deployMatch![1]) as {
success: boolean;
done: boolean;
numberComponentsDeployed: number;
};

expect(result.success).to.be.true;
expect(result.done).to.be.true;
expect(result.numberComponentsDeployed).to.equal(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,56 @@ describe('retrieve_metadata', () => {
expect(apexClasses.length).to.equal(9);
expect(packageXml.length).to.equal(1);
});

it('should retrieve and overwrite local edits when ignoreConflicts is true', async () => {
const apexClassPath = path.join('force-app', 'main', 'default', 'classes', 'GeocodingService.cls');
const localFilePath = path.join(testSession.project.dir, apexClassPath);
const originalContent = fs.readFileSync(localFilePath, 'utf8');
const localEditContent = originalContent + '\n// Local edit';

// Make a local edit to the file
fs.writeFileSync(localFilePath, localEditContent, 'utf8');

// Retrieve with ignoreConflicts=true - should overwrite local edit
const result = await client.callTool(retrieveMetadataSchema, {
name: 'retrieve_metadata',
params: {
sourceDir: [apexClassPath],
ignoreConflicts: true,
usernameOrAlias: orgUsername,
directory: testSession.project.dir,
},
});

expect(result.isError).to.equal(false);
expect(result.content.length).to.equal(1);
if (result.content[0].type !== 'text') assert.fail();

const responseText = result.content[0].text;
expect(responseText).to.contain('Retrieve result:');

// Parse the retrieve result JSON
const retrieveMatch = responseText.match(/Retrieve result: ({.*})/);
expect(retrieveMatch).to.not.be.null;

const retrieveResult = JSON.parse(retrieveMatch![1]) as {
success: boolean;
done: boolean;
fileProperties: Array<{
type: string;
fullName: string;
fileName: string;
}>;
};
expect(retrieveResult.success).to.be.true;
expect(retrieveResult.done).to.be.true;
expect(retrieveResult.fileProperties.length).to.equal(2);

const apexClass = retrieveResult.fileProperties.find(
(fp: { type: string; fullName: string }) => fp.type === 'ApexClass',
);
if (!apexClass) assert.fail();
expect(apexClass.fullName).to.equal('GeocodingService');
expect(apexClass.fileName).to.equal('unpackaged/classes/GeocodingService.cls');
});
});
Loading