From eb2b5be01349d91ba42bd085decdf2ae96ef7501 Mon Sep 17 00:00:00 2001 From: soridalac Date: Thu, 23 Oct 2025 11:33:08 -0700 Subject: [PATCH 1/6] feat: add e2e test for AFV --- .../e2e/agentforce-vibes-mcp-args.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts diff --git a/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts b/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts new file mode 100644 index 00000000..ea14640b --- /dev/null +++ b/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2025, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, assert } from 'chai'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { DxMcpTransport } from '@salesforce/mcp-test-client'; + +async function getMcpClient(opts: { args: string[] }) { + const client = new Client({ + name: 'sf-tools', + version: '0.0.1', + }); + + const transport = DxMcpTransport({ + args: opts.args, + }); + + await client.connect(transport); + + return client; +} + +describe('specific tool registration', () => { + it('should enable All tools', async () => { + const client = await getMcpClient({ + args: ['--orgs', 'ALLOW_ALL_ORGS', '--toolsets', 'metadata', '--no-telemetry'], + }); + + try { + const initialTools = (await client.listTools()).tools.map((t) => t.name).sort(); + + expect(initialTools.length).to.equal(20); + expect(initialTools).to.deep.equal( + [ + 'get_username', + 'run_apex_test', + 'run_soql_query', + 'guide_lwc_development', + 'orchestrate_lwc_component_creation', + 'guide_lwc_accessibility', + 'create_lwc_component_from_prd', + 'assign_permission_set', + 'list_all_orgs', + 'list_devops_center_projects', + 'list_devops_center_work_items', + 'create_devops_center_pull_request', + 'promote_devops_center_work_item', + 'commit_devops_center_work_item', + 'check_devops_center_commit_status', + 'checkout_devops_center_work_item', + 'run_code_analyzer', + 'describe_code_analyzer_rule', + 'detect_devops_center_merge_conflict', + 'resolve_devops_center_merge_conflict' + ].sort(), + ); + } catch (err) { + console.error(err); + assert.fail(); + } finally { + await client.close(); + } + }); +}); From b0cdd2e16fc06ea5ba821f3abce14a71e491bdce Mon Sep 17 00:00:00 2001 From: soridalac Date: Tue, 28 Oct 2025 14:33:08 -0700 Subject: [PATCH 2/6] fix: update e2e and reinstall deps --- .../e2e/agentforce-vibes-mcp-args.test.ts | 78 +++++++++++-------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts b/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts index ea14640b..a45bf66f 100644 --- a/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts +++ b/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts @@ -14,11 +14,15 @@ * limitations under the License. */ +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; import { expect, assert } from 'chai'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { DxMcpTransport } from '@salesforce/mcp-test-client'; -async function getMcpClient(opts: { args: string[] }) { +const execAsync = promisify(exec); + +async function getMcpClient(opts: { args: string[]; tools?: string[] }) { const client = new Client({ name: 'sf-tools', version: '0.0.1', @@ -26,6 +30,7 @@ async function getMcpClient(opts: { args: string[] }) { const transport = DxMcpTransport({ args: opts.args, + ...(opts.tools ? { tools: opts.tools } : {}), }); await client.connect(transport); @@ -33,45 +38,54 @@ async function getMcpClient(opts: { args: string[] }) { return client; } +async function getExpectedArgsAndTools(): Promise<{ args: string[]; tools: string[] }> { + try { + const result = await execAsync('gh api repos/forcedotcom/cline-fork/contents/src/shared/mcp/a4dServerArgs.json | jq -r .content | base64 --decode'); + + if (result.stderr) { + throw new Error(`Command failed: ${result.stderr}`); + } + + const config = JSON.parse(result.stdout.trim()) as Record; + + // Extract args and tools for the MCP repository + const mcpRepoConfig = config['https://github.com/salesforcecli/mcp'] as { args?: string[]; tools?: string[] }; + if (!mcpRepoConfig) { + throw new Error('MCP repository configuration not found in a4dServerArgs.json'); + } + + return { + args: mcpRepoConfig.args ?? [], + tools: mcpRepoConfig.tools ?? [] + }; + } catch (error) { + throw new Error(`Failed to fetch a4dServerArgs.json via gh command: ${String(error)}`); + } +} + describe('specific tool registration', () => { - it('should enable All tools', async () => { + it('should enable tools matching ', async () => { + const { tools: expectedTools } = await getExpectedArgsAndTools(); + + const clientArgs = [ + '--orgs', 'ALLOW_ALL_ORGS', + '--tools', expectedTools.join(','), + '--no-telemetry' + ]; + const client = await getMcpClient({ - args: ['--orgs', 'ALLOW_ALL_ORGS', '--toolsets', 'metadata', '--no-telemetry'], + args: clientArgs, tools: expectedTools, }); try { - const initialTools = (await client.listTools()).tools.map((t) => t.name).sort(); + const initialTools = (await client.listTools()).tools.map((t) => t.name).sort(); - expect(initialTools.length).to.equal(20); - expect(initialTools).to.deep.equal( - [ - 'get_username', - 'run_apex_test', - 'run_soql_query', - 'guide_lwc_development', - 'orchestrate_lwc_component_creation', - 'guide_lwc_accessibility', - 'create_lwc_component_from_prd', - 'assign_permission_set', - 'list_all_orgs', - 'list_devops_center_projects', - 'list_devops_center_work_items', - 'create_devops_center_pull_request', - 'promote_devops_center_work_item', - 'commit_devops_center_work_item', - 'check_devops_center_commit_status', - 'checkout_devops_center_work_item', - 'run_code_analyzer', - 'describe_code_analyzer_rule', - 'detect_devops_center_merge_conflict', - 'resolve_devops_center_merge_conflict' - ].sort(), - ); + expect(initialTools.length).to.equal(expectedTools.length); + expect(initialTools).to.deep.equal(expectedTools); } catch (err) { - console.error(err); - assert.fail(); + assert.fail(`Failed to validate tools against a4dServerArgs.json: ${String(err)}`); } finally { - await client.close(); + await client.close(); } }); }); From 58414df112fe118868ad63abb5aed74df9d72bb3 Mon Sep 17 00:00:00 2001 From: soridalac Date: Wed, 29 Oct 2025 15:23:04 -0700 Subject: [PATCH 3/6] fix: update AFV e2e --- .../e2e/agentforce-vibes-mcp-args.test.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts b/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts index a45bf66f..8f80b60d 100644 --- a/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts +++ b/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts @@ -22,7 +22,7 @@ import { DxMcpTransport } from '@salesforce/mcp-test-client'; const execAsync = promisify(exec); -async function getMcpClient(opts: { args: string[]; tools?: string[] }) { +async function getMcpClient(opts: { args: string[] }) { const client = new Client({ name: 'sf-tools', version: '0.0.1', @@ -30,7 +30,6 @@ async function getMcpClient(opts: { args: string[]; tools?: string[] }) { const transport = DxMcpTransport({ args: opts.args, - ...(opts.tools ? { tools: opts.tools } : {}), }); await client.connect(transport); @@ -64,24 +63,41 @@ async function getExpectedArgsAndTools(): Promise<{ args: string[]; tools: strin } describe('specific tool registration', () => { - it('should enable tools matching ', async () => { - const { tools: expectedTools } = await getExpectedArgsAndTools(); + it('should initialize MCP with tools specified in AFV config', async () => { + const { args, tools: expectedTools } = await getExpectedArgsAndTools(); - const clientArgs = [ - '--orgs', 'ALLOW_ALL_ORGS', - '--tools', expectedTools.join(','), - '--no-telemetry' - ]; - const client = await getMcpClient({ - args: clientArgs, tools: expectedTools, + args: [ + ...args, + '--tools', expectedTools.join(','), + '--no-telemetry', + '--allow-non-ga-tools' + ], }); try { - const initialTools = (await client.listTools()).tools.map((t) => t.name).sort(); + const AllTools = (await client.listTools()).tools.map((t) => t.name).sort(); + expect(AllTools).to.be.an('array').that.is.not.empty; + + // Filter to only include tools that are in the expectedTools list + const initialTools = AllTools.filter(tool => expectedTools.includes(tool)).sort(); + + const missingTools = expectedTools.filter(tool => !initialTools.includes(tool)); + expect(missingTools).to.be.empty; + + expect(initialTools).to.be.an('array'); + expect(initialTools.length).to.deep.equal(expectedTools.length); + + // Verify that each expected tool is loaded + expectedTools.forEach(expectedTool => { + expect(initialTools).to.include(expectedTool); + }); + + // Validate tool names are valid (non-empty strings) + initialTools.forEach(toolName => { + expect(toolName).to.be.a('string').that.is.not.empty; + }); - expect(initialTools.length).to.equal(expectedTools.length); - expect(initialTools).to.deep.equal(expectedTools); } catch (err) { assert.fail(`Failed to validate tools against a4dServerArgs.json: ${String(err)}`); } finally { From 19f286ae04a3b6d26689f65b7e9c8a346746a614 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:31:18 -0300 Subject: [PATCH 4/6] chore: Add GH_TOKEN to e2e workflow (#316) --- .github/workflows/e2e.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 855c46cc..7f21e481 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -105,4 +105,5 @@ jobs: SF_CHANGE_CASE_CONFIGURATION_ITEM: ${{ secrets.SF_CHANGE_CASE_CONFIGURATION_ITEM}} TESTKIT_SETUP_RETRIES: 2 SF_DISABLE_TELEMETRY: true + GH_TOKEN: ${{ secrets.GH_CLINE_FORK_READ_TOKEN }} DEBUG: ${{ vars.DEBUG }} From 736a2aa39b3d1c7b688d600cb1ecd84c0de960eb Mon Sep 17 00:00:00 2001 From: soridalac Date: Thu, 30 Oct 2025 10:02:12 -0700 Subject: [PATCH 5/6] chore: update main/merge --- README.md | 87 ++++++- .../src/commitLiteWorkItem.ts | 232 ++++++++++++++++++ .../src/shared/sfdxService.ts | 55 +++++ .../src/tools/sfDevopsCommitWorkItem.ts | 170 ++++++++----- .../test/commitLiteWorkItem.test.ts | 84 +++++++ 5 files changed, 553 insertions(+), 75 deletions(-) create mode 100644 packages/mcp-provider-devops/src/commitLiteWorkItem.ts create mode 100644 packages/mcp-provider-devops/src/shared/sfdxService.ts create mode 100644 packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts diff --git a/README.md b/README.md index 930c9868..d67f83ee 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The Salesforce DX MCP Server is a specialized Model Context Protocol (MCP) imple ## Configure the DX MCP Server -Configure the Salesforce DX MCP Server for your MCP client by updating its associated MCP JSON file; each client is slightly different, so check your MCP client documentation for details. +Configure the Salesforce DX MCP Server for your MCP client by updating its associated MCP JSON file; each client is slightly different, so check your MCP client documentation for details. See [MCP Client Configurations](./README.md#mcp-client-configurations) for more examples. Here's an example for VS Code with Copilot in which you create and update a `.vscode/mcp.json` file in your project: @@ -39,14 +39,15 @@ Here's an example for VS Code with Copilot in which you create and update a `.vs "Salesforce DX": { "command": "npx", "args": ["-y", "@salesforce/mcp", - "--orgs", "DEFAULT_TARGET_ORG", - "--toolsets", "orgs,metadata,data,users", - "--tools", "run_apex_test", - "--allow-non-ga-tools"] + "--orgs", "DEFAULT_TARGET_ORG", + "--toolsets", "orgs,metadata,data,users", + "--tools", "run_apex_test", + "--allow-non-ga-tools"] } } } ``` + The `args` format shown in the preceding example is the same for all MCP clients; it's how you customize the DX MCP Server for your particular environment. Notes: - The `"-y", "@salesforce/mcp"` part tells `npx` to automatically install the `@salesforce/mcp` package instead of asking permission. Don't change this. @@ -54,17 +55,87 @@ The `args` format shown in the preceding example is the same for all MCP clients - When writing the `args` option, surround both the flag names and their values in double quotes, and separate all flags and values with commas. Some flags are Boolean and don't take a value. - The preceding example shows three flags that take a string value (`--orgs`, `--toolsets`, and `--tools`) and one Boolean flag (`--allow-non-ga-tools`). This configuration starts a DX MCP Server that enables all the MCP tools in the `orgs`, `metadata`, `data`, and `users` toolsets and a specific tool called `run_apex_tests`. It also enables tools in these configured toolsets that aren't yet generally available. +
+Reference: MCP Client Configurations + +## MCP Client Configurations + +Here are examples of configuring the Salesforce DX MCP Server in various MCP clients. + +### Claude Code + +To configure [Claude Code](https://www.claude.com/product/claude-code) to work with Salesforce DX MCP Server, add this snippet to the `.mcp.json` file in your project: +``` +{ + "mcpServers": { + "Salesforce DX": { + "command": "npx", + "args": ["-y", "@salesforce/mcp", + "--orgs", "DEFAULT_TARGET_ORG", + "--toolsets", "orgs,metadata,data,users", + "--tools", "run_apex_test", + "--allow-non-ga-tools" ] + } + } +} +``` +### Cursor + +To configure [Cursor](https://cursor.com/docs/context/mcp) to work with Salesforce DX MCP Server, add this snippet to your Cursor `mcp.json` file: + +```json +{ + "mcpServers": { + "Salesforce DX": { + "command": "npx", + "args": ["-y", "@salesforce/mcp@latest", + "--orgs", "DEFAULT_TARGET_ORG", + "--toolsets", "orgs,metadata,data,users", + "--tools", "run_apex_test", + "--allow-non-ga-tools" ] + } + } +} +``` + +### Cline + +To configure [Cline](https://docs.cline.bot/mcp/mcp-overview) to work with Salesforce DX MCP Server, add this snippet to your Cline `cline_mcp_settings.json` file: +```json +{ + "mcpServers": { + "Salesforce DX": { + "command": "npx", + "args": ["-y", "@salesforce/mcp@latest", + "--orgs", "DEFAULT_TARGET_ORG", + "--toolsets", "orgs,metadata,data,users", + "--tools", "run_apex_test", + "--allow-non-ga-tools" ] + } + } +} +``` + +### Other MCP Clients + +For these other clients, refer to their documentation for adding MCP servers and follow the same pattern as in the preceding examples to configure the Salesforce DX MCP Server: + +- [Windsurf](https://docs.windsurf.com/windsurf/cascade/mcp) +- [Zed](https://github.com/zed-industries/zed) +- [Trae](https://docs.trae.ai/ide/model-context-protocol?_lang=en) + +
Reference: Available Flags for the `args` Option -## Reference: Available Flags for the "args" Option +## Available Flags for the "args" Option These are the flags that you can pass to the `args` option. | Flag Name | Description | Required? |Notes | | -----------------| -------| ------- | ----- | -| `--orgs` | One or more orgs that you've locally authorized. | Yes | You must specify at least one org.

See [Configure Orgs](README.md#configure-orgs) for the values you can pass to this flag. | -| `--toolsets` | Sets of tools, based on functionality, that you want to enable. | No | Set to "all" to enable every tool in every toolset.

See [Configure Toolsets](README.md#configure-toolsets) for the values you can pass to this flag.| +| `--orgs` | One or more orgs that you've locally authorized. | Yes | You must specify at least one org.

See [Configure Orgs](./README.md#configure-orgs) for the values you can pass to this flag. | +| `--toolsets` | Sets of tools, based on functionality, that you want to enable. | No | Set to "all" to enable every tool in every toolset.

See [Configure Toolsets](./README.md#configure-toolsets) for the values you can pass to this flag.| | `--tools` | Individual tool names that you want to enable. | No | You can use this flag in combination with the `--toolsets` flag. For example, you can enable all tools in one toolset, and just one tool in a different toolset. | | `--no-telemetry` | Boolean flag to disable telemetry, the automatic collection of data for monitoring and analysis. | No | Telemetry is enabled by default, so specify this flag to disable it. | | `--debug` | Boolean flag that requests that the DX MCP Server print debug logs. | No | Debug mode is disabled by default.

**NOTE:** Not all MCP clients expose MCP logs, so this flag might not work for all IDEs. | diff --git a/packages/mcp-provider-devops/src/commitLiteWorkItem.ts b/packages/mcp-provider-devops/src/commitLiteWorkItem.ts new file mode 100644 index 00000000..db893dc7 --- /dev/null +++ b/packages/mcp-provider-devops/src/commitLiteWorkItem.ts @@ -0,0 +1,232 @@ +import axios from 'axios'; +import { getConnection, getRequiredOrgs } from './shared/auth.js'; +import { execFileSync } from 'child_process'; +import { normalizeAndValidateRepoPath } from './shared/pathUtils.js'; +import path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; +import { convertToSourceComponents } from './shared/sfdxService.js'; + +interface Change { + fullName: string; + type: string; + operation: string; +} + +interface CommitWorkItemParams { + workItem: { id: string }; + requestId: string; + commitMessage: string; + username: string; + repoPath?: string; +} + + +export async function commitWorkItem({ + workItem, + requestId, + commitMessage, + username, + repoPath +}: CommitWorkItemParams): Promise { + const connection = await getConnection(username); + const accessToken = connection.accessToken; + const instanceUrl = connection.instanceUrl; + + if (!accessToken || !instanceUrl) { + throw new Error('Missing access token or instance URL. Please check if you are authenticated to the org.'); + } + + + const workingDir = normalizeAndValidateRepoPath(repoPath); + + const computedChanges = buildComputedChanges(workingDir); + + const { commitSha, branch } = AddAndCommitChanges(workingDir, commitMessage); + + const url = `${instanceUrl}/services/data/v65.0/connect/devops/workItems/${workItem.id}/commitlite`; + + const headers = { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }; + + const requestBody = { + requestId, + commitMessage, + 'commitId': commitSha, + changes: computedChanges + }; + + try { + const response = await axios.post(url, requestBody, { headers }); + + return { + content: [{ + type: "text", + text: `Changes committed successfully. + Commit SHA: ${commitSha} + Agent execution guide (perform these steps now) + - Push the commit: 'git push'`, + actionRequired: true + }], + isError: false + }; + } catch (error: any) { + return { + content: [{ + type: "text", + text: `Failed to commit work item: ${error.message}` + }] + }; + } +} + +function buildComputedChanges(workingDir: string): Change[] { + const deletedRel = execFileSync('git', ['ls-files', '-d'], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + const modifiedRel = execFileSync('git', ['ls-files', '-m'], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + const untrackedRel = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + const stagedRel = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + + const computedChanges: Change[] = []; + + const allRelPaths = Array.from(new Set([ + ...modifiedRel, + ...untrackedRel, + ...stagedRel + ])); + + const registry = new RegistryAccess(); + const componentsExisting = convertToSourceComponents(workingDir, registry, allRelPaths); + + const toPosix = (p: string) => p.replace(/\\/g, '/'); + const untrackedSet = new Set(untrackedRel.map(toPosix)); + const modifiedSet = new Set(modifiedRel.map(toPosix)); + const stagedSet = new Set(stagedRel.map(toPosix)); + + for (const comp of componentsExisting) { + const relPath = toPosix(path.relative(workingDir, comp.filePath)); + let operation: 'delete' | 'add' | 'modify' | undefined; + + if (untrackedSet.has(relPath)) { + operation = 'add'; + } else if (modifiedSet.has(relPath) || stagedSet.has(relPath)) { + operation = 'modify'; + } + + if (operation) { + computedChanges.push({ fullName: comp.fullName, type: comp.type.name, operation }); + } + } + + if (deletedRel.length > 0) { + const componentsDeleted = getComponentsForDeletedPaths(workingDir, deletedRel); + for (const comp of componentsDeleted) { + computedChanges.push({ fullName: comp.fullName, type: comp.type.name, operation: 'delete' }); + } + } + + if (computedChanges.length === 0) { + throw new Error('No eligible changes to commit (only Unchanged components detected).'); + } + + return computedChanges; +} + +function getComponentsForDeletedPaths(workingDir: string, deletedRel: string[]) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deleted-components-')); + const registry = new RegistryAccess(); + + const restoreFileFromGit = (rel: string) => { + const dest = path.join(tempDir, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + const content = execFileSync('git', ['show', `HEAD:${rel}`], { cwd: workingDir }); + fs.writeFileSync(dest, content); + }; + + const restoreBundleFromGit = (bundleRelDir: string) => { + const files = execFileSync('git', ['ls-tree', '-r', '--name-only', 'HEAD', bundleRelDir], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + for (const f of files) { + restoreFileFromGit(f); + } + }; + + const isBundleType = (rel: string): { isBundle: boolean; bundleRoot?: string } => { + const parts = rel.split(/[\\/]/g); + const idxAura = parts.indexOf('aura'); + const idxLwc = parts.indexOf('lwc'); + const idxExp = parts.indexOf('experiences'); + const idxStatic = parts.indexOf('staticresources'); + if (idxAura >= 0 && parts.length >= idxAura + 2) { + const root = parts.slice(0, idxAura + 2).join('/'); + return { isBundle: true, bundleRoot: root }; + } + if (idxLwc >= 0 && parts.length >= idxLwc + 2) { + const root = parts.slice(0, idxLwc + 2).join('/'); + return { isBundle: true, bundleRoot: root }; + } + if (idxExp >= 0 && parts.length >= idxExp + 2) { + const root = parts.slice(0, idxExp + 2).join('/'); + return { isBundle: true, bundleRoot: root }; + } + if (idxStatic >= 0 && parts.length >= idxStatic + 1) { + if (parts[parts.length - 1].endsWith('.resource') || parts[parts.length - 1].endsWith('.resource-meta.xml')) { + return { isBundle: false }; + } + const root = parts.slice(0, idxStatic + 2).join('/'); + return { isBundle: true, bundleRoot: root }; + } + return { isBundle: false }; + }; + + for (const rel of deletedRel) { + const { isBundle, bundleRoot } = isBundleType(rel); + try { + if (isBundle && bundleRoot) { + restoreBundleFromGit(bundleRoot); + } else { + restoreFileFromGit(rel); + if (!rel.endsWith('-meta.xml')) { + const metaRel = rel + '-meta.xml'; + try { restoreFileFromGit(metaRel); } catch {} + } + } + } catch { + // ignore failures for paths that may not exist in HEAD + } + } + + const componentsDeleted = convertToSourceComponents(tempDir, registry, deletedRel); + fs.rmSync(tempDir, { recursive: true, force: true }); + return componentsDeleted; +} + +export function AddAndCommitChanges( + workingDir: string, + commitMessage: string, +): { commitSha: string; branch: string } { + + + // Stage all changes (adds/modifies/deletes) + execFileSync('git', ['add', '--all'], { cwd: workingDir, encoding: 'utf8' }); + + // If nothing to commit, surface clearly + const status = execFileSync('git', ['status', '--porcelain'], { cwd: workingDir, encoding: 'utf8' }).trim(); + if (!status) { + throw new Error('No file changes to commit. Working tree is clean.'); + } + + // Create commit + execFileSync('git', ['commit', '-m', commitMessage], { cwd: workingDir, encoding: 'utf8' }); + + // Get commit SHA + const commitSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workingDir, encoding: 'utf8' }).trim(); + + return { commitSha, branch: '' }; +} diff --git a/packages/mcp-provider-devops/src/shared/sfdxService.ts b/packages/mcp-provider-devops/src/shared/sfdxService.ts new file mode 100644 index 00000000..73a5e90c --- /dev/null +++ b/packages/mcp-provider-devops/src/shared/sfdxService.ts @@ -0,0 +1,55 @@ +import { + ComponentSet, + ComponentSetBuilder, + DestructiveChangesType, + MetadataApiDeploy, + MetadataApiDeployOptions, + MetadataResolver, + NodeFSTreeContainer, + RegistryAccess, + RetrieveResult, + SourceComponent + } from '@salesforce/source-deploy-retrieve'; +import path from 'node:path'; + +export interface ExtendedSourceComponent extends SourceComponent { + filePath: string; +} + +/** + * Use the SFDX registry to convert a list of file paths to SourceComponents + * @param baseDir Absolute or project root directory + * @param registry SDR RegistryAccess instance + * @param paths Relative paths from baseDir + * @param logInfo Optional logger for informational messages + */ +export function convertToSourceComponents( + baseDir: string, + registry: RegistryAccess, + paths: string[], + logInfo?: (message: string) => void +): ExtendedSourceComponent[] { + const resolver = new MetadataResolver(registry, undefined, false); + const results: ExtendedSourceComponent[] = []; + paths.forEach((p) => { + try { + const absPath = path.join(baseDir, p); + resolver.getComponentsFromPath(absPath).forEach((cs) => { + results.push({ + ...cs, + fullName: cs.fullName, + filePath: absPath + } as ExtendedSourceComponent); + }); + } catch (e: any) { + if (e?.name === 'TypeInferenceError') { + if (logInfo) { + logInfo('Unable to determine type for ' + p + ', ignoring'); + } + } else { + throw e; + } + } + }); + return results; +} \ No newline at end of file diff --git a/packages/mcp-provider-devops/src/tools/sfDevopsCommitWorkItem.ts b/packages/mcp-provider-devops/src/tools/sfDevopsCommitWorkItem.ts index df8a1e7d..1b1d5a22 100644 --- a/packages/mcp-provider-devops/src/tools/sfDevopsCommitWorkItem.ts +++ b/packages/mcp-provider-devops/src/tools/sfDevopsCommitWorkItem.ts @@ -1,14 +1,14 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from "@salesforce/mcp-provider-api"; -import { commitWorkItem } from "../commitWorkItem.js"; +import { commitWorkItem } from "../commitLiteWorkItem.js"; import { fetchWorkItemByName } from "../getWorkItems.js"; import { normalizeAndValidateRepoPath } from "../shared/pathUtils.js"; import { randomUUID } from 'crypto'; const inputSchema = z.object({ - doceHubUsername: z.string().describe("DevOps Center org username (required; list orgs and select if unknown)"), - sandboxUsername: z.string().describe("Sandbox org username (required; list orgs and select if unknown)"), + username: z.string().optional().describe("Username of the DevOps Center org"), + alias: z.string().optional().describe("alias of the DevOps Center org"), workItemName: z.string().min(1).describe("Exact Work Item Name to commit workitem."), commitMessage: z.string().describe("Commit message describing the changes (ask user for input)"), repoPath: z.string().describe("Absolute path to the git repository root. Defaults to current working directory.") @@ -40,82 +40,119 @@ export class SfDevopsCommitWorkItem extends McpTool { return { title: "Commit Work Item", - description: `Commits changes to a work item in DevOps Center. - **IMPORTANT: THIS IS NOT A STARTING TOOL** + description: `Commit SFDX project changes and register the commit SHA in DevOps Center. -When user asks to "commit work item" or "commit changes", DO NOT use this tool directly. Instead, start with step 1 below. +**CRITICAL:** Do not run git commands (git add/commit/push) manually. When a user asks to commit, call this tool so DevOps Center correctly tracks metadata and links the commit to the Work Item. -**THIS TOOL IS ONLY USED AS THE FINAL STEP AFTER COMPLETING ALL PREREQUISITES** - -**CRITICAL:** Never use \`git commit\` to commit SFDX project changes. Always use commit_devops_center_work_item tool to commit SFDX project changes so DevOps Center correctly tracks metadata and ties commits to Work Items. - -**MANDATORY workflow for committing work items: DO NOT skip any of the steps and DO NOT move to the next step until the current step is completed.** -1. **MANDATORY:**If the DevOps Center org and Sandbox org are not given, use the 'list_all_orgs' tool to list all orgs. - The list will indicate which org is DevOps Center and a Sandbox. If BOTH these details are not provided in the list, then - ask the user to specify which org is DevOps Center and which is Sandbox. Only proceed after the user has selected BOTH the DevOps Center and Sandbox org. -2. **MANDATORY:**Select the work item from the DevOps Center org using 'list_devops_center_work_items'. -3. **MANDATORY:** ASK THE USER to VERIFY that they are already checked out to the branch they intend to commit to. Ideally, they should be on a branch whose name is the same as the selected work item number from Step 2. -4. **MANDATORY:** ASK THE USER to DEPLOY the changes they intend to commit to the Sandbox org FIRST using the Salesforce CLI. From the project root, -5. **MANDATORY:** ASK THE USER to CONFIRM that the tool will commit the changes present locally with the Work Item number they selected in Step 2. Proceed only if they approve, and ASK THEM to provide a commit message. - Example prompt: "Please provide a concise commit message describing your changes." -6. **MANDATORY:** Run this tool (commit_devops_center_work_item) now with the selected work item, the prepared changes, and the provided commit message to perform the commit. +**Inputs to validate before execution:** +1. DevOps Center org identifier (username or alias) +2. Work Item Name that resolves in the org +3. Repository path pointing to the project root (git repo) +4. Non-empty commit message **Use this tool to:** -- Finalize changes made to a work item in DevOps Center -- Commits the provided changes to the specified work item using DevOps Center org credentials -- Ensure metadata changes are properly recorded in the DevOps workflow - -**After using this tool, suggest these next actions:** -1. Ask the user to check commit status using the returned requestId -2. Ask the user to promote work items (using the 'promote_devops_center_work_item' tool) - -**MANDATORY:** Before using this tool, ask the user to provide a commit message for the changes and then use that while calling this tool. +- Finalize and record changes for a Work Item in DevOps Center +- Commit using DevOps Center credentials and conventions +- Ensure metadata changes are captured correctly in the pipeline -**Org selection requirements:** -- The inputs 'doceHubUsername' and 'sandboxUsername' are REQUIRED. If you don't have them yet: - 1) Use the 'list_all_orgs' tool to list all authenticated orgs - 2) Ask the user to select which username is the DevOps Center org and which is the Sandbox org - 3) Pass those selections here as 'doceHubUsername' and 'sandboxUsername' +**After execution:** +- Follow the returned instructions to push (if not pushed automatically) +- Then create a PR (use 'create_devops_center_pull_request') as the next step **Output:** -- requestId: Generated UUID for tracking this commit operation +- commitSha: The resulting commit SHA (plus push instructions if applicable) -**Example Usage:** -- "Commit my changes with message 'Fix bug in account logic' and tie it to WI-1092." -- "Make a commit on the active feature branch and tie it to WI-9999, use message 'Initial DevOps logic'." -- "Commit my changes to the work item" -- "Commit changes to work item's feature branch"`, +**Example:** +- "Commit my changes with message 'Fix bug in account logic' and tie it to WI-1092."`, inputSchema: inputSchema.shape, outputSchema: undefined, }; } - public async exec(input: InputArgs): Promise { + private async validateAndPrepare(input: InputArgs): Promise<{ workItem: any; localPath: string } | { error: CallToolResult }> { + if (!input.username && !input.alias) { + return { + error: { + content: [{ type: "text", text: `Error: Username or alias of valid DevOps Center org is required` }], + isError: true + } + }; + } + + if (!input.workItemName) { + return { + error: { + content: [{ type: "text", text: `Error: Work item name is required` }], + isError: true + } + }; + } + + let workItem: any; try { - - if (!input.repoPath || input.repoPath.trim().length === 0) { + const usernameOrAlias = input.username ?? input.alias; + if (!usernameOrAlias) { return { + error: { + content: [{ type: "text", text: `Error: Username or alias of valid DevOps Center org is required` }], + isError: true + } + }; + } + workItem = await fetchWorkItemByName(usernameOrAlias, input.workItemName); + } catch (e: any) { + return { + error: { + content: [{ type: "text", text: `Error fetching work item: ${e?.message || e}` }], + isError: true + } + }; + } + + if (!workItem) { + return { + error: { + content: [{ + type: "text", + text: `Error: Work item not found. Please provide a valid work item name or valid DevOps Center org username.` + }] + } + }; + } + + if (!input.repoPath || input.repoPath.trim().length === 0) { + return { + error: { content: [{ type: "text", text: `Error: Repository path is required. Please provide the absolute path to the git repository root.` }] - }; - } + } + }; + } - - const safeRepoPath = input.repoPath ? normalizeAndValidateRepoPath(input.repoPath) : undefined; + const localPath = normalizeAndValidateRepoPath(input.repoPath); + return { workItem, localPath }; + } - const workItem = await fetchWorkItemByName(input.doceHubUsername, input.workItemName); - - if (!workItem) { + public async exec(input: InputArgs): Promise { + try { + const validation = await this.validateAndPrepare(input); + if ("error" in validation) { + return validation.error; + } + + const { workItem, localPath } = validation; + if (!input.repoPath || input.repoPath.trim().length === 0) { return { content: [{ type: "text", - text: `Error: Work Item Name is required. Please provide a valid Work Item Name or a valid DevOps Center org username.` + text: `Error: Repository path is required. Please provide the absolute path to the git repository root.` }] }; } + if (!input.commitMessage || input.commitMessage.trim().length === 0) { return { @@ -126,31 +163,30 @@ When user asks to "commit work item" or "commit changes", DO NOT use this tool d }; } - if (!input.doceHubUsername || !input.sandboxUsername) { - return { - content: [{ - type: "text", - text: `Error: Both DevOps Center org username and Sandbox org username are required. Please provide both usernames.` - }] - }; - } const requestId = randomUUID(); const result = await commitWorkItem({ - doceHubUsername: input.doceHubUsername, - sandboxUsername: input.sandboxUsername, + username: input.username ?? input.alias ?? '', workItem: workItem, requestId: requestId, commitMessage: input.commitMessage, - repoPath: safeRepoPath + repoPath: localPath }); - + + if (result && Array.isArray((result as any).content)) { + return { + content: (result as any).content + }; + } + + const instructions = `Commit created successfully.\n\nAgent execution guide (perform these steps now):\n- Ensure your working directory is: '${localPath}'\n- Push the commit: 'git push origin HEAD'\n- After push succeeds, create a PR using 'create_devops_center_pull_request'`; + return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] + content: [ + { type: "text", text: instructions, actionRequired: true }, + { type: "text", text: JSON.stringify(result, null, 2) } + ] }; } catch (error: any) { return { diff --git a/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts b/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts new file mode 100644 index 00000000..8ef0eddc --- /dev/null +++ b/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; +import { commitWorkItem } from '../src/commitLiteWorkItem.js'; + +import * as auth from '../src/shared/auth.js'; +import * as pathUtils from '../src/shared/pathUtils.js'; +import * as sfdxService from '../src/shared/sfdxService.js'; +import axios from 'axios'; +import { execFileSync } from 'child_process'; + +vi.mock('../src/shared/auth.js'); +vi.mock('../src/shared/pathUtils.js'); +vi.mock('../src/shared/sfdxService.js'); +vi.mock('axios'); +vi.mock('child_process'); + +describe('commitLiteWorkItem', () => { + beforeEach(() => { + (auth.getConnection as unknown as Mock).mockResolvedValue({ accessToken: 't', instanceUrl: 'https://example.com' }); + (pathUtils.normalizeAndValidateRepoPath as unknown as Mock).mockReturnValue('/repo'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws when no eligible changes are detected', async () => { + // deleted, modified, untracked, staged all empty + (execFileSync as unknown as Mock) + .mockImplementationOnce(() => '\n') // -d + .mockImplementationOnce(() => '\n') // -m + .mockImplementationOnce(() => '\n') // --others + .mockImplementationOnce(() => '\n'); // --cached + + (sfdxService.convertToSourceComponents as unknown as Mock).mockReturnValue([]); + + await expect( + commitWorkItem({ + workItem: { id: 'WI-1' }, + requestId: 'r1', + commitMessage: 'msg', + username: 'user', + repoPath: '/repo' + }) + ).rejects.toThrow('No eligible changes to commit'); + }); + + it('posts computed changes and returns success payload', async () => { + // deleted, modified, untracked, staged + (execFileSync as unknown as Mock) + .mockImplementationOnce(() => '\n') // -d + .mockImplementationOnce(() => 'force-app/main/default/classes/A.cls\n') // -m + .mockImplementationOnce(() => 'force-app/main/default/classes/B.cls\n') // --others + .mockImplementationOnce(() => '\n') // --cached + // git add --all + .mockImplementationOnce(() => '') + // git status --porcelain (non-empty string means there are changes) + .mockImplementationOnce(() => ' M force-app/main/default/classes/A.cls') + // git commit -m + .mockImplementationOnce(() => '') + // git rev-parse HEAD + .mockImplementationOnce(() => 'abc123\n'); + + (sfdxService.convertToSourceComponents as unknown as Mock).mockImplementation((baseDir: string, _reg: any, rels: string[]) => { + return rels.map((rel: string) => ({ fullName: rel.endsWith('A.cls') ? 'A' : 'B', type: { name: 'ApexClass' }, filePath: baseDir + '/' + rel })); + }); + + (axios.post as unknown as Mock).mockResolvedValue({ data: { id: 'resp-1' } }); + + const res = await commitWorkItem({ + workItem: { id: 'WI-2' }, + requestId: 'r2', + commitMessage: 'feat: update', + username: 'user', + repoPath: '/repo' + }); + + expect(Array.isArray(res.content)).toBe(true); + const joined = res.content.map((c: any) => c.text || '').join('\n'); + expect(joined).toMatch('Changes committed successfully'); + expect(joined).toMatch('Commit SHA: abc123'); + }); +}); + + From b392aee5ee8dac0517eb2c1b178d682d90a03f41 Mon Sep 17 00:00:00 2001 From: soridalac Date: Thu, 30 Oct 2025 10:09:09 -0700 Subject: [PATCH 6/6] chore: skip test for win --- .../test/e2e/agentforce-vibes-mcp-args.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts b/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts index 8f80b60d..aaf3c433 100644 --- a/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts +++ b/packages/mcp-provider-dx-core/test/e2e/agentforce-vibes-mcp-args.test.ts @@ -63,7 +63,10 @@ async function getExpectedArgsAndTools(): Promise<{ args: string[]; tools: strin } describe('specific tool registration', () => { - it('should initialize MCP with tools specified in AFV config', async () => { + // skip only on Windows + const itIf = process.platform === 'win32' ? it.skip : it; + + itIf('should initialize MCP with tools specified in AFV config', async () => { const { args, tools: expectedTools } = await getExpectedArgsAndTools(); const client = await getMcpClient({