├── .github
└── workflows
│ └── npm-publish.yml
├── .gitignore
├── .idea
├── AugmentWebviewStateStore.xml
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── git_toolbox_blame.xml
├── git_toolbox_prj.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── jsLinters
│ └── eslint.xml
├── modules.xml
├── prettier.xml
├── sf-mcp.iml
├── shelf
│ ├── Uncommitted_changes_before_Checkout_at_4_8_25,_22_47_[Changes]
│ │ └── shelved.patch
│ └── Uncommitted_changes_before_Checkout_at_4_8_25__22_47__Changes_.xml
├── vcs.xml
└── workspace.xml
├── .prettierrc
├── CHANGELOG.md
├── CLAUDE.md
├── README.md
├── build
├── index.js
├── resources.js
├── sfCommands.js
└── utils.js
├── eslint.config.js
├── package-lock.json
├── package.json
├── run.sh
├── src
├── index.ts
├── resources.ts
├── sfCommands.ts
└── utils.ts
└── tsconfig.json
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
3 |
4 | name: Node.js Package
5 |
6 | on:
7 | release:
8 | types: [created]
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: actions/setup-node@v4
16 | with:
17 | node-version: 20
18 | - run: npm ci
19 | - run: npm test
20 |
21 | publish-npm:
22 | needs: build
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v4
26 | - uses: actions/setup-node@v4
27 | with:
28 | node-version: 20
29 | registry-url: https://registry.npmjs.org/
30 | - run: npm ci
31 | - run: npm publish
32 | env:
33 | NODE_AUTH_TOKEN: ${{secrets.NPMJS_TOKEN}}
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Node template
2 | # Logs
3 | logs
4 | *.log
5 | npm-debug.log*
6 | yarn-debug.log*
7 | yarn-error.log*
8 | lerna-debug.log*
9 | .pnpm-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
13 |
14 | # Runtime data
15 | pids
16 | *.pid
17 | *.seed
18 | *.pid.lock
19 |
20 | # Directory for instrumented libs generated by jscoverage/JSCover
21 | lib-cov
22 |
23 | # Coverage directory used by tools like istanbul
24 | coverage
25 | *.lcov
26 |
27 | # nyc test coverage
28 | .nyc_output
29 |
30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
31 | .grunt
32 |
33 | # Bower dependency directory (https://bower.io/)
34 | bower_components
35 |
36 | # node-waf configuration
37 | .lock-wscript
38 |
39 | # Compiled binary addons (https://nodejs.org/api/addons.html)
40 | build/Release
41 |
42 | # Dependency directories
43 | node_modules/
44 | jspm_packages/
45 |
46 | # Snowpack dependency directory (https://snowpack.dev/)
47 | web_modules/
48 |
49 | # TypeScript cache
50 | *.tsbuildinfo
51 |
52 | # Optional npm cache directory
53 | .npm
54 |
55 | # Optional eslint cache
56 | .eslintcache
57 |
58 | # Optional stylelint cache
59 | .stylelintcache
60 |
61 | # Microbundle cache
62 | .rpt2_cache/
63 | .rts2_cache_cjs/
64 | .rts2_cache_es/
65 | .rts2_cache_umd/
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variable files
77 | .env
78 | .env.development.local
79 | .env.test.local
80 | .env.production.local
81 | .env.local
82 |
83 | # parcel-bundler cache (https://parceljs.org/)
84 | .cache
85 | .parcel-cache
86 |
87 | # Next.js build output
88 | .next
89 | out
90 |
91 | # Nuxt.js build / generate output
92 | .nuxt
93 | dist
94 |
95 | # Gatsby files
96 | .cache/
97 | # Comment in the public line in if your project uses Gatsby and not Next.js
98 | # https://nextjs.org/blog/next-9-1#public-directory-support
99 | # public
100 |
101 | # vuepress build output
102 | .vuepress/dist
103 |
104 | # vuepress v2.x temp and cache directory
105 | .temp
106 | .cache
107 |
108 | # Docusaurus cache and generated files
109 | .docusaurus
110 |
111 | # Serverless directories
112 | .serverless/
113 |
114 | # FuseBox cache
115 | .fusebox/
116 |
117 | # DynamoDB Local files
118 | .dynamodb/
119 |
120 | # TernJS port file
121 | .tern-port
122 |
123 | # Stores VSCode versions used for testing VSCode extensions
124 | .vscode-test
125 |
126 | # yarn v2
127 | .yarn/cache
128 | .yarn/unplugged
129 | .yarn/build-state.yml
130 | .yarn/install-state.gz
131 | .pnp.*
132 |
133 |
--------------------------------------------------------------------------------
/.idea/AugmentWebviewStateStore.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/git_toolbox_blame.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/git_toolbox_prj.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/jsLinters/eslint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/prettier.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/sf-mcp.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/shelf/Uncommitted_changes_before_Checkout_at_4_8_25,_22_47_[Changes]/shelved.patch:
--------------------------------------------------------------------------------
1 | Index: .idea/vcs.xml
2 | IDEA additional info:
3 | Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
4 | <+>\n\n \n \n \n \n \n \n \n \n \n
5 | Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
6 | <+>UTF-8
7 | ===================================================================
8 | diff --git a/.idea/vcs.xml b/.idea/vcs.xml
9 | --- a/.idea/vcs.xml (revision da38db3187809b42c47604d9d078238d2d02705a)
10 | +++ b/.idea/vcs.xml (date 1744177016069)
11 | @@ -1,11 +1,5 @@
12 |
13 |
14 | -
15 | -
16 | -
17 | -
18 | -
19 | -
20 |
21 |
22 |
23 | Index: .idea/workspace.xml
24 | IDEA additional info:
25 | Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
26 | <+>\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n \n \n {\n "lastFilter": {\n "state": "OPEN",\n "assignee": "codefriar"\n }\n}\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n
\n \n \n \n \n {\n "selectedUrlAndAccountId": {\n "url": "git@github.com:codefriar/sf-mcp.git",\n "accountId": "cd1051c7-86d1-42aa-9984-58b0635d53d5"\n },\n "recentNewPullRequestHead": {\n "server": {\n "useHttp": false,\n "host": "github.com",\n "port": null,\n "suffix": null\n },\n "owner": "codefriar",\n "repository": "sf-mcp"\n }\n}\n \n \n \n {\n "associatedIndex": 4\n}\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n 1743015380248\n \n \n 1743015380248\n \n \n \n \n \n \n \n \n \n \n \n 1743142354973\n \n \n \n 1743142354973\n \n \n \n 1743143924176\n \n \n \n 1743143924176\n \n \n \n 1743144159287\n \n \n \n 1743144159287\n \n \n \n 1743144468869\n \n \n \n 1743144468869\n \n \n \n 1743613695726\n \n \n \n 1743613695726\n \n \n \n 1743826235821\n \n \n \n 1743826235821\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n feat\n roots\n Now with Roots\n Now using "roots" - configurable inputs to the MCP that set the project directories, including instructions in the readme\n \n \n \n \n
27 | Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
28 | <+>UTF-8
29 | ===================================================================
30 | diff --git a/.idea/workspace.xml b/.idea/workspace.xml
31 | --- a/.idea/workspace.xml (revision da38db3187809b42c47604d9d078238d2d02705a)
32 | +++ b/.idea/workspace.xml (date 1744177602290)
33 | @@ -7,15 +7,6 @@
34 |
35 |
36 |
37 | -
38 | -
39 | -
40 | -
41 | -
42 | -
43 | -
44 | -
45 | -
46 |
47 |
48 |
49 | @@ -90,6 +81,7 @@
50 | "RunOnceActivity.git.unshallow": "true",
51 | "git-widget-placeholder": "main",
52 | "js.linters.configure.manually.selectedeslint": "true",
53 | + "last_opened_file_path": "/Users/kpoorman/src/sf-mcp",
54 | "node.js.detected.package.eslint": "true",
55 | "node.js.detected.package.standard": "true",
56 | "node.js.detected.package.tslint": "true",
57 | @@ -97,7 +89,7 @@
58 | "node.js.selected.package.standard": "",
59 | "node.js.selected.package.tslint": "(autodetect)",
60 | "nodejs_package_manager_path": "npm",
61 | - "settings.editor.selected.configurable": "settings.javascript.linters.eslint",
62 | + "settings.editor.selected.configurable": "IlluminatedCloudApplicationConfigurable",
63 | "ts.external.directory.path": "/Applications/IntelliJ IDEA.app/Contents/plugins/javascript-plugin/jsLanguageServicesImpl/external",
64 | "vue.rearranger.settings.migration": "true"
65 | }
66 | @@ -126,6 +118,11 @@
67 |
68 |
69 |
70 | +
71 | +
72 | +
73 | +
74 | +
75 |
76 |
77 |
78 | @@ -195,13 +192,4 @@
79 |
80 |
81 |
82 | -
83 | - feat
84 | - roots
85 | - Now with Roots
86 | - Now using "roots" - configurable inputs to the MCP that set the project directories, including instructions in the readme
87 | -
88 | -
89 | -
90 | -
91 |
92 | \ No newline at end of file
93 |
--------------------------------------------------------------------------------
/.idea/shelf/Uncommitted_changes_before_Checkout_at_4_8_25__22_47__Changes_.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/workspace.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {
20 | "lastFilter": {
21 | "state": "OPEN",
22 | "assignee": "codefriar"
23 | }
24 | }
25 | {
26 | "selectedUrlAndAccountId": {
27 | "url": "git@github.com:codefriar/sf-mcp.git",
28 | "accountId": "cd1051c7-86d1-42aa-9984-58b0635d53d5"
29 | },
30 | "recentNewPullRequestHead": {
31 | "server": {
32 | "useHttp": false,
33 | "host": "github.com",
34 | "port": null,
35 | "suffix": null
36 | },
37 | "owner": "codefriar",
38 | "repository": "sf-mcp"
39 | }
40 | }
41 | {
42 | "associatedIndex": 4
43 | }
44 |
45 |
46 |
47 |
48 |
49 | {
50 | "keyToString": {
51 | "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
52 | "RunOnceActivity.ShowReadmeOnStart": "true",
53 | "RunOnceActivity.git.unshallow": "true",
54 | "git-widget-placeholder": "#2 on feat/contextualExecution",
55 | "js.linters.configure.manually.selectedeslint": "true",
56 | "node.js.detected.package.eslint": "true",
57 | "node.js.detected.package.standard": "true",
58 | "node.js.detected.package.tslint": "true",
59 | "node.js.selected.package.eslint": "/Users/kpoorman/src/sfMcp/node_modules/@eslint/eslintrc",
60 | "node.js.selected.package.standard": "",
61 | "node.js.selected.package.tslint": "(autodetect)",
62 | "nodejs_package_manager_path": "npm",
63 | "settings.editor.selected.configurable": "settings.javascript.linters.eslint",
64 | "ts.external.directory.path": "/Applications/IntelliJ IDEA.app/Contents/plugins/javascript-plugin/jsLanguageServicesImpl/external",
65 | "vue.rearranger.settings.migration": "true"
66 | }
67 | }
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | 1743015380248
88 |
89 |
90 | 1743015380248
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | 1743142354973
102 |
103 |
104 |
105 | 1743142354973
106 |
107 |
108 |
109 | 1743143924176
110 |
111 |
112 |
113 | 1743143924176
114 |
115 |
116 |
117 | 1743144159287
118 |
119 |
120 |
121 | 1743144159287
122 |
123 |
124 |
125 | 1743144468869
126 |
127 |
128 |
129 | 1743144468869
130 |
131 |
132 |
133 | 1743613695726
134 |
135 |
136 |
137 | 1743613695726
138 |
139 |
140 |
141 | 1743826235821
142 |
143 |
144 |
145 | 1743826235821
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "trailingComma": "es5",
4 | "singleQuote": true,
5 | "printWidth": 120,
6 | "tabWidth": 4,
7 | "useTabs": false,
8 | "endOfLine":"lf"
9 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ### [1.3.2](https://github.com/codefriar/sf-mcp/compare/v1.3.1...v1.3.2) (2025-05-27)
6 |
7 | ### [1.3.1](https://github.com/codefriar/sf-mcp/compare/v1.3.0...v1.3.1) (2025-04-09)
8 |
9 | ## [1.3.0](https://github.com/codefriar/sf-mcp/compare/v1.1.1...v1.3.0) (2025-04-09)
10 |
11 |
12 | ### Features
13 |
14 | * add contextual execution with project directory detection ([0c02e01](https://github.com/codefriar/sf-mcp/commit/0c02e0100da6906ea0ece9e26e0fd75ec0886044))
15 | * add Salesforce project directory handling for contextual command execution ([5c1ddc3](https://github.com/codefriar/sf-mcp/commit/5c1ddc3783a0e8e80f357dfb0c9c082e8710b36d))
16 | * **context directories:** All commands require a directory ([4b1d76b](https://github.com/codefriar/sf-mcp/commit/4b1d76b0b38c9b5b01b12efbed2ad107320af3c2))
17 | * **roots:** Now with Roots ([da38db3](https://github.com/codefriar/sf-mcp/commit/da38db3187809b42c47604d9d078238d2d02705a))
18 |
19 | ## [1.2.0](https://github.com/codefriar/sf-mcp/compare/v1.1.1...v1.2.0) (2025-04-09)
20 |
21 |
22 | ### Features
23 |
24 | * add contextual execution with project directory detection ([0c02e01](https://github.com/codefriar/sf-mcp/commit/0c02e0100da6906ea0ece9e26e0fd75ec0886044))
25 | * add Salesforce project directory handling for contextual command execution ([5c1ddc3](https://github.com/codefriar/sf-mcp/commit/5c1ddc3783a0e8e80f357dfb0c9c082e8710b36d))
26 | * **context directories:** All commands require a directory ([4b1d76b](https://github.com/codefriar/sf-mcp/commit/4b1d76b0b38c9b5b01b12efbed2ad107320af3c2))
27 | * **roots:** Now with Roots ([da38db3](https://github.com/codefriar/sf-mcp/commit/da38db3187809b42c47604d9d078238d2d02705a))
28 |
29 | ### [1.1.1](https://github.com/codefriar/sf-mcp/compare/v1.1.0...v1.1.1) (2025-04-02)
30 |
31 | ## 1.1.0 (2025-03-28)
32 |
33 |
34 | ### Features
35 |
36 | * **tools:** autodiscovery of tools ([532c685](https://github.com/codefriar/sf-mcp/commit/532c685aa8b22f01e81b4bfa69024c14a05d932d))
37 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # MCP Development Guide
2 |
3 | ## Build Commands
4 |
5 | - Build project: `npm run build`
6 | - Run the MCP server: `node build/index.js`
7 |
8 | ## Lint & Formatting
9 |
10 | - Format with Prettier: `npx prettier --write 'src/**/*.ts'`
11 | - Lint: `npx eslint 'src/**/*.ts'`
12 | - Type check: `npx tsc --noEmit`
13 |
14 | ## Testing
15 |
16 | - Run tests: `npm test`
17 | - Run a single test: `npm test -- -t 'test name'`
18 |
19 | ## Code Style Guidelines
20 |
21 | - Use ES modules (import/export) syntax
22 | - TypeScript strict mode enabled
23 | - Types: Use strong typing with TypeScript interfaces/types
24 | - Naming: camelCase for variables/functions, PascalCase for classes/interfaces
25 | - Error handling: Use try/catch with typed errors
26 | - Imports: Group by 3rd party, then local, alphabetized within groups
27 | - Async: Prefer async/await over raw Promises
28 | - Documentation: JSDoc for public APIs
29 | - Endpoint API naming following MCP conventions for resources/tools/prompts
30 |
31 | ## Project Description
32 |
33 | - This project seeks to create a Model Context Protocol Server that tools like Claude code, and Claude desktop can use to directly and intelligently interface with the Salesforce Command Line Interface. (CLI)
34 |
35 | ## Model Context Protocol (MCP) Architecture
36 |
37 | ### Core Components
38 | - **Hosts**: LLM applications (Claude Desktop, Claude Code) that initiate connections
39 | - **Clients**: Maintain connections with MCP servers
40 | - **Servers**: Provide context, tools, and prompts (this Salesforce CLI MCP server)
41 |
42 | ### MCP Primitives for Salesforce Integration
43 |
44 | #### Tools
45 | - Executable functions for Salesforce operations
46 | - Dynamic tool discovery and invocation
47 | - Tool annotations (read-only, destructive operations)
48 | - Key Salesforce tools to implement:
49 | - SOQL query execution
50 | - Record CRUD operations
51 | - Metadata deployment/retrieval
52 | - Org inspection and configuration
53 | - Apex execution and testing
54 |
55 | #### Resources
56 | - Expose Salesforce data and metadata
57 | - Unique URI identification for resources
58 | - Support for text and binary content
59 | - Salesforce resources to expose:
60 | - Object schemas and field definitions
61 | - Org configuration and limits
62 | - Deployment metadata
63 | - Code coverage reports
64 | - Flow definitions
65 |
66 | #### Prompts
67 | - Reusable prompt templates for Salesforce workflows
68 | - Dynamic arguments for context-aware interactions
69 | - Common Salesforce prompt patterns:
70 | - Data analysis and reporting
71 | - Code generation and review
72 | - Deployment guidance
73 | - Best practices recommendations
74 |
75 | #### Sampling
76 | - Allow server to request LLM completions
77 | - Human-in-the-loop approval for destructive operations
78 | - Fine-grained control over Salesforce operations
79 |
80 | ### Security Considerations
81 | - Input validation for all Salesforce CLI commands
82 | - Proper authentication with Salesforce orgs
83 | - Rate limiting to respect Salesforce API limits
84 | - Sanitization of external interactions
85 | - Secure handling of sensitive org data
86 |
87 | ### Transport
88 | - Primary: Stdio (standard input/output)
89 | - Alternative: HTTP with Server-Sent Events (SSE)
90 |
91 | ### Implementation Strategy
92 | 1. Start with core Salesforce CLI tools (query, describe, deploy)
93 | 2. Use TypeScript MCP SDK for type safety
94 | 3. Implement robust error handling for CLI failures
95 | 4. Provide clear tool descriptions and examples
96 | 5. Add progressive enhancement for advanced features
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Salesforce CLI MCP Server
2 |
3 | Model Context Protocol (MCP) server for providing Salesforce CLI functionality to LLM tools like Claude Desktop.
4 |
5 | ## Overview
6 |
7 | This MCP server wraps the Salesforce CLI (`sf`) command-line tool and exposes its commands as MCP tools and resources, allowing LLM-powered agents to:
8 |
9 | - View help information about Salesforce CLI topics and commands
10 | - Execute Salesforce CLI commands with appropriate parameters
11 | - Leverage Salesforce CLI capabilities in AI workflows
12 |
13 | ## Requirements
14 |
15 | - Node.js 18+ and npm
16 | - Salesforce CLI (`sf`) installed and configured
17 | - Your Salesforce org credentials configured in the CLI
18 |
19 | ## Installation
20 |
21 | ```bash
22 | # Clone the repository
23 | git clone
24 | cd sfMcp
25 |
26 | # Install dependencies
27 | npm install
28 | ```
29 |
30 | ## Usage
31 |
32 | ### Starting the server
33 |
34 | ```bash
35 | # Basic usage
36 | npm start
37 |
38 | # With project roots
39 | npm start /path/to/project1 /path/to/project2
40 | # or using the convenience script
41 | npm run with-roots /path/to/project1 /path/to/project2
42 |
43 | # As an npx package with roots
44 | npx -y codefriar/sf-mcp /path/to/project1 /path/to/project2
45 | ```
46 |
47 | The MCP server uses stdio transport, which can be used with MCP clients
48 | like the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) or Claude Desktop.
49 |
50 | ### Configuring in Claude Desktop
51 |
52 | To configure this MCP in Claude Desktop's `.claude.json` configuration:
53 |
54 | ```json
55 | {
56 | "tools": {
57 | "salesforce": {
58 | "command": "/path/to/node",
59 | "args": [
60 | "/path/to/sf-mcp/build/index.js",
61 | "/path/to/project1",
62 | "/path/to/project2"
63 | ]
64 | }
65 | }
66 | }
67 | ```
68 |
69 | Using the npm package directly:
70 |
71 | ```json
72 | {
73 | "tools": {
74 | "salesforce": {
75 | "command": "/path/to/npx",
76 | "args": [
77 | "-y",
78 | "codefriar/sf-mcp",
79 | "/path/to/project1",
80 | "/path/to/project2"
81 | ]
82 | }
83 | }
84 | }
85 | ```
86 |
87 | ### Development
88 |
89 | ```bash
90 | # Watch mode (recompiles on file changes)
91 | npm run dev
92 |
93 | # In another terminal
94 | npm start [optional project roots...]
95 | ```
96 |
97 | ## Available Tools and Resources
98 |
99 | This MCP server provides Salesforce CLI commands as MCP tools. It automatically discovers and registers all available commands from the Salesforce CLI, and also specifically implements the most commonly used commands.
100 |
101 | ### Core Tools
102 |
103 | - `sf_version` - Get the Salesforce CLI version information
104 | - `sf_help` - Get help information for Salesforce CLI commands
105 | - `sf_cache_clear` - Clear the command discovery cache
106 | - `sf_cache_refresh` - Refresh the command discovery cache
107 |
108 | ### Project Directory Management (Roots)
109 |
110 | For commands that require a Salesforce project context (like deployments), you must specify the project directory.
111 | The MCP supports multiple project directories (roots) similar to the filesystem MCP.
112 |
113 | #### Configuration Methods
114 |
115 | **Method 1: Via Command Line Arguments**
116 | ```bash
117 | # Start the MCP with project roots
118 | npm start /path/to/project1 /path/to/project2
119 | # or
120 | npx -y codefriar/sf-mcp /path/to/project1 /path/to/project2
121 | ```
122 |
123 | When configured this way, the roots will be automatically named `root1`, `root2`, etc.,
124 | with the first one set as default.
125 |
126 | **Method 2: Using MCP Tools**
127 | - `sf_set_project_directory` - Set a Salesforce project directory to use for commands
128 | - Parameters:
129 | - `directory` - Path to a directory containing a sfdx-project.json file
130 | - `name` - (Optional) Name for this project root
131 | - `description` - (Optional) Description for this project root
132 | - `isDefault` - (Optional) Set this root as the default for command execution
133 | - `sf_list_roots` - List all configured project roots
134 | - `sf_detect_project_directory` - Attempt to detect project directory from user messages
135 |
136 | Example usage:
137 | ```
138 | # Set project directory with a name
139 | sf_set_project_directory --directory=/path/to/your/sfdx/project --name=project1 --isDefault=true
140 |
141 | # List all configured roots
142 | sf_list_roots
143 |
144 | # Or include in your message:
145 | "Please deploy the apex code from the project in /path/to/your/sfdx/project to my scratch org"
146 | ```
147 |
148 | **Method 3: Claude Desktop Configuration**
149 | Configure project roots in `.claude.json` as described below.
150 |
151 | #### Using Project Roots
152 |
153 | You can execute commands in specific project roots:
154 | ```
155 | # Using resource URI
156 | sf://roots/project1/commands/project deploy start --sourcedir=force-app
157 |
158 | # Using rootName parameter
159 | sf_project_deploy_start --sourcedir=force-app --rootName=project1
160 | ```
161 |
162 | Project directory must be specified for commands such as deployments,
163 | source retrieval, and other project-specific operations.
164 | If multiple roots are configured, the default root will be used unless otherwise specified.
165 |
166 | ### Key Implemented Tools
167 |
168 | The following commands are specifically implemented and guaranteed to work:
169 |
170 | #### Organization Management
171 |
172 | - `sf_org_list` - List Salesforce orgs
173 | - Parameters: `json`, `verbose`
174 | - `sf_auth_list_orgs` - List authenticated Salesforce orgs
175 | - Parameters: `json`, `verbose`
176 | - `sf_org_display` - Display details about an org
177 | - Parameters: `targetusername`, `json`
178 | - `sf_org_open` - Open an org in the browser
179 | - Parameters: `targetusername`, `path`, `urlonly`
180 |
181 | #### Apex Code
182 |
183 | - `sf_apex_run` - Run anonymous Apex code
184 | - Parameters: `targetusername`, `file`, `apexcode`, `json`
185 | - `sf_apex_test_run` - Run Apex tests
186 | - Parameters: `targetusername`, `testnames`, `suitenames`, `classnames`, `json`
187 |
188 | #### Data Management
189 |
190 | - `sf_data_query` - Execute a SOQL query
191 | - Parameters: `targetusername`, `query`, `json`
192 | - `sf_schema_list_objects` - List sObjects in the org
193 | - Parameters: `targetusername`, `json`
194 | - `sf_schema_describe` - Describe a Salesforce object
195 | - Parameters: `targetusername`, `sobject`, `json`
196 |
197 | #### Deployment
198 |
199 | - `sf_project_deploy_start` - Deploy the source to an org
200 | - Parameters: `targetusername`, `sourcedir`, `json`, `wait`
201 |
202 | ### Dynamically Discovered Tools
203 |
204 | The server discovers all available Salesforce CLI commands and registers them as tools with format: `sf__`.
205 |
206 | For example:
207 |
208 | - `sf_apex_run` - Run anonymous Apex code
209 | - `sf_data_query` - Execute a SOQL query
210 |
211 | For nested topic commands, the tool name includes the full path with underscores:
212 |
213 | - `sf_apex_log_get` - Get apex logs
214 | - `sf_org_login_web` - Login to an org using web flow
215 |
216 | The server also creates simplified aliases for common nested commands where possible:
217 |
218 | - `sf_get` as an alias for `sf_apex_log_get`
219 | - `sf_web` as an alias for `sf_org_login_web`
220 |
221 | The available commands vary depending on the installed Salesforce CLI plugins.
222 |
223 | > **Note:** Command discovery is cached to improve startup performance. If you install new SF CLI plugins, use the `sf_cache_refresh` tool to update the cache, then restart the server.
224 |
225 | ### Resources
226 |
227 | The following resources provide documentation about Salesforce CLI:
228 |
229 | - `sf://help` - Main CLI documentation
230 | - `sf://topics/{topic}/help` - Topic help documentation
231 | - `sf://commands/{command}/help` - Command help documentation
232 | - `sf://topics/{topic}/commands/{command}/help` - Topic-command help documentation
233 | - `sf://version` - Version information
234 | - `sf://roots` - List all configured project roots
235 | - `sf://roots/{root}/commands/{command}` - Execute a command in a specific project root
236 |
237 | ## How It Works
238 |
239 | 1. At startup, the server checks for a cached list of commands (stored in `~/.sf-mcp/command-cache.json`)
240 | 2. If a valid cache exists, it's used to register commands; otherwise, commands are discovered dynamically
241 | 3. During discovery, the server queries `sf commands --json` to get a complete list of available commands
242 | 4. Command metadata (including parameters and descriptions) is extracted directly from the JSON output
243 | 5. All commands are registered as MCP tools with appropriate parameter schemas
244 | 6. Resources are registered for help documentation
245 | 7. When a tool is called, the corresponding Salesforce CLI command is executed
246 |
247 | ### Project Roots Management
248 |
249 | For commands that require a Salesforce project context:
250 |
251 | 1. The server checks if any project roots have been configured via `sf_set_project_directory`
252 | 2. If multiple roots are configured, it uses the default root unless a specific root is specified
253 | 3. If no roots are set, the server will prompt the user to specify a project directory
254 | 4. Commands are executed within the appropriate project directory, ensuring proper context
255 | 5. The user can add or switch between multiple project roots as needed
256 |
257 | Project-specific commands (like deployments, retrievals, etc.)
258 | will automatically run in the appropriate project directory.
259 | For commands that don't require a project context, the working directory doesn't matter.
260 |
261 | You can execute commands in specific project roots by:
262 | - Using the resource URI: `sf://roots/{rootName}/commands/{command}`
263 | - Providing a `rootName` parameter to command tools (internal implementation details)
264 | - Setting a specific root as the default with `sf_set_project_directory --isDefault=true`
265 |
266 | ### Command Caching
267 |
268 | To improve startup performance, the MCP server caches discovered commands:
269 |
270 | - The cache is stored in `~/.sf-mcp/command-cache.json`
271 | - It includes all topics, commands, parameters, and descriptions
272 | - The cache has a validation timestamp and SF CLI version check
273 | - By default, the cache expires after 7 days
274 | - When you install new Salesforce CLI plugins, use `sf_cache_refresh` to update the cache
275 |
276 | #### Troubleshooting Cache Issues
277 |
278 | The first run of the server performs a full command discovery which can take some time. If you encounter any issues with missing commands or cache problems:
279 |
280 | 1. Stop the MCP server (if running)
281 | 2. Manually delete the cache file: `rm ~/.sf-mcp/command-cache.json`
282 | 3. Start the server again: `npm start`
283 |
284 | This will force a complete rediscovery of all commands using the official CLI metadata.
285 |
286 | If specific commands are still missing, or you've installed new SF CLI plugins:
287 |
288 | 1. Use the `sf_cache_refresh` tool from Claude Desktop
289 | 2. Stop and restart the MCP server
290 |
291 | ### Handling Nested Topics
292 |
293 | The Salesforce CLI has a hierarchical command structure that can be several levels deep. This MCP server handles these nested commands by:
294 |
295 | - Converting colon-separated paths to underscore format (`apex:log:get` → `sf_apex_log_get`)
296 | - Providing aliases for common deep commands when possible (`sf_get` for `sf_apex_log_get`)
297 | - Preserving the full command hierarchy in the tool names
298 | - Using the official command structure from `sf commands --json`
299 |
300 | Nested topic commands are registered twice when possible—once with the full hierarchy name and once with a simplified alias,
301 | making them easier to discover and use.
302 |
303 | ## License
304 |
305 | ISC
306 |
--------------------------------------------------------------------------------
/build/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4 | import { z } from 'zod';
5 | import { registerSfCommands, clearCommandCache, refreshCommandCache, setProjectDirectory, getProjectRoots } from './sfCommands.js';
6 | import path from 'path';
7 | import { registerResources } from './resources.js';
8 | // Create an MCP server
9 | const server = new McpServer({
10 | name: 'Salesforce CLI MCP',
11 | version: '1.1.0',
12 | description: 'MCP server for Salesforce CLI integration',
13 | });
14 | // Only register utility tools that aren't SF CLI commands
15 | // These are utility functions that extend or manage the MCP server itself
16 | server.tool('sf_cache_clear', 'Clear the cached SF command metadata to force a refresh', {}, async () => {
17 | const result = clearCommandCache();
18 | return {
19 | content: [
20 | {
21 | type: 'text',
22 | text: result
23 | ? 'Command cache cleared successfully.'
24 | : 'Failed to clear command cache or cache did not exist.',
25 | },
26 | ]
27 | };
28 | });
29 | server.tool('sf_cache_refresh', 'Refresh the SF command cache by re-scanning all available commands', {}, async () => {
30 | const result = refreshCommandCache();
31 | return {
32 | content: [
33 | {
34 | type: 'text',
35 | text: result
36 | ? 'Command cache refreshed successfully. Restart the server to use the new cache.'
37 | : 'Failed to refresh command cache.',
38 | },
39 | ],
40 | };
41 | });
42 | // Tools for managing Salesforce project directories (roots)
43 | // Tool for automatically detecting project directories from messages
44 | server.tool('sf_detect_project_directory', 'Get instructions for setting up Salesforce project directories for command execution', {}, async () => {
45 | // Since we can't access the message in this version of MCP,
46 | // we need to rely on the LLM to extract the directory and use sf_set_project_directory
47 | return {
48 | content: [
49 | {
50 | type: 'text',
51 | text: 'To set a project directory, please use sf_set_project_directory with the path to your Salesforce project, or include the project path in your message using formats like "Execute in /path/to/project" or "Use project in /path/to/project".',
52 | },
53 | ],
54 | };
55 | });
56 | // Tool for explicitly setting a project directory (root)
57 | server.tool('sf_set_project_directory', 'Set a Salesforce project directory for command execution context', {
58 | directory: z.string().describe('The absolute path to a directory containing an sfdx-project.json file'),
59 | name: z.string().optional().describe('Optional name for this project root'),
60 | description: z.string().optional().describe('Optional description for this project root'),
61 | isDefault: z.boolean().optional().describe('Set this root as the default for command execution')
62 | }, async (params) => {
63 | // Set the project directory with optional metadata
64 | const result = setProjectDirectory(params.directory, {
65 | name: params.name,
66 | description: params.description,
67 | isDefault: params.isDefault
68 | });
69 | return {
70 | content: [
71 | {
72 | type: 'text',
73 | text: result
74 | ? `Successfully set Salesforce project root: ${params.directory}${params.name ? ` with name "${params.name}"` : ''}${params.isDefault ? ' (default)' : ''}`
75 | : `Failed to set project directory. Make sure the path exists and contains an sfdx-project.json file.`,
76 | },
77 | ],
78 | };
79 | });
80 | // Tool for listing configured project roots
81 | server.tool('sf_list_roots', 'List all configured Salesforce project directories and their metadata', {}, async () => {
82 | const roots = getProjectRoots();
83 | if (roots.length === 0) {
84 | return {
85 | content: [
86 | {
87 | type: 'text',
88 | text: 'No project roots configured. Use sf_set_project_directory to add a project root.'
89 | }
90 | ]
91 | };
92 | }
93 | // Format roots list for display
94 | const rootsList = roots.map(root => (`- ${root.name || path.basename(root.path)}${root.isDefault ? ' (default)' : ''}: ${root.path}${root.description ? `\n Description: ${root.description}` : ''}`)).join('\n\n');
95 | return {
96 | content: [
97 | {
98 | type: 'text',
99 | text: `Configured Salesforce project roots:\n\n${rootsList}`
100 | }
101 | ]
102 | };
103 | });
104 | // Start the server with stdio transport
105 | // We can't use middleware, so we'll rely on explicit tool use
106 | // The LLM will need to be instructed to look for project directory references
107 | // and call the sf_set_project_directory tool
108 | /**
109 | * Process command line arguments to detect and set project roots
110 | * All arguments that look like filesystem paths are treated as potential roots
111 | */
112 | function processRootPaths() {
113 | // Skip the first two arguments (node executable and script path)
114 | const args = process.argv.slice(2);
115 | if (!args || args.length === 0) {
116 | console.error('No arguments provided');
117 | return;
118 | }
119 | // Filter arguments that appear to be filesystem paths
120 | // A path typically starts with / or ./ or ../ or ~/ or contains a directory separator
121 | const rootPaths = args.filter(arg => arg.startsWith('/') ||
122 | arg.startsWith('./') ||
123 | arg.startsWith('../') ||
124 | arg.startsWith('~/') ||
125 | arg.includes('/') ||
126 | arg.includes('\\'));
127 | if (rootPaths.length === 0) {
128 | console.error('No project roots identified in CLI arguments');
129 | return;
130 | }
131 | console.error(`Configuring ${rootPaths.length} project roots from CLI arguments...`);
132 | // Process each provided path
133 | for (let i = 0; i < rootPaths.length; i++) {
134 | const rootPath = rootPaths[i];
135 | const isDefault = i === 0; // Make the first root the default
136 | const rootName = `root${i + 1}`;
137 | // Set up this root
138 | const result = setProjectDirectory(rootPath, {
139 | name: rootName,
140 | isDefault,
141 | description: `CLI-configured root #${i + 1}`
142 | });
143 | if (result) {
144 | console.error(`Configured project root #${i + 1}: ${rootPath}`);
145 | }
146 | else {
147 | console.error(`Failed to configure project root #${i + 1}: ${rootPath}`);
148 | }
149 | }
150 | }
151 | async function main() {
152 | try {
153 | // Process any command line arguments for project roots
154 | processRootPaths();
155 | // Register documentation resources
156 | registerResources(server);
157 | // Register all SF CLI commands as tools (dynamic discovery)
158 | const dynamicToolCount = await registerSfCommands(server);
159 | // Add the utility tools we registered manually
160 | const totalTools = dynamicToolCount + 5; // sf_cache_clear, sf_cache_refresh, sf_set_project_directory, sf_detect_project_directory, sf_list_roots
161 | console.error(`Total registered tools: ${totalTools} (${dynamicToolCount} SF CLI tools + 5 utility tools)`);
162 | console.error('Starting Salesforce CLI MCP Server...');
163 | const transport = new StdioServerTransport();
164 | await server.connect(transport);
165 | }
166 | catch (err) {
167 | console.error('Error starting server:', err);
168 | process.exit(1);
169 | }
170 | }
171 | main();
172 |
--------------------------------------------------------------------------------
/build/resources.js:
--------------------------------------------------------------------------------
1 | import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { executeSfCommand, getProjectRoots } from './sfCommands.js';
3 | /**
4 | * Register all resources for the SF CLI MCP Server
5 | */
6 | export function registerResources(server) {
7 | // Main CLI documentation
8 | server.resource('sf-help', 'sf://help', async (uri) => ({
9 | contents: [
10 | {
11 | uri: uri.href,
12 | text: executeSfCommand('-h'),
13 | },
14 | ],
15 | }));
16 | // Project roots information
17 | server.resource('sf-roots', 'sf://roots', async (uri) => {
18 | const roots = getProjectRoots();
19 | const rootsText = roots.length > 0
20 | ? roots.map(root => `${root.name}${root.isDefault ? ' (default)' : ''}: ${root.path}${root.description ? ` - ${root.description}` : ''}`).join('\n')
21 | : 'No project roots configured. Use sf_set_project_directory to add a project root.';
22 | return {
23 | contents: [
24 | {
25 | uri: uri.href,
26 | text: rootsText,
27 | },
28 | ],
29 | };
30 | });
31 | // Topic help documentation
32 | server.resource('sf-topic-help', new ResourceTemplate('sf://topics/{topic}/help', { list: undefined }), async (uri, { topic }) => ({
33 | contents: [
34 | {
35 | uri: uri.href,
36 | text: executeSfCommand(`${topic} -h`),
37 | },
38 | ],
39 | }));
40 | // Command help documentation
41 | server.resource('sf-command-help', new ResourceTemplate('sf://commands/{command}/help', { list: undefined }), async (uri, { command }) => ({
42 | contents: [
43 | {
44 | uri: uri.href,
45 | text: executeSfCommand(`${command} -h`),
46 | },
47 | ],
48 | }));
49 | // Topic-command help documentation
50 | server.resource('sf-topic-command-help', new ResourceTemplate('sf://topics/{topic}/commands/{command}/help', {
51 | list: undefined,
52 | }), async (uri, { topic, command }) => ({
53 | contents: [
54 | {
55 | uri: uri.href,
56 | text: executeSfCommand(`${topic} ${command} -h`),
57 | },
58 | ],
59 | }));
60 | // Root-specific command help (execute in a specific root)
61 | server.resource('sf-root-command', new ResourceTemplate('sf://roots/{root}/commands/{command}', { list: undefined }), async (uri, { root, command }) => ({
62 | contents: [
63 | {
64 | uri: uri.href,
65 | // Ensure command is treated as string
66 | text: executeSfCommand(String(command), String(root)),
67 | },
68 | ],
69 | }));
70 | // Version information
71 | server.resource('sf-version', 'sf://version', async (uri) => ({
72 | contents: [
73 | {
74 | uri: uri.href,
75 | text: executeSfCommand('--version'),
76 | },
77 | ],
78 | }));
79 | }
80 |
--------------------------------------------------------------------------------
/build/sfCommands.js:
--------------------------------------------------------------------------------
1 | import { execSync } from 'child_process';
2 | import { z } from 'zod';
3 | import { formatFlags } from './utils.js';
4 | import fs from 'fs';
5 | import path from 'path';
6 | import os from 'os';
7 | /**
8 | * List of topics to ignore during command discovery
9 | */
10 | const IGNORED_TOPICS = ['help', 'which', 'whatsnew', 'alias'];
11 | /**
12 | * Path to the cache file
13 | */
14 | const CACHE_DIR = path.join(os.homedir(), '.sf-mcp');
15 | const CACHE_FILE = path.join(CACHE_DIR, 'command-cache.json');
16 | const CACHE_MAX_AGE = 86400 * 7 * 1000; // 1 week in milliseconds
17 | /**
18 | * Clear the command cache
19 | */
20 | export function clearCommandCache() {
21 | try {
22 | if (fs.existsSync(CACHE_FILE)) {
23 | fs.unlinkSync(CACHE_FILE);
24 | console.error(`Removed cache file: ${CACHE_FILE}`);
25 | return true;
26 | }
27 | else {
28 | console.error(`Cache file does not exist: ${CACHE_FILE}`);
29 | return false;
30 | }
31 | }
32 | catch (error) {
33 | console.error('Error clearing command cache:', error);
34 | return false;
35 | }
36 | }
37 | /**
38 | * Manually force the cache to refresh
39 | */
40 | export function refreshCommandCache() {
41 | try {
42 | // Clear existing cache
43 | if (fs.existsSync(CACHE_FILE)) {
44 | fs.unlinkSync(CACHE_FILE);
45 | }
46 | // Create a fresh cache
47 | console.error('Refreshing SF command cache...');
48 | // Get all commands directly from sf commands --json
49 | const commands = getAllSfCommands();
50 | console.error(`Found ${commands.length} total commands for cache refresh`);
51 | // Save the cache
52 | saveCommandCache(commands);
53 | console.error('Cache refresh complete!');
54 | return true;
55 | }
56 | catch (error) {
57 | console.error('Error refreshing command cache:', error);
58 | return false;
59 | }
60 | }
61 | // Get the full path to the sf command
62 | const SF_BINARY_PATH = (() => {
63 | try {
64 | // Try to find the sf binary in common locations
65 | const possiblePaths = [
66 | '/Users/kpoorman/.volta/bin/sf', // The path we found earlier
67 | '/usr/local/bin/sf',
68 | '/usr/bin/sf',
69 | '/opt/homebrew/bin/sf',
70 | process.env.HOME + '/.npm/bin/sf',
71 | process.env.HOME + '/bin/sf',
72 | process.env.HOME + '/.nvm/versions/node/*/bin/sf',
73 | ];
74 | for (const path of possiblePaths) {
75 | try {
76 | if (execSync(`[ -x "${path}" ] && echo "exists"`, {
77 | encoding: 'utf8',
78 | }).trim() === 'exists') {
79 | return path;
80 | }
81 | }
82 | catch (e) {
83 | // Path doesn't exist or isn't executable, try the next one
84 | }
85 | }
86 | // If we didn't find it in a known location, try to get it from the PATH
87 | return 'sf';
88 | }
89 | catch (e) {
90 | console.error("Unable to locate sf binary, falling back to 'sf'");
91 | return 'sf';
92 | }
93 | })();
94 | const projectRoots = [];
95 | let defaultRootPath = null;
96 | /**
97 | * Validate a directory is a valid Salesforce project
98 | * @param directory The directory to validate
99 | * @returns boolean indicating if valid
100 | */
101 | function isValidSalesforceProject(directory) {
102 | const projectFilePath = path.join(directory, 'sfdx-project.json');
103 | return fs.existsSync(directory) && fs.existsSync(projectFilePath);
104 | }
105 | /**
106 | * Get all configured project roots
107 | * @returns Array of project roots
108 | */
109 | export function getProjectRoots() {
110 | return [...projectRoots];
111 | }
112 | /**
113 | * Get the default project directory (for backward compatibility)
114 | * @returns The default project directory or null if none set
115 | */
116 | export function getDefaultProjectDirectory() {
117 | return defaultRootPath;
118 | }
119 | /**
120 | * Set the Salesforce project directory to use for commands
121 | * @param directory The directory containing sfdx-project.json
122 | * @param options Optional parameters (name, description, isDefault)
123 | * @returns boolean indicating success
124 | */
125 | export function setProjectDirectory(directory, options = {}) {
126 | try {
127 | // Validate that the directory exists and contains an sfdx-project.json file
128 | if (!isValidSalesforceProject(directory)) {
129 | console.error(`Invalid Salesforce project: ${directory}`);
130 | return false;
131 | }
132 | // Check if this root already exists
133 | const existingIndex = projectRoots.findIndex(root => root.path === directory);
134 | if (existingIndex >= 0) {
135 | // Update existing root with new options
136 | projectRoots[existingIndex] = {
137 | ...projectRoots[existingIndex],
138 | ...options,
139 | path: directory
140 | };
141 | // If this is now the default root, update defaultRootPath
142 | if (options.isDefault) {
143 | // Remove default flag from other roots
144 | projectRoots.forEach((root, idx) => {
145 | if (idx !== existingIndex) {
146 | root.isDefault = false;
147 | }
148 | });
149 | defaultRootPath = directory;
150 | }
151 | console.error(`Updated Salesforce project root: ${directory}`);
152 | }
153 | else {
154 | // Add as new root
155 | const isDefault = options.isDefault ?? (projectRoots.length === 0);
156 | projectRoots.push({
157 | path: directory,
158 | name: options.name || path.basename(directory),
159 | description: options.description,
160 | isDefault
161 | });
162 | // If this is now the default root, update defaultRootPath
163 | if (isDefault) {
164 | // Remove default flag from other roots
165 | projectRoots.forEach((root, idx) => {
166 | if (idx !== projectRoots.length - 1) {
167 | root.isDefault = false;
168 | }
169 | });
170 | defaultRootPath = directory;
171 | }
172 | console.error(`Added Salesforce project root: ${directory}`);
173 | }
174 | // Always ensure we have exactly one default root if any roots exist
175 | if (projectRoots.length > 0 && !projectRoots.some(root => root.isDefault)) {
176 | projectRoots[0].isDefault = true;
177 | defaultRootPath = projectRoots[0].path;
178 | }
179 | return true;
180 | }
181 | catch (error) {
182 | console.error('Error setting project directory:', error);
183 | return false;
184 | }
185 | }
186 | /**
187 | * Checks if a command requires a Salesforce project context
188 | * @param command The SF command to check
189 | * @returns True if the command requires a Salesforce project context
190 | */
191 | function requiresSalesforceProjectContext(command) {
192 | // List of commands or command prefixes that require a Salesforce project context
193 | const projectContextCommands = [
194 | 'project deploy',
195 | 'project retrieve',
196 | 'project delete',
197 | 'project convert',
198 | 'package version create',
199 | 'package1 version create',
200 | 'source',
201 | 'mdapi',
202 | 'apex',
203 | 'lightning',
204 | 'schema generate'
205 | ];
206 | // Check if the command matches any of the project context commands
207 | return projectContextCommands.some(contextCmd => command.startsWith(contextCmd));
208 | }
209 | /**
210 | * Execute an sf command and return the results
211 | * @param command The sf command to run
212 | * @param rootName Optional specific root name to use for execution
213 | * @returns The stdout output from the command
214 | */
215 | export function executeSfCommand(command, rootName) {
216 | try {
217 | console.error(`Executing: ${SF_BINARY_PATH} ${command}`);
218 | // Check if target-org parameter is 'default' and replace with the default org
219 | if (command.includes('--target-org default') || command.includes('--target-org=default')) {
220 | // Get the default org from sf org list
221 | const orgListOutput = execSync(`"${SF_BINARY_PATH}" org list --json`, {
222 | encoding: 'utf8',
223 | maxBuffer: 10 * 1024 * 1024,
224 | });
225 | const orgList = JSON.parse(orgListOutput);
226 | let defaultUsername = '';
227 | // Look for the default org across different org types
228 | for (const orgType of ['nonScratchOrgs', 'scratchOrgs', 'sandboxes']) {
229 | if (orgList.result[orgType]) {
230 | const defaultOrg = orgList.result[orgType].find((org) => org.isDefaultUsername);
231 | if (defaultOrg) {
232 | defaultUsername = defaultOrg.username;
233 | break;
234 | }
235 | }
236 | }
237 | if (defaultUsername) {
238 | // Replace 'default' with the actual default org username
239 | command = command.replace(/--target-org[= ]default/, `--target-org ${defaultUsername}`);
240 | console.error(`Using default org: ${defaultUsername}`);
241 | }
242 | }
243 | // Determine which project directory to use
244 | let projectDir = null;
245 | // If rootName specified, find that specific root
246 | if (rootName) {
247 | const root = projectRoots.find(r => r.name === rootName);
248 | if (root) {
249 | projectDir = root.path;
250 | console.error(`Using specified root "${rootName}" at ${projectDir}`);
251 | }
252 | else {
253 | console.error(`Root "${rootName}" not found, falling back to default root`);
254 | // Fall back to default
255 | projectDir = defaultRootPath;
256 | }
257 | }
258 | else {
259 | // Use default root
260 | projectDir = defaultRootPath;
261 | }
262 | // Check if this command requires a Salesforce project context and we don't have a project directory
263 | if (requiresSalesforceProjectContext(command) && !projectDir) {
264 | return `This command requires a Salesforce project context (sfdx-project.json).
265 | Please specify a project directory using the format:
266 | "Execute in " or "Use project in "`;
267 | }
268 | try {
269 | // Always execute in project directory if available
270 | if (projectDir) {
271 | console.error(`Executing command in Salesforce project directory: ${projectDir}`);
272 | // Execute the command within the specified project directory
273 | const result = execSync(`"${SF_BINARY_PATH}" ${command}`, {
274 | encoding: 'utf8',
275 | maxBuffer: 10 * 1024 * 1024,
276 | env: {
277 | ...process.env,
278 | PATH: process.env.PATH,
279 | },
280 | cwd: projectDir,
281 | stdio: ['pipe', 'pipe', 'pipe'] // Capture stderr too
282 | });
283 | console.error('Command execution successful');
284 | return result;
285 | }
286 | else {
287 | // Standard execution for when no project directory is set
288 | return execSync(`"${SF_BINARY_PATH}" ${command}`, {
289 | encoding: 'utf8',
290 | maxBuffer: 10 * 1024 * 1024,
291 | env: {
292 | ...process.env,
293 | PATH: process.env.PATH,
294 | },
295 | });
296 | }
297 | }
298 | catch (execError) {
299 | console.error(`Error executing command: ${execError.message}`);
300 | // Capture both stdout and stderr for better error diagnostics
301 | let errorOutput = '';
302 | if (execError.stdout) {
303 | errorOutput += execError.stdout;
304 | }
305 | if (execError.stderr) {
306 | errorOutput += `\n\nError details: ${execError.stderr}`;
307 | }
308 | if (errorOutput) {
309 | console.error(`Command output: ${errorOutput}`);
310 | return errorOutput;
311 | }
312 | return `Error executing command: ${execError.message}`;
313 | }
314 | }
315 | catch (error) {
316 | console.error(`Top-level error executing command: ${error.message}`);
317 | // Capture both stdout and stderr
318 | let errorOutput = '';
319 | if (error.stdout) {
320 | errorOutput += error.stdout;
321 | }
322 | if (error.stderr) {
323 | errorOutput += `\n\nError details: ${error.stderr}`;
324 | }
325 | if (errorOutput) {
326 | console.error(`Command output: ${errorOutput}`);
327 | return errorOutput;
328 | }
329 | return `Error executing command: ${error.message}`;
330 | }
331 | }
332 | /**
333 | * Get all Salesforce CLI commands using 'sf commands --json'
334 | */
335 | function getAllSfCommands() {
336 | try {
337 | console.error("Fetching all SF CLI commands via 'sf commands --json'...");
338 | // Execute the command to get all commands in JSON format
339 | const commandsJson = executeSfCommand('commands --json');
340 | const allCommands = JSON.parse(commandsJson);
341 | console.error(`Found ${allCommands.length} total commands from 'sf commands --json'`);
342 | // Filter out commands from ignored topics
343 | const filteredCommands = allCommands.filter((cmd) => {
344 | if (!cmd.id)
345 | return false;
346 | // For commands with colons (topic:command format), check if the topic should be ignored
347 | if (cmd.id.includes(':')) {
348 | const topic = cmd.id.split(':')[0].toLowerCase();
349 | return !IGNORED_TOPICS.includes(topic);
350 | }
351 | // For standalone commands, check if the command itself should be ignored
352 | return !IGNORED_TOPICS.includes(cmd.id.toLowerCase());
353 | });
354 | console.error(`After filtering ignored topics, ${filteredCommands.length} commands remain`);
355 | // Transform JSON commands to SfCommand format
356 | const sfCommands = filteredCommands.map((jsonCmd) => {
357 | // Parse the command structure from its ID
358 | const commandParts = jsonCmd.id.split(':');
359 | const isTopicCommand = commandParts.length > 1;
360 | // For commands like "apex:run", extract name and topic
361 | let commandName = isTopicCommand ? commandParts[commandParts.length - 1] : jsonCmd.id;
362 | let topic = isTopicCommand ? commandParts.slice(0, commandParts.length - 1).join(':') : undefined;
363 | // The full command with spaces instead of colons for execution
364 | const fullCommand = jsonCmd.id.replace(/:/g, ' ');
365 | // Convert flags from JSON format to SfFlag format
366 | const flags = Object.entries(jsonCmd.flags || {}).map(([flagName, flagDetails]) => {
367 | return {
368 | name: flagName,
369 | char: flagDetails.char,
370 | description: flagDetails.description || '',
371 | required: !!flagDetails.required,
372 | type: flagDetails.type || 'string',
373 | options: flagDetails.options,
374 | default: flagDetails.default,
375 | };
376 | });
377 | return {
378 | id: jsonCmd.id,
379 | name: commandName,
380 | description: jsonCmd.summary || jsonCmd.description || jsonCmd.id,
381 | fullCommand,
382 | flags,
383 | topic,
384 | };
385 | });
386 | console.error(`Successfully processed ${sfCommands.length} commands`);
387 | return sfCommands;
388 | }
389 | catch (error) {
390 | console.error('Error getting SF commands:', error);
391 | return [];
392 | }
393 | }
394 | /**
395 | * Convert an SF command to a schema object for validation
396 | */
397 | function commandToZodSchema(command) {
398 | const schemaObj = {};
399 | for (const flag of command.flags) {
400 | let flagSchema;
401 | // Convert flag type to appropriate Zod schema
402 | switch (flag.type) {
403 | case 'number':
404 | case 'integer':
405 | case 'int':
406 | flagSchema = z.number();
407 | break;
408 | case 'boolean':
409 | case 'flag':
410 | flagSchema = z.boolean();
411 | break;
412 | case 'array':
413 | case 'string[]':
414 | flagSchema = z.array(z.string());
415 | break;
416 | case 'json':
417 | case 'object':
418 | flagSchema = z.union([z.string(), z.record(z.any())]);
419 | break;
420 | case 'file':
421 | case 'directory':
422 | case 'filepath':
423 | case 'path':
424 | case 'email':
425 | case 'url':
426 | case 'date':
427 | case 'datetime':
428 | case 'id':
429 | default:
430 | // For options-based flags, create an enum schema
431 | if (flag.options && flag.options.length > 0) {
432 | flagSchema = z.enum(flag.options);
433 | }
434 | else {
435 | flagSchema = z.string();
436 | }
437 | }
438 | // Add description
439 | if (flag.description) {
440 | flagSchema = flagSchema.describe(flag.description);
441 | }
442 | // Make required or optional based on flag definition
443 | schemaObj[flag.name] = flag.required ? flagSchema : flagSchema.optional();
444 | }
445 | return schemaObj;
446 | }
447 | /**
448 | * Get the SF CLI version to use for cache validation
449 | */
450 | function getSfVersion() {
451 | try {
452 | const versionOutput = executeSfCommand('--version');
453 | const versionMatch = versionOutput.match(/sf\/(\d+\.\d+\.\d+)/);
454 | return versionMatch ? versionMatch[1] : 'unknown';
455 | }
456 | catch (error) {
457 | console.error('Error getting SF version:', error);
458 | return 'unknown';
459 | }
460 | }
461 | /**
462 | * Saves the SF command data to cache
463 | */
464 | function saveCommandCache(commands) {
465 | try {
466 | // Create cache directory if it doesn't exist
467 | if (!fs.existsSync(CACHE_DIR)) {
468 | fs.mkdirSync(CACHE_DIR, { recursive: true });
469 | }
470 | const sfVersion = getSfVersion();
471 | const cache = {
472 | version: sfVersion,
473 | timestamp: Date.now(),
474 | commands,
475 | };
476 | fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
477 | console.error(`Command cache saved to ${CACHE_FILE} (SF version: ${sfVersion})`);
478 | }
479 | catch (error) {
480 | console.error('Error saving command cache:', error);
481 | }
482 | }
483 | /**
484 | * Loads the SF command data from cache
485 | * Returns null if cache is missing, invalid, or expired
486 | */
487 | function loadCommandCache() {
488 | try {
489 | if (!fs.existsSync(CACHE_FILE)) {
490 | console.error('Command cache file does not exist');
491 | return null;
492 | }
493 | const cacheData = fs.readFileSync(CACHE_FILE, 'utf8');
494 | const cache = JSON.parse(cacheData);
495 | // Validate cache structure
496 | if (!cache.version || !cache.timestamp || !Array.isArray(cache.commands)) {
497 | console.error('Invalid cache structure');
498 | return null;
499 | }
500 | // Check if cache is expired
501 | const now = Date.now();
502 | if (now - cache.timestamp > CACHE_MAX_AGE) {
503 | console.error('Cache is expired');
504 | return null;
505 | }
506 | // Verify that SF version matches
507 | const currentVersion = getSfVersion();
508 | if (cache.version !== currentVersion) {
509 | console.error(`Cache version mismatch. Cache: ${cache.version}, Current: ${currentVersion}`);
510 | return null;
511 | }
512 | console.error(`Using command cache from ${new Date(cache.timestamp).toLocaleString()} (SF version: ${cache.version})`);
513 | console.error(`Found ${cache.commands.length} commands in cache`);
514 | return cache.commands;
515 | }
516 | catch (error) {
517 | console.error('Error loading command cache:', error);
518 | return null;
519 | }
520 | }
521 | /**
522 | * Register all SF commands as MCP tools
523 | * @returns The total number of registered tools
524 | */
525 | export async function registerSfCommands(server) {
526 | try {
527 | console.error('Starting SF command registration');
528 | // Try to load commands from cache first
529 | let sfCommands = loadCommandCache();
530 | // If cache doesn't exist or is invalid, fetch commands directly
531 | if (!sfCommands) {
532 | console.error('Cache not available or invalid, fetching commands directly');
533 | sfCommands = getAllSfCommands();
534 | // Save to cache for future use
535 | saveCommandCache(sfCommands);
536 | }
537 | // List of manually defined tools to avoid conflicts
538 | // Only includes the utility cache management tools
539 | const reservedTools = ['sf_cache_clear', 'sf_cache_refresh'];
540 | // Keep track of registered tools and aliases to avoid duplicates
541 | const registeredTools = new Set(reservedTools);
542 | const registeredAliases = new Set();
543 | // Register all commands as tools
544 | let toolCount = 0;
545 | for (const command of sfCommands) {
546 | try {
547 | // Create appropriate MCP-valid tool name
548 | let toolName;
549 | if (command.topic) {
550 | // For commands with topics, format as "sf_topic_command"
551 | toolName = `sf_${command.topic.replace(/:/g, '_')}_${command.name}`.replace(/[^a-zA-Z0-9_-]/g, '_');
552 | }
553 | else {
554 | // Standalone commands - sf_command
555 | toolName = `sf_${command.name}`.replace(/[^a-zA-Z0-9_-]/g, '_');
556 | }
557 | // Ensure tool name meets length requirements (1-64 characters)
558 | if (toolName.length > 64) {
559 | toolName = toolName.substring(0, 64);
560 | }
561 | // Skip if this tool name conflicts with a manually defined tool or is already registered
562 | if (registeredTools.has(toolName)) {
563 | console.error(`Skipping ${toolName} because it's already registered`);
564 | continue;
565 | }
566 | const zodSchema = commandToZodSchema(command);
567 | // Register the command as a tool with description
568 | server.tool(toolName, command.description, zodSchema, async (flags) => {
569 | const flagsStr = formatFlags(flags);
570 | const commandStr = `${command.fullCommand} ${flagsStr}`;
571 | console.error(`Executing: sf ${commandStr}`);
572 | try {
573 | const output = executeSfCommand(commandStr);
574 | // Check if the output indicates an error but was returned as normal output
575 | if (output && (output.includes('Error executing command') || output.includes('Error details:'))) {
576 | console.error(`Command returned error: ${output}`);
577 | return {
578 | content: [
579 | {
580 | type: 'text',
581 | text: output,
582 | },
583 | ],
584 | isError: true,
585 | };
586 | }
587 | return {
588 | content: [
589 | {
590 | type: 'text',
591 | text: output,
592 | },
593 | ],
594 | };
595 | }
596 | catch (error) {
597 | console.error(`Error executing ${commandStr}:`, error);
598 | const errorMessage = error.stdout || error.stderr || error.message || 'Unknown error';
599 | return {
600 | content: [
601 | {
602 | type: 'text',
603 | text: `Error: ${errorMessage}`,
604 | },
605 | ],
606 | isError: true,
607 | };
608 | }
609 | });
610 | // Add to registered tools set and increment counter
611 | registeredTools.add(toolName);
612 | toolCount++;
613 | // For nested commands, create simplified aliases when possible
614 | // (e.g., sf_get for sf_apex_log_get)
615 | if (command.topic && command.topic.includes(':') && command.name.length > 2) {
616 | const simplifiedName = command.name.toLowerCase();
617 | const simplifiedToolName = `sf_${simplifiedName}`.replace(/[^a-zA-Z0-9_-]/g, '_');
618 | // Skip if the simplified name is already registered as a tool or alias
619 | if (registeredTools.has(simplifiedToolName) || registeredAliases.has(simplifiedToolName)) {
620 | continue;
621 | }
622 | // Register simplified alias with description
623 | try {
624 | server.tool(simplifiedToolName, `Alias for ${command.description}`, zodSchema, async (flags) => {
625 | const flagsStr = formatFlags(flags);
626 | const commandStr = `${command.fullCommand} ${flagsStr}`;
627 | console.error(`Executing (via alias ${simplifiedToolName}): sf ${commandStr}`);
628 | try {
629 | const output = executeSfCommand(commandStr);
630 | // Check if the output indicates an error but was returned as normal output
631 | if (output && (output.includes('Error executing command') || output.includes('Error details:'))) {
632 | console.error(`Command returned error: ${output}`);
633 | return {
634 | content: [
635 | {
636 | type: 'text',
637 | text: output,
638 | },
639 | ],
640 | isError: true,
641 | };
642 | }
643 | return {
644 | content: [
645 | {
646 | type: 'text',
647 | text: output,
648 | },
649 | ],
650 | };
651 | }
652 | catch (error) {
653 | console.error(`Error executing ${commandStr}:`, error);
654 | const errorMessage = error.stdout || error.stderr || error.message || 'Unknown error';
655 | return {
656 | content: [
657 | {
658 | type: 'text',
659 | text: `Error: ${errorMessage}`,
660 | },
661 | ],
662 | isError: true,
663 | };
664 | }
665 | });
666 | // Add alias to tracking sets and increment counter
667 | registeredAliases.add(simplifiedToolName);
668 | registeredTools.add(simplifiedToolName);
669 | toolCount++;
670 | console.error(`Registered alias ${simplifiedToolName} for ${toolName}`);
671 | }
672 | catch (err) {
673 | console.error(`Error registering alias ${simplifiedToolName}:`, err);
674 | }
675 | }
676 | }
677 | catch (err) {
678 | console.error(`Error registering tool for command ${command.id}:`, err);
679 | }
680 | }
681 | const totalTools = toolCount + registeredAliases.size;
682 | console.error(`Registration complete. Registered ${totalTools} tools (${toolCount} commands and ${registeredAliases.size} aliases).`);
683 | // Return the count for the main server to use
684 | return totalTools;
685 | }
686 | catch (error) {
687 | console.error('Error registering SF commands:', error);
688 | return 0;
689 | }
690 | }
691 |
--------------------------------------------------------------------------------
/build/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Utility functions for working with the Salesforce CLI
3 | */
4 | /**
5 | * Parse a user message to look for project directory specification
6 | * @param message A message from the user that might contain project directory specification
7 | * @returns The extracted directory path, or null if none found
8 | */
9 | export function extractProjectDirectoryFromMessage(message) {
10 | if (!message)
11 | return null;
12 | // Common patterns for specifying project directories
13 | const patterns = [
14 | // "Execute in /path/to/project"
15 | /[Ee]xecute\s+(?:in|from)\s+(['"]?)([\/~][^\n'"]+)\1/,
16 | // "Run in /path/to/project"
17 | /[Rr]un\s+(?:in|from)\s+(['"]?)([\/~][^\n'"]+)\1/,
18 | // "Use project in /path/to/project"
19 | /[Uu]se\s+project\s+(?:in|from|at)\s+(['"]?)([\/~][^\n'"]+)\1/,
20 | // "Set project directory to /path/to/project"
21 | /[Ss]et\s+project\s+directory\s+(?:to|as)\s+(['"]?)([\/~][^\n'"]+)\1/,
22 | // "Project is at /path/to/project"
23 | /[Pp]roject\s+(?:is|located)\s+(?:at|in)\s+(['"]?)([\/~][^\n'"]+)\1/,
24 | // "My project is in /path/to/project"
25 | /[Mm]y\s+project\s+is\s+(?:at|in)\s+(['"]?)([\/~][^\n'"]+)\1/,
26 | // "/path/to/project is my project"
27 | /(['"]?)([\/~][^\n'"]+)\1\s+is\s+my\s+(?:project|directory)/,
28 | ];
29 | for (const pattern of patterns) {
30 | const match = message.match(pattern);
31 | if (match) {
32 | return match[2];
33 | }
34 | }
35 | return null;
36 | }
37 | /**
38 | * Formats an object as a string representation of CLI flags
39 | * @param flags Key-value pairs of flag names and values
40 | * @returns Formatted flags string suitable for command line
41 | */
42 | export function formatFlags(flags) {
43 | if (!flags)
44 | return '';
45 | return Object.entries(flags)
46 | .map(([key, value]) => {
47 | // Skip undefined/null values
48 | if (value === undefined || value === null)
49 | return '';
50 | // Handle boolean flags
51 | if (typeof value === 'boolean') {
52 | return value ? `--${key}` : '';
53 | }
54 | // Handle arrays (space-separated multi-values)
55 | if (Array.isArray(value)) {
56 | return value.map((v) => `--${key}=${escapeValue(v)}`).join(' ');
57 | }
58 | // Handle objects (JSON stringify)
59 | if (typeof value === 'object') {
60 | return `--${key}=${escapeValue(JSON.stringify(value))}`;
61 | }
62 | // Regular values
63 | return `--${key}=${escapeValue(value)}`;
64 | })
65 | .filter(Boolean)
66 | .join(' ');
67 | }
68 | /**
69 | * Escapes values for command line usage
70 | */
71 | function escapeValue(value) {
72 | const stringValue = String(value);
73 | // If value contains spaces, wrap in quotes
74 | if (stringValue.includes(' ')) {
75 | // Escape any existing quotes
76 | return `"${stringValue.replace(/"/g, '\\"')}"`;
77 | }
78 | return stringValue;
79 | }
80 | /**
81 | * Parses help text to extract structured information about commands or flags
82 | * @param helpText Help text from Salesforce CLI
83 | * @returns Structured information extracted from help text
84 | */
85 | export function parseHelpText(helpText) {
86 | const description = [];
87 | const examples = [];
88 | const flags = {};
89 | // Split by sections
90 | const sections = helpText.split(/\n\s*\n/);
91 | // Extract description (usually the first section, skipping DESCRIPTION header if present)
92 | if (sections.length > 0) {
93 | let firstSection = sections[0].trim();
94 | if (firstSection.toUpperCase().startsWith('DESCRIPTION')) {
95 | firstSection = firstSection.substring(firstSection.indexOf('\n') + 1).trim();
96 | }
97 | description.push(firstSection);
98 | }
99 | // Look for a description section if the first section wasn't clear
100 | if (description[0]?.length < 10 || description[0]?.toUpperCase().includes('USAGE')) {
101 | const descSection = sections.find((section) => section.toUpperCase().startsWith('DESCRIPTION') || section.toUpperCase().includes('\nDESCRIPTION\n'));
102 | if (descSection) {
103 | const descContent = descSection.replace(/DESCRIPTION/i, '').trim();
104 | if (descContent) {
105 | description.push(descContent);
106 | }
107 | }
108 | }
109 | // Look for examples section with improved pattern matching
110 | const examplePatterns = [/EXAMPLES?/i, /USAGE/i];
111 | for (const pattern of examplePatterns) {
112 | const exampleSection = sections.find((section) => pattern.test(section));
113 | if (exampleSection) {
114 | // Extract examples - look for command lines that start with $ or sf
115 | const exampleLines = exampleSection
116 | .split('\n')
117 | .filter((line) => {
118 | const trimmed = line.trim();
119 | return trimmed.startsWith('$') || trimmed.startsWith('sf ') || /^\s*\d+\.\s+sf\s+/.test(line); // Numbered examples: "1. sf ..."
120 | })
121 | .map((line) => line.trim().replace(/^\d+\.\s+/, '')); // Remove numbering if present
122 | examples.push(...exampleLines);
123 | }
124 | }
125 | // Look for flags section with improved pattern matching
126 | const flagPatterns = [/FLAGS/i, /OPTIONS/i, /PARAMETERS/i, /ARGUMENTS/i];
127 | for (const pattern of flagPatterns) {
128 | const flagSections = sections.filter((section) => pattern.test(section));
129 | for (const flagSection of flagSections) {
130 | // Skip the section header line
131 | const sectionLines = flagSection.split('\n').slice(1);
132 | // Different patterns for flag lines
133 | const flagPatterns = [
134 | // Pattern 1: Classic -c, --char= Description
135 | /^\s*(?:-([a-zA-Z]),\s+)?--([a-zA-Z][a-zA-Z0-9-]+)(?:=([a-zA-Z0-9_\-\[\]|]+)>?)?\s+(.+)$/,
136 | // Pattern 2: Indented flag with details (common in newer SF CLI)
137 | /^\s+(?:-([a-zA-Z]),\s+)?--([a-zA-Z][a-zA-Z0-9-]+)(?:\s+|\=)(?:<([a-zA-Z0-9_\-\[\]|]+)>)?\s*\n\s+(.+)/,
138 | // Pattern 3: Simple flag with no/minimal formatting
139 | /^\s*(?:-([a-zA-Z]),\s*)?--([a-zA-Z][a-zA-Z0-9-]+)(?:\s+|\=)?(?:\s*<([a-zA-Z0-9_\-\[\]|]+)>)?\s+(.+)$/,
140 | ];
141 | // Process the flag section
142 | let i = 0;
143 | while (i < sectionLines.length) {
144 | const line = sectionLines[i];
145 | const nextLine = i < sectionLines.length - 1 ? sectionLines[i + 1] : '';
146 | const combinedLines = line + '\n' + nextLine;
147 | let matched = false;
148 | // Try all patterns
149 | for (const pattern of flagPatterns) {
150 | const match = line.match(pattern) || combinedLines.match(pattern);
151 | if (match) {
152 | matched = true;
153 | const char = match[1];
154 | const name = match[2];
155 | const type = match[3] || 'boolean';
156 | const description = match[4].trim();
157 | // Check if this flag is required
158 | const required = description.toLowerCase().includes('(required)') ||
159 | description.toLowerCase().includes('[required]') ||
160 | description.toLowerCase().includes('required:') ||
161 | description.toLowerCase().includes('required -');
162 | // Normalize the type
163 | let normalizedType = type.toLowerCase();
164 | if (normalizedType.includes('number') || normalizedType.includes('int')) {
165 | normalizedType = 'number';
166 | }
167 | else if (normalizedType.includes('boolean') || normalizedType === 'flag') {
168 | normalizedType = 'boolean';
169 | }
170 | else if (normalizedType.includes('array') || normalizedType.includes('[]')) {
171 | normalizedType = 'array';
172 | }
173 | else if (normalizedType.includes('json') || normalizedType.includes('object')) {
174 | normalizedType = 'json';
175 | }
176 | else {
177 | normalizedType = 'string';
178 | }
179 | flags[name] = {
180 | name,
181 | char,
182 | description: description
183 | .replace(/\([Rr]equired\)|\[[Rr]equired\]|[Rr]equired:?/g, '')
184 | .trim(),
185 | required,
186 | type: normalizedType,
187 | };
188 | // Skip the next line if we matched against a two-line pattern
189 | if (combinedLines.match(pattern) && !line.match(pattern)) {
190 | i++;
191 | }
192 | break;
193 | }
194 | }
195 | // If no pattern matched and this line looks like it might be a flag
196 | if (!matched && (line.includes('--') || line.trim().startsWith('-'))) {
197 | console.error(`No pattern matched for potential flag line: "${line.trim()}"`);
198 | }
199 | i++;
200 | }
201 | }
202 | }
203 | return {
204 | description: description.join('\n\n'),
205 | examples,
206 | flags,
207 | };
208 | }
209 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import eslint from '@eslint/js';
4 | import tseslint from 'typescript-eslint';
5 |
6 | export default tseslint.config(
7 | eslint.configs.recommended,
8 | tseslint.configs.recommendedTypeChecked,
9 | // tseslint.configs.strictTypeChecked,
10 | // tseslint.configs.stylisticTypeChecked,
11 | {
12 | languageOptions: {
13 | parserOptions: {
14 | projectService: true,
15 | tsconfigRootDir: import.meta.dirname,
16 | },
17 | },
18 | },
19 | );
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sf-mcp",
3 | "version": "1.3.2",
4 | "main": "build/index.js",
5 | "type": "module",
6 | "bin": {
7 | "sfmcp": "./build/index.js"
8 | },
9 | "scripts": {
10 | "build": "tsc && chmod 755 build/index.js",
11 | "start": "node build/index.js",
12 | "dev": "tsc -w",
13 | "lint": "eslint src",
14 | "prepare": "npm run build",
15 | "format": "prettier --write \"**/*.{ts,json,md}\"",
16 | "test": "echo \"No tests configured\" && exit 0",
17 | "release": "standard-version && git push --follow-tags origin main && npm publish",
18 | "with-roots": "./run.sh"
19 | },
20 | "files": [
21 | "build",
22 | "run.sh"
23 | ],
24 | "keywords": [
25 | "mcp",
26 | "modelcontextprotocol",
27 | "salesforce",
28 | "sf",
29 | "cli",
30 | "llm"
31 | ],
32 | "author": "Kevin Poorman",
33 | "license": "ISC",
34 | "description": "Model Context Protocol (MCP) server for the Salesforce CLI, making Salesforce CLI commands available to LLM tools like Claude Desktop.",
35 | "repository": {
36 | "type": "git",
37 | "url": "https://github.com/codefriar/sf-mcp"
38 | },
39 | "dependencies": {
40 | "@modelcontextprotocol/sdk": "^1.8.0",
41 | "zod": "^3.24.2"
42 | },
43 | "devDependencies": {
44 | "@eslint/js": "^9.23.0",
45 | "@types/node": "^22.13.14",
46 | "eslint": "^9.23.0",
47 | "prettier": "^3.5.3",
48 | "standard-release": "^0.2.0",
49 | "standard-version": "^9.5.0",
50 | "typescript": "^5.8.2",
51 | "typescript-eslint": "^8.28.0"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Check if sf command is available
4 | if ! command -v sf &> /dev/null; then
5 | echo "Error: Salesforce CLI (sf) is not installed or not in your PATH"
6 | echo "Please install it from: https://developer.salesforce.com/tools/sfdxcli"
7 | exit 1
8 | fi
9 |
10 | # Print current directory and sf version
11 | echo "Current directory: $(pwd)"
12 | echo "Salesforce CLI version:"
13 | sf --version
14 |
15 | # Build and run the server
16 | echo "Building and starting MCP server..."
17 | npm run build
18 |
19 | # Pass all command-line arguments to the server (for project roots)
20 | node build/index.js "$@"
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5 | import { z } from 'zod';
6 | import { registerSfCommands, clearCommandCache, refreshCommandCache, setProjectDirectory, getProjectRoots } from './sfCommands.js';
7 | import path from 'path';
8 | import { registerResources } from './resources.js';
9 | import { extractProjectDirectoryFromMessage } from './utils.js';
10 |
11 | // Create an MCP server
12 | const server = new McpServer({
13 | name: 'Salesforce CLI MCP',
14 | version: '1.1.0',
15 | description: 'MCP server for Salesforce CLI integration',
16 | });
17 |
18 | // Only register utility tools that aren't SF CLI commands
19 | // These are utility functions that extend or manage the MCP server itself
20 | server.tool('sf_cache_clear', 'Clear the cached SF command metadata to force a refresh', {}, async () => {
21 | const result = clearCommandCache();
22 | return {
23 | content: [
24 | {
25 | type: 'text',
26 | text: result
27 | ? 'Command cache cleared successfully.'
28 | : 'Failed to clear command cache or cache did not exist.',
29 | },
30 | ]
31 | };
32 | });
33 |
34 | server.tool('sf_cache_refresh', 'Refresh the SF command cache by re-scanning all available commands', {}, async () => {
35 | const result = refreshCommandCache();
36 | return {
37 | content: [
38 | {
39 | type: 'text',
40 | text: result
41 | ? 'Command cache refreshed successfully. Restart the server to use the new cache.'
42 | : 'Failed to refresh command cache.',
43 | },
44 | ],
45 | };
46 | });
47 |
48 | // Tools for managing Salesforce project directories (roots)
49 |
50 | // Tool for automatically detecting project directories from messages
51 | server.tool('sf_detect_project_directory', 'Get instructions for setting up Salesforce project directories for command execution', {}, async () => {
52 | // Since we can't access the message in this version of MCP,
53 | // we need to rely on the LLM to extract the directory and use sf_set_project_directory
54 |
55 | return {
56 | content: [
57 | {
58 | type: 'text',
59 | text: 'To set a project directory, please use sf_set_project_directory with the path to your Salesforce project, or include the project path in your message using formats like "Execute in /path/to/project" or "Use project in /path/to/project".',
60 | },
61 | ],
62 | };
63 | });
64 |
65 | // Tool for explicitly setting a project directory (root)
66 | server.tool('sf_set_project_directory', 'Set a Salesforce project directory for command execution context', {
67 | directory: z.string().describe('The absolute path to a directory containing an sfdx-project.json file'),
68 | name: z.string().optional().describe('Optional name for this project root'),
69 | description: z.string().optional().describe('Optional description for this project root'),
70 | isDefault: z.boolean().optional().describe('Set this root as the default for command execution')
71 | }, async (params) => {
72 |
73 | // Set the project directory with optional metadata
74 | const result = setProjectDirectory(params.directory, {
75 | name: params.name,
76 | description: params.description,
77 | isDefault: params.isDefault
78 | });
79 |
80 | return {
81 | content: [
82 | {
83 | type: 'text',
84 | text: result
85 | ? `Successfully set Salesforce project root: ${params.directory}${params.name ? ` with name "${params.name}"` : ''}${params.isDefault ? ' (default)' : ''}`
86 | : `Failed to set project directory. Make sure the path exists and contains an sfdx-project.json file.`,
87 | },
88 | ],
89 | };
90 | });
91 |
92 | // Tool for listing configured project roots
93 | server.tool('sf_list_roots', 'List all configured Salesforce project directories and their metadata', {}, async () => {
94 | const roots = getProjectRoots();
95 |
96 | if (roots.length === 0) {
97 | return {
98 | content: [
99 | {
100 | type: 'text',
101 | text: 'No project roots configured. Use sf_set_project_directory to add a project root.'
102 | }
103 | ]
104 | };
105 | }
106 |
107 | // Format roots list for display
108 | const rootsList = roots.map(root => (
109 | `- ${root.name || path.basename(root.path)}${root.isDefault ? ' (default)' : ''}: ${root.path}${root.description ? `\n Description: ${root.description}` : ''}`
110 | )).join('\n\n');
111 |
112 | return {
113 | content: [
114 | {
115 | type: 'text',
116 | text: `Configured Salesforce project roots:\n\n${rootsList}`
117 | }
118 | ]
119 | };
120 | });
121 |
122 | // Start the server with stdio transport
123 | // We can't use middleware, so we'll rely on explicit tool use
124 | // The LLM will need to be instructed to look for project directory references
125 | // and call the sf_set_project_directory tool
126 |
127 | /**
128 | * Process command line arguments to detect and set project roots
129 | * All arguments that look like filesystem paths are treated as potential roots
130 | */
131 | function processRootPaths(): void {
132 | // Skip the first two arguments (node executable and script path)
133 | const args = process.argv.slice(2);
134 |
135 | if (!args || args.length === 0) {
136 | console.error('No arguments provided');
137 | return;
138 | }
139 |
140 | // Filter arguments that appear to be filesystem paths
141 | // A path typically starts with / or ./ or ../ or ~/ or contains a directory separator
142 | const rootPaths = args.filter(arg =>
143 | arg.startsWith('/') ||
144 | arg.startsWith('./') ||
145 | arg.startsWith('../') ||
146 | arg.startsWith('~/') ||
147 | arg.includes('/') ||
148 | arg.includes('\\')
149 | );
150 |
151 | if (rootPaths.length === 0) {
152 | console.error('No project roots identified in CLI arguments');
153 | return;
154 | }
155 |
156 | console.error(`Configuring ${rootPaths.length} project roots from CLI arguments...`);
157 |
158 | // Process each provided path
159 | for (let i = 0; i < rootPaths.length; i++) {
160 | const rootPath = rootPaths[i];
161 | const isDefault = i === 0; // Make the first root the default
162 | const rootName = `root${i + 1}`;
163 |
164 | // Set up this root
165 | const result = setProjectDirectory(rootPath, {
166 | name: rootName,
167 | isDefault,
168 | description: `CLI-configured root #${i + 1}`
169 | });
170 |
171 | if (result) {
172 | console.error(`Configured project root #${i + 1}: ${rootPath}`);
173 | } else {
174 | console.error(`Failed to configure project root #${i + 1}: ${rootPath}`);
175 | }
176 | }
177 | }
178 |
179 | async function main() {
180 | try {
181 | // Process any command line arguments for project roots
182 | processRootPaths();
183 |
184 | // Register documentation resources
185 | registerResources(server);
186 |
187 | // Register all SF CLI commands as tools (dynamic discovery)
188 | const dynamicToolCount = await registerSfCommands(server);
189 |
190 | // Add the utility tools we registered manually
191 | const totalTools = dynamicToolCount + 5; // sf_cache_clear, sf_cache_refresh, sf_set_project_directory, sf_detect_project_directory, sf_list_roots
192 | console.error(`Total registered tools: ${totalTools} (${dynamicToolCount} SF CLI tools + 5 utility tools)`);
193 |
194 | console.error('Starting Salesforce CLI MCP Server...');
195 | const transport = new StdioServerTransport();
196 | await server.connect(transport);
197 | } catch (err) {
198 | console.error('Error starting server:', err);
199 | process.exit(1);
200 | }
201 | }
202 |
203 | main();
204 |
--------------------------------------------------------------------------------
/src/resources.ts:
--------------------------------------------------------------------------------
1 | import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { executeSfCommand, getProjectRoots } from './sfCommands.js';
3 |
4 | /**
5 | * Register all resources for the SF CLI MCP Server
6 | */
7 | export function registerResources(server: McpServer): void {
8 | // Main CLI documentation
9 | server.resource('sf-help', 'sf://help', async (uri) => ({
10 | contents: [
11 | {
12 | uri: uri.href,
13 | text: executeSfCommand('-h'),
14 | },
15 | ],
16 | }));
17 |
18 | // Project roots information
19 | server.resource('sf-roots', 'sf://roots', async (uri) => {
20 | const roots = getProjectRoots();
21 | const rootsText = roots.length > 0
22 | ? roots.map(root => `${root.name}${root.isDefault ? ' (default)' : ''}: ${root.path}${root.description ? ` - ${root.description}` : ''}`).join('\n')
23 | : 'No project roots configured. Use sf_set_project_directory to add a project root.';
24 |
25 | return {
26 | contents: [
27 | {
28 | uri: uri.href,
29 | text: rootsText,
30 | },
31 | ],
32 | };
33 | });
34 |
35 | // Topic help documentation
36 | server.resource(
37 | 'sf-topic-help',
38 | new ResourceTemplate('sf://topics/{topic}/help', { list: undefined }),
39 | async (uri, { topic }) => ({
40 | contents: [
41 | {
42 | uri: uri.href,
43 | text: executeSfCommand(`${topic} -h`),
44 | },
45 | ],
46 | })
47 | );
48 |
49 | // Command help documentation
50 | server.resource(
51 | 'sf-command-help',
52 | new ResourceTemplate('sf://commands/{command}/help', { list: undefined }),
53 | async (uri, { command }) => ({
54 | contents: [
55 | {
56 | uri: uri.href,
57 | text: executeSfCommand(`${command} -h`),
58 | },
59 | ],
60 | })
61 | );
62 |
63 | // Topic-command help documentation
64 | server.resource(
65 | 'sf-topic-command-help',
66 | new ResourceTemplate('sf://topics/{topic}/commands/{command}/help', {
67 | list: undefined,
68 | }),
69 | async (uri, { topic, command }) => ({
70 | contents: [
71 | {
72 | uri: uri.href,
73 | text: executeSfCommand(`${topic} ${command} -h`),
74 | },
75 | ],
76 | })
77 | );
78 |
79 | // Root-specific command help (execute in a specific root)
80 | server.resource(
81 | 'sf-root-command',
82 | new ResourceTemplate('sf://roots/{root}/commands/{command}', { list: undefined }),
83 | async (uri, { root, command }) => ({
84 | contents: [
85 | {
86 | uri: uri.href,
87 | // Ensure command is treated as string
88 | text: executeSfCommand(String(command), String(root)),
89 | },
90 | ],
91 | })
92 | );
93 |
94 | // Version information
95 | server.resource('sf-version', 'sf://version', async (uri) => ({
96 | contents: [
97 | {
98 | uri: uri.href,
99 | text: executeSfCommand('--version'),
100 | },
101 | ],
102 | }));
103 | }
104 |
--------------------------------------------------------------------------------
/src/sfCommands.ts:
--------------------------------------------------------------------------------
1 | import { execSync } from 'child_process';
2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3 | import { z } from 'zod';
4 | import { formatFlags } from './utils.js';
5 | import fs from 'fs';
6 | import path from 'path';
7 | import os from 'os';
8 |
9 | /**
10 | * Represents a Salesforce CLI command
11 | */
12 | interface SfCommand {
13 | id: string;
14 | name: string;
15 | description: string;
16 | fullCommand: string;
17 | flags: SfFlag[];
18 | topic?: string;
19 | }
20 |
21 | /**
22 | * Represents a flag for an SF command
23 | */
24 | interface SfFlag {
25 | name: string;
26 | char?: string;
27 | description: string;
28 | required: boolean;
29 | type: string;
30 | options?: string[];
31 | default?: string | boolean | number;
32 | }
33 |
34 | /**
35 | * Interface for the JSON format returned by 'sf commands --json'
36 | */
37 | interface SfCommandJsonEntry {
38 | id: string;
39 | summary: string;
40 | description: string;
41 | aliases?: string[];
42 | flags: Record<
43 | string,
44 | {
45 | name: string;
46 | description: string;
47 | type: string;
48 | required?: boolean;
49 | helpGroup?: string;
50 | options?: string[];
51 | default?: string | boolean | number;
52 | char?: string;
53 | }
54 | >;
55 | [key: string]: any;
56 | }
57 |
58 | /**
59 | * Cache structure for storing discovered SF commands
60 | */
61 | interface SfCommandCache {
62 | version: string;
63 | timestamp: number;
64 | commands: SfCommand[];
65 | }
66 |
67 | /**
68 | * List of topics to ignore during command discovery
69 | */
70 | const IGNORED_TOPICS = ['help', 'which', 'whatsnew', 'alias'];
71 |
72 | /**
73 | * Path to the cache file
74 | */
75 | const CACHE_DIR = path.join(os.homedir(), '.sf-mcp');
76 | const CACHE_FILE = path.join(CACHE_DIR, 'command-cache.json');
77 | const CACHE_MAX_AGE = 86400 * 7 * 1000; // 1 week in milliseconds
78 |
79 | /**
80 | * Clear the command cache
81 | */
82 | export function clearCommandCache(): boolean {
83 | try {
84 | if (fs.existsSync(CACHE_FILE)) {
85 | fs.unlinkSync(CACHE_FILE);
86 | console.error(`Removed cache file: ${CACHE_FILE}`);
87 | return true;
88 | } else {
89 | console.error(`Cache file does not exist: ${CACHE_FILE}`);
90 | return false;
91 | }
92 | } catch (error) {
93 | console.error('Error clearing command cache:', error);
94 | return false;
95 | }
96 | }
97 |
98 | /**
99 | * Manually force the cache to refresh
100 | */
101 | export function refreshCommandCache(): boolean {
102 | try {
103 | // Clear existing cache
104 | if (fs.existsSync(CACHE_FILE)) {
105 | fs.unlinkSync(CACHE_FILE);
106 | }
107 |
108 | // Create a fresh cache
109 | console.error('Refreshing SF command cache...');
110 |
111 | // Get all commands directly from sf commands --json
112 | const commands = getAllSfCommands();
113 | console.error(`Found ${commands.length} total commands for cache refresh`);
114 |
115 | // Save the cache
116 | saveCommandCache(commands);
117 | console.error('Cache refresh complete!');
118 |
119 | return true;
120 | } catch (error) {
121 | console.error('Error refreshing command cache:', error);
122 | return false;
123 | }
124 | }
125 |
126 | // Get the full path to the sf command
127 | const SF_BINARY_PATH = (() => {
128 | try {
129 | // Try to find the sf binary in common locations
130 | const possiblePaths = [
131 | '/Users/kpoorman/.volta/bin/sf', // The path we found earlier
132 | '/usr/local/bin/sf',
133 | '/usr/bin/sf',
134 | '/opt/homebrew/bin/sf',
135 | process.env.HOME + '/.npm/bin/sf',
136 | process.env.HOME + '/bin/sf',
137 | process.env.HOME + '/.nvm/versions/node/*/bin/sf',
138 | ];
139 |
140 | for (const path of possiblePaths) {
141 | try {
142 | if (
143 | execSync(`[ -x "${path}" ] && echo "exists"`, {
144 | encoding: 'utf8',
145 | }).trim() === 'exists'
146 | ) {
147 | return path;
148 | }
149 | } catch (e) {
150 | // Path doesn't exist or isn't executable, try the next one
151 | }
152 | }
153 |
154 | // If we didn't find it in a known location, try to get it from the PATH
155 | return 'sf';
156 | } catch (e) {
157 | console.error("Unable to locate sf binary, falling back to 'sf'");
158 | return 'sf';
159 | }
160 | })();
161 |
162 | /**
163 | * Execute an sf command and return the results
164 | * @param command The sf command to run
165 | * @returns The stdout output from the command
166 | */
167 | // Store the user-provided project directories (roots)
168 | interface ProjectRoot {
169 | path: string;
170 | name?: string;
171 | description?: string;
172 | isDefault?: boolean;
173 | }
174 |
175 | const projectRoots: ProjectRoot[] = [];
176 | let defaultRootPath: string | null = null;
177 |
178 | /**
179 | * Validate a directory is a valid Salesforce project
180 | * @param directory The directory to validate
181 | * @returns boolean indicating if valid
182 | */
183 | function isValidSalesforceProject(directory: string): boolean {
184 | const projectFilePath = path.join(directory, 'sfdx-project.json');
185 | return fs.existsSync(directory) && fs.existsSync(projectFilePath);
186 | }
187 |
188 | /**
189 | * Get all configured project roots
190 | * @returns Array of project roots
191 | */
192 | export function getProjectRoots(): ProjectRoot[] {
193 | return [...projectRoots];
194 | }
195 |
196 | /**
197 | * Get the default project directory (for backward compatibility)
198 | * @returns The default project directory or null if none set
199 | */
200 | export function getDefaultProjectDirectory(): string | null {
201 | return defaultRootPath;
202 | }
203 |
204 | /**
205 | * Set the Salesforce project directory to use for commands
206 | * @param directory The directory containing sfdx-project.json
207 | * @param options Optional parameters (name, description, isDefault)
208 | * @returns boolean indicating success
209 | */
210 | export function setProjectDirectory(
211 | directory: string,
212 | options: { name?: string; description?: string; isDefault?: boolean } = {}
213 | ): boolean {
214 | try {
215 | // Validate that the directory exists and contains an sfdx-project.json file
216 | if (!isValidSalesforceProject(directory)) {
217 | console.error(`Invalid Salesforce project: ${directory}`);
218 | return false;
219 | }
220 |
221 | // Check if this root already exists
222 | const existingIndex = projectRoots.findIndex(root => root.path === directory);
223 |
224 | if (existingIndex >= 0) {
225 | // Update existing root with new options
226 | projectRoots[existingIndex] = {
227 | ...projectRoots[existingIndex],
228 | ...options,
229 | path: directory
230 | };
231 |
232 | // If this is now the default root, update defaultRootPath
233 | if (options.isDefault) {
234 | // Remove default flag from other roots
235 | projectRoots.forEach((root, idx) => {
236 | if (idx !== existingIndex) {
237 | root.isDefault = false;
238 | }
239 | });
240 | defaultRootPath = directory;
241 | }
242 |
243 | console.error(`Updated Salesforce project root: ${directory}`);
244 | } else {
245 | // Add as new root
246 | const isDefault = options.isDefault ?? (projectRoots.length === 0);
247 |
248 | projectRoots.push({
249 | path: directory,
250 | name: options.name || path.basename(directory),
251 | description: options.description,
252 | isDefault
253 | });
254 |
255 | // If this is now the default root, update defaultRootPath
256 | if (isDefault) {
257 | // Remove default flag from other roots
258 | projectRoots.forEach((root, idx) => {
259 | if (idx !== projectRoots.length - 1) {
260 | root.isDefault = false;
261 | }
262 | });
263 | defaultRootPath = directory;
264 | }
265 |
266 | console.error(`Added Salesforce project root: ${directory}`);
267 | }
268 |
269 | // Always ensure we have exactly one default root if any roots exist
270 | if (projectRoots.length > 0 && !projectRoots.some(root => root.isDefault)) {
271 | projectRoots[0].isDefault = true;
272 | defaultRootPath = projectRoots[0].path;
273 | }
274 |
275 | return true;
276 | } catch (error) {
277 | console.error('Error setting project directory:', error);
278 | return false;
279 | }
280 | }
281 |
282 | /**
283 | * Checks if a command requires a Salesforce project context
284 | * @param command The SF command to check
285 | * @returns True if the command requires a Salesforce project context
286 | */
287 | function requiresSalesforceProjectContext(command: string): boolean {
288 | // List of commands or command prefixes that require a Salesforce project context
289 | const projectContextCommands = [
290 | 'project deploy',
291 | 'project retrieve',
292 | 'project delete',
293 | 'project convert',
294 | 'package version create',
295 | 'package1 version create',
296 | 'source',
297 | 'mdapi',
298 | 'apex',
299 | 'lightning',
300 | 'schema generate'
301 | ];
302 |
303 | // Check if the command matches any of the project context commands
304 | return projectContextCommands.some(contextCmd => command.startsWith(contextCmd));
305 | }
306 |
307 | /**
308 | * Execute an sf command and return the results
309 | * @param command The sf command to run
310 | * @param rootName Optional specific root name to use for execution
311 | * @returns The stdout output from the command
312 | */
313 | export function executeSfCommand(command: string, rootName?: string): string {
314 | try {
315 | console.error(`Executing: ${SF_BINARY_PATH} ${command}`);
316 |
317 | // Check if target-org parameter is 'default' and replace with the default org
318 | if (command.includes('--target-org default') || command.includes('--target-org=default')) {
319 | // Get the default org from sf org list
320 | const orgListOutput = execSync(`"${SF_BINARY_PATH}" org list --json`, {
321 | encoding: 'utf8',
322 | maxBuffer: 10 * 1024 * 1024,
323 | });
324 |
325 | const orgList = JSON.parse(orgListOutput);
326 | let defaultUsername = '';
327 |
328 | // Look for the default org across different org types
329 | for (const orgType of ['nonScratchOrgs', 'scratchOrgs', 'sandboxes']) {
330 | if (orgList.result[orgType]) {
331 | const defaultOrg = orgList.result[orgType].find((org: any) => org.isDefaultUsername);
332 | if (defaultOrg) {
333 | defaultUsername = defaultOrg.username;
334 | break;
335 | }
336 | }
337 | }
338 |
339 | if (defaultUsername) {
340 | // Replace 'default' with the actual default org username
341 | command = command.replace(/--target-org[= ]default/, `--target-org ${defaultUsername}`);
342 | console.error(`Using default org: ${defaultUsername}`);
343 | }
344 | }
345 |
346 | // Determine which project directory to use
347 | let projectDir: string | null = null;
348 |
349 | // If rootName specified, find that specific root
350 | if (rootName) {
351 | const root = projectRoots.find(r => r.name === rootName);
352 | if (root) {
353 | projectDir = root.path;
354 | console.error(`Using specified root "${rootName}" at ${projectDir}`);
355 | } else {
356 | console.error(`Root "${rootName}" not found, falling back to default root`);
357 | // Fall back to default
358 | projectDir = defaultRootPath;
359 | }
360 | } else {
361 | // Use default root
362 | projectDir = defaultRootPath;
363 | }
364 |
365 | // Check if this command requires a Salesforce project context and we don't have a project directory
366 | if (requiresSalesforceProjectContext(command) && !projectDir) {
367 | return `This command requires a Salesforce project context (sfdx-project.json).
368 | Please specify a project directory using the format:
369 | "Execute in " or "Use project in "`;
370 | }
371 |
372 | try {
373 | // Always execute in project directory if available
374 | if (projectDir) {
375 | console.error(`Executing command in Salesforce project directory: ${projectDir}`);
376 |
377 | // Execute the command within the specified project directory
378 | const result = execSync(`"${SF_BINARY_PATH}" ${command}`, {
379 | encoding: 'utf8',
380 | maxBuffer: 10 * 1024 * 1024,
381 | env: {
382 | ...process.env,
383 | PATH: process.env.PATH,
384 | },
385 | cwd: projectDir,
386 | stdio: ['pipe', 'pipe', 'pipe'] // Capture stderr too
387 | });
388 |
389 | console.error('Command execution successful');
390 | return result;
391 | } else {
392 | // Standard execution for when no project directory is set
393 | return execSync(`"${SF_BINARY_PATH}" ${command}`, {
394 | encoding: 'utf8',
395 | maxBuffer: 10 * 1024 * 1024,
396 | env: {
397 | ...process.env,
398 | PATH: process.env.PATH,
399 | },
400 | });
401 | }
402 | } catch (execError: any) {
403 | console.error(`Error executing command: ${execError.message}`);
404 |
405 | // Capture both stdout and stderr for better error diagnostics
406 | let errorOutput = '';
407 | if (execError.stdout) {
408 | errorOutput += execError.stdout;
409 | }
410 | if (execError.stderr) {
411 | errorOutput += `\n\nError details: ${execError.stderr}`;
412 | }
413 |
414 | if (errorOutput) {
415 | console.error(`Command output: ${errorOutput}`);
416 | return errorOutput;
417 | }
418 |
419 | return `Error executing command: ${execError.message}`;
420 | }
421 | } catch (error: any) {
422 | console.error(`Top-level error executing command: ${error.message}`);
423 |
424 | // Capture both stdout and stderr
425 | let errorOutput = '';
426 | if (error.stdout) {
427 | errorOutput += error.stdout;
428 | }
429 | if (error.stderr) {
430 | errorOutput += `\n\nError details: ${error.stderr}`;
431 | }
432 |
433 | if (errorOutput) {
434 | console.error(`Command output: ${errorOutput}`);
435 | return errorOutput;
436 | }
437 |
438 | return `Error executing command: ${error.message}`;
439 | }
440 | }
441 |
442 | /**
443 | * Get all Salesforce CLI commands using 'sf commands --json'
444 | */
445 | function getAllSfCommands(): SfCommand[] {
446 | try {
447 | console.error("Fetching all SF CLI commands via 'sf commands --json'...");
448 |
449 | // Execute the command to get all commands in JSON format
450 | const commandsJson = executeSfCommand('commands --json');
451 | const allCommands: SfCommandJsonEntry[] = JSON.parse(commandsJson);
452 |
453 | console.error(`Found ${allCommands.length} total commands from 'sf commands --json'`);
454 |
455 | // Filter out commands from ignored topics
456 | const filteredCommands = allCommands.filter((cmd) => {
457 | if (!cmd.id) return false;
458 |
459 | // For commands with colons (topic:command format), check if the topic should be ignored
460 | if (cmd.id.includes(':')) {
461 | const topic = cmd.id.split(':')[0].toLowerCase();
462 | return !IGNORED_TOPICS.includes(topic);
463 | }
464 |
465 | // For standalone commands, check if the command itself should be ignored
466 | return !IGNORED_TOPICS.includes(cmd.id.toLowerCase());
467 | });
468 |
469 | console.error(`After filtering ignored topics, ${filteredCommands.length} commands remain`);
470 |
471 | // Transform JSON commands to SfCommand format
472 | const sfCommands: SfCommand[] = filteredCommands.map((jsonCmd) => {
473 | // Parse the command structure from its ID
474 | const commandParts = jsonCmd.id.split(':');
475 | const isTopicCommand = commandParts.length > 1;
476 |
477 | // For commands like "apex:run", extract name and topic
478 | let commandName = isTopicCommand ? commandParts[commandParts.length - 1] : jsonCmd.id;
479 | let topic = isTopicCommand ? commandParts.slice(0, commandParts.length - 1).join(':') : undefined;
480 |
481 | // The full command with spaces instead of colons for execution
482 | const fullCommand = jsonCmd.id.replace(/:/g, ' ');
483 |
484 | // Convert flags from JSON format to SfFlag format
485 | const flags: SfFlag[] = Object.entries(jsonCmd.flags || {}).map(([flagName, flagDetails]) => {
486 | return {
487 | name: flagName,
488 | char: flagDetails.char,
489 | description: flagDetails.description || '',
490 | required: !!flagDetails.required,
491 | type: flagDetails.type || 'string',
492 | options: flagDetails.options,
493 | default: flagDetails.default,
494 | };
495 | });
496 |
497 | return {
498 | id: jsonCmd.id,
499 | name: commandName,
500 | description: jsonCmd.summary || jsonCmd.description || jsonCmd.id,
501 | fullCommand,
502 | flags,
503 | topic,
504 | };
505 | });
506 |
507 | console.error(`Successfully processed ${sfCommands.length} commands`);
508 | return sfCommands;
509 | } catch (error) {
510 | console.error('Error getting SF commands:', error);
511 | return [];
512 | }
513 | }
514 |
515 | /**
516 | * Convert an SF command to a schema object for validation
517 | */
518 | function commandToZodSchema(command: SfCommand): Record {
519 | const schemaObj: Record = {};
520 |
521 |
522 | for (const flag of command.flags) {
523 | let flagSchema: z.ZodTypeAny;
524 |
525 | // Convert flag type to appropriate Zod schema
526 | switch (flag.type) {
527 | case 'number':
528 | case 'integer':
529 | case 'int':
530 | flagSchema = z.number();
531 | break;
532 | case 'boolean':
533 | case 'flag':
534 | flagSchema = z.boolean();
535 | break;
536 | case 'array':
537 | case 'string[]':
538 | flagSchema = z.array(z.string());
539 | break;
540 | case 'json':
541 | case 'object':
542 | flagSchema = z.union([z.string(), z.record(z.any())]);
543 | break;
544 | case 'file':
545 | case 'directory':
546 | case 'filepath':
547 | case 'path':
548 | case 'email':
549 | case 'url':
550 | case 'date':
551 | case 'datetime':
552 | case 'id':
553 | default:
554 | // For options-based flags, create an enum schema
555 | if (flag.options && flag.options.length > 0) {
556 | flagSchema = z.enum(flag.options as [string, ...string[]]);
557 | } else {
558 | flagSchema = z.string();
559 | }
560 | }
561 |
562 | // Add description
563 | if (flag.description) {
564 | flagSchema = flagSchema.describe(flag.description);
565 | }
566 |
567 | // Make required or optional based on flag definition
568 | schemaObj[flag.name] = flag.required ? flagSchema : flagSchema.optional();
569 | }
570 |
571 | return schemaObj;
572 | }
573 |
574 | /**
575 | * Get the SF CLI version to use for cache validation
576 | */
577 | function getSfVersion(): string {
578 | try {
579 | const versionOutput = executeSfCommand('--version');
580 | const versionMatch = versionOutput.match(/sf\/(\d+\.\d+\.\d+)/);
581 | return versionMatch ? versionMatch[1] : 'unknown';
582 | } catch (error) {
583 | console.error('Error getting SF version:', error);
584 | return 'unknown';
585 | }
586 | }
587 |
588 | /**
589 | * Saves the SF command data to cache
590 | */
591 | function saveCommandCache(commands: SfCommand[]): void {
592 | try {
593 | // Create cache directory if it doesn't exist
594 | if (!fs.existsSync(CACHE_DIR)) {
595 | fs.mkdirSync(CACHE_DIR, { recursive: true });
596 | }
597 |
598 | const sfVersion = getSfVersion();
599 | const cache: SfCommandCache = {
600 | version: sfVersion,
601 | timestamp: Date.now(),
602 | commands,
603 | };
604 |
605 | fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
606 | console.error(`Command cache saved to ${CACHE_FILE} (SF version: ${sfVersion})`);
607 | } catch (error) {
608 | console.error('Error saving command cache:', error);
609 | }
610 | }
611 |
612 | /**
613 | * Loads the SF command data from cache
614 | * Returns null if cache is missing, invalid, or expired
615 | */
616 | function loadCommandCache(): SfCommand[] | null {
617 | try {
618 | if (!fs.existsSync(CACHE_FILE)) {
619 | console.error('Command cache file does not exist');
620 | return null;
621 | }
622 |
623 | const cacheData = fs.readFileSync(CACHE_FILE, 'utf8');
624 | const cache = JSON.parse(cacheData) as SfCommandCache;
625 |
626 | // Validate cache structure
627 | if (!cache.version || !cache.timestamp || !Array.isArray(cache.commands)) {
628 | console.error('Invalid cache structure');
629 | return null;
630 | }
631 |
632 | // Check if cache is expired
633 | const now = Date.now();
634 | if (now - cache.timestamp > CACHE_MAX_AGE) {
635 | console.error('Cache is expired');
636 | return null;
637 | }
638 |
639 | // Verify that SF version matches
640 | const currentVersion = getSfVersion();
641 | if (cache.version !== currentVersion) {
642 | console.error(`Cache version mismatch. Cache: ${cache.version}, Current: ${currentVersion}`);
643 | return null;
644 | }
645 |
646 | console.error(
647 | `Using command cache from ${new Date(cache.timestamp).toLocaleString()} (SF version: ${cache.version})`
648 | );
649 | console.error(`Found ${cache.commands.length} commands in cache`);
650 |
651 | return cache.commands;
652 | } catch (error) {
653 | console.error('Error loading command cache:', error);
654 | return null;
655 | }
656 | }
657 |
658 | /**
659 | * Register all SF commands as MCP tools
660 | * @returns The total number of registered tools
661 | */
662 | export async function registerSfCommands(server: McpServer): Promise {
663 | try {
664 | console.error('Starting SF command registration');
665 |
666 | // Try to load commands from cache first
667 | let sfCommands = loadCommandCache();
668 |
669 | // If cache doesn't exist or is invalid, fetch commands directly
670 | if (!sfCommands) {
671 | console.error('Cache not available or invalid, fetching commands directly');
672 | sfCommands = getAllSfCommands();
673 |
674 | // Save to cache for future use
675 | saveCommandCache(sfCommands);
676 | }
677 |
678 | // List of manually defined tools to avoid conflicts
679 | // Only includes the utility cache management tools
680 | const reservedTools = ['sf_cache_clear', 'sf_cache_refresh'];
681 |
682 | // Keep track of registered tools and aliases to avoid duplicates
683 | const registeredTools = new Set(reservedTools);
684 | const registeredAliases = new Set();
685 |
686 | // Register all commands as tools
687 | let toolCount = 0;
688 |
689 | for (const command of sfCommands) {
690 | try {
691 | // Create appropriate MCP-valid tool name
692 | let toolName: string;
693 |
694 | if (command.topic) {
695 | // For commands with topics, format as "sf_topic_command"
696 | toolName = `sf_${command.topic.replace(/:/g, '_')}_${command.name}`.replace(/[^a-zA-Z0-9_-]/g, '_');
697 | } else {
698 | // Standalone commands - sf_command
699 | toolName = `sf_${command.name}`.replace(/[^a-zA-Z0-9_-]/g, '_');
700 | }
701 |
702 | // Ensure tool name meets length requirements (1-64 characters)
703 | if (toolName.length > 64) {
704 | toolName = toolName.substring(0, 64);
705 | }
706 |
707 | // Skip if this tool name conflicts with a manually defined tool or is already registered
708 | if (registeredTools.has(toolName)) {
709 | console.error(`Skipping ${toolName} because it's already registered`);
710 | continue;
711 | }
712 |
713 | const zodSchema = commandToZodSchema(command);
714 |
715 | // Register the command as a tool with description
716 | server.tool(toolName, command.description, zodSchema, async (flags) => {
717 | const flagsStr = formatFlags(flags);
718 | const commandStr = `${command.fullCommand} ${flagsStr}`;
719 |
720 | console.error(`Executing: sf ${commandStr}`);
721 | try {
722 | const output = executeSfCommand(commandStr);
723 | // Check if the output indicates an error but was returned as normal output
724 | if (output && (output.includes('Error executing command') || output.includes('Error details:'))) {
725 | console.error(`Command returned error: ${output}`);
726 | return {
727 | content: [
728 | {
729 | type: 'text',
730 | text: output,
731 | },
732 | ],
733 | isError: true,
734 | };
735 | }
736 |
737 | return {
738 | content: [
739 | {
740 | type: 'text',
741 | text: output,
742 | },
743 | ],
744 | };
745 | } catch (error: any) {
746 | console.error(`Error executing ${commandStr}:`, error);
747 | const errorMessage = error.stdout || error.stderr || error.message || 'Unknown error';
748 | return {
749 | content: [
750 | {
751 | type: 'text',
752 | text: `Error: ${errorMessage}`,
753 | },
754 | ],
755 | isError: true,
756 | };
757 | }
758 | });
759 |
760 | // Add to registered tools set and increment counter
761 | registeredTools.add(toolName);
762 | toolCount++;
763 |
764 | // For nested commands, create simplified aliases when possible
765 | // (e.g., sf_get for sf_apex_log_get)
766 | if (command.topic && command.topic.includes(':') && command.name.length > 2) {
767 | const simplifiedName = command.name.toLowerCase();
768 | const simplifiedToolName = `sf_${simplifiedName}`.replace(/[^a-zA-Z0-9_-]/g, '_');
769 |
770 | // Skip if the simplified name is already registered as a tool or alias
771 | if (registeredTools.has(simplifiedToolName) || registeredAliases.has(simplifiedToolName)) {
772 | continue;
773 | }
774 |
775 | // Register simplified alias with description
776 | try {
777 | server.tool(simplifiedToolName, `Alias for ${command.description}`, zodSchema, async (flags) => {
778 | const flagsStr = formatFlags(flags);
779 | const commandStr = `${command.fullCommand} ${flagsStr}`;
780 |
781 | console.error(`Executing (via alias ${simplifiedToolName}): sf ${commandStr}`);
782 | try {
783 | const output = executeSfCommand(commandStr);
784 | // Check if the output indicates an error but was returned as normal output
785 | if (output && (output.includes('Error executing command') || output.includes('Error details:'))) {
786 | console.error(`Command returned error: ${output}`);
787 | return {
788 | content: [
789 | {
790 | type: 'text',
791 | text: output,
792 | },
793 | ],
794 | isError: true,
795 | };
796 | }
797 |
798 | return {
799 | content: [
800 | {
801 | type: 'text',
802 | text: output,
803 | },
804 | ],
805 | };
806 | } catch (error: any) {
807 | console.error(`Error executing ${commandStr}:`, error);
808 | const errorMessage = error.stdout || error.stderr || error.message || 'Unknown error';
809 | return {
810 | content: [
811 | {
812 | type: 'text',
813 | text: `Error: ${errorMessage}`,
814 | },
815 | ],
816 | isError: true,
817 | };
818 | }
819 | });
820 |
821 | // Add alias to tracking sets and increment counter
822 | registeredAliases.add(simplifiedToolName);
823 | registeredTools.add(simplifiedToolName);
824 | toolCount++;
825 | console.error(`Registered alias ${simplifiedToolName} for ${toolName}`);
826 | } catch (err) {
827 | console.error(`Error registering alias ${simplifiedToolName}:`, err);
828 | }
829 | }
830 | } catch (err) {
831 | console.error(`Error registering tool for command ${command.id}:`, err);
832 | }
833 | }
834 |
835 | const totalTools = toolCount + registeredAliases.size;
836 | console.error(
837 | `Registration complete. Registered ${totalTools} tools (${toolCount} commands and ${registeredAliases.size} aliases).`
838 | );
839 |
840 | // Return the count for the main server to use
841 | return totalTools;
842 | } catch (error) {
843 | console.error('Error registering SF commands:', error);
844 | return 0;
845 | }
846 | }
847 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Utility functions for working with the Salesforce CLI
3 | */
4 |
5 | /**
6 | * Parse a user message to look for project directory specification
7 | * @param message A message from the user that might contain project directory specification
8 | * @returns The extracted directory path, or null if none found
9 | */
10 | export function extractProjectDirectoryFromMessage(message: string): string | null {
11 | if (!message) return null;
12 |
13 | // Common patterns for specifying project directories
14 | const patterns = [
15 | // "Execute in /path/to/project"
16 | /[Ee]xecute\s+(?:in|from)\s+(['"]?)([\/~][^\n'"]+)\1/,
17 | // "Run in /path/to/project"
18 | /[Rr]un\s+(?:in|from)\s+(['"]?)([\/~][^\n'"]+)\1/,
19 | // "Use project in /path/to/project"
20 | /[Uu]se\s+project\s+(?:in|from|at)\s+(['"]?)([\/~][^\n'"]+)\1/,
21 | // "Set project directory to /path/to/project"
22 | /[Ss]et\s+project\s+directory\s+(?:to|as)\s+(['"]?)([\/~][^\n'"]+)\1/,
23 | // "Project is at /path/to/project"
24 | /[Pp]roject\s+(?:is|located)\s+(?:at|in)\s+(['"]?)([\/~][^\n'"]+)\1/,
25 | // "My project is in /path/to/project"
26 | /[Mm]y\s+project\s+is\s+(?:at|in)\s+(['"]?)([\/~][^\n'"]+)\1/,
27 | // "/path/to/project is my project"
28 | /(['"]?)([\/~][^\n'"]+)\1\s+is\s+my\s+(?:project|directory)/,
29 | ];
30 |
31 | for (const pattern of patterns) {
32 | const match = message.match(pattern);
33 | if (match) {
34 | return match[2];
35 | }
36 | }
37 |
38 | return null;
39 | }
40 |
41 | /**
42 | * Formats an object as a string representation of CLI flags
43 | * @param flags Key-value pairs of flag names and values
44 | * @returns Formatted flags string suitable for command line
45 | */
46 | export function formatFlags(flags: Record): string {
47 | if (!flags) return '';
48 |
49 | return Object.entries(flags)
50 | .map(([key, value]) => {
51 | // Skip undefined/null values
52 | if (value === undefined || value === null) return '';
53 |
54 | // Handle boolean flags
55 | if (typeof value === 'boolean') {
56 | return value ? `--${key}` : '';
57 | }
58 |
59 | // Handle arrays (space-separated multi-values)
60 | if (Array.isArray(value)) {
61 | return value.map((v) => `--${key}=${escapeValue(v)}`).join(' ');
62 | }
63 |
64 | // Handle objects (JSON stringify)
65 | if (typeof value === 'object') {
66 | return `--${key}=${escapeValue(JSON.stringify(value))}`;
67 | }
68 |
69 | // Regular values
70 | return `--${key}=${escapeValue(value)}`;
71 | })
72 | .filter(Boolean)
73 | .join(' ');
74 | }
75 |
76 | /**
77 | * Escapes values for command line usage
78 | */
79 | function escapeValue(value: any): string {
80 | const stringValue = String(value);
81 |
82 | // If value contains spaces, wrap in quotes
83 | if (stringValue.includes(' ')) {
84 | // Escape any existing quotes
85 | return `"${stringValue.replace(/"/g, '\\"')}"`;
86 | }
87 |
88 | return stringValue;
89 | }
90 |
91 | /**
92 | * Parses help text to extract structured information about commands or flags
93 | * @param helpText Help text from Salesforce CLI
94 | * @returns Structured information extracted from help text
95 | */
96 | export function parseHelpText(helpText: string): {
97 | description: string;
98 | examples: string[];
99 | flags: Record<
100 | string,
101 | {
102 | name: string;
103 | description: string;
104 | required: boolean;
105 | type: string;
106 | char?: string;
107 | }
108 | >;
109 | } {
110 | const description: string[] = [];
111 | const examples: string[] = [];
112 | const flags: Record = {};
113 |
114 | // Split by sections
115 | const sections = helpText.split(/\n\s*\n/);
116 |
117 | // Extract description (usually the first section, skipping DESCRIPTION header if present)
118 | if (sections.length > 0) {
119 | let firstSection = sections[0].trim();
120 | if (firstSection.toUpperCase().startsWith('DESCRIPTION')) {
121 | firstSection = firstSection.substring(firstSection.indexOf('\n') + 1).trim();
122 | }
123 | description.push(firstSection);
124 | }
125 |
126 | // Look for a description section if the first section wasn't clear
127 | if (description[0]?.length < 10 || description[0]?.toUpperCase().includes('USAGE')) {
128 | const descSection = sections.find(
129 | (section) =>
130 | section.toUpperCase().startsWith('DESCRIPTION') || section.toUpperCase().includes('\nDESCRIPTION\n')
131 | );
132 |
133 | if (descSection) {
134 | const descContent = descSection.replace(/DESCRIPTION/i, '').trim();
135 | if (descContent) {
136 | description.push(descContent);
137 | }
138 | }
139 | }
140 |
141 | // Look for examples section with improved pattern matching
142 | const examplePatterns = [/EXAMPLES?/i, /USAGE/i];
143 |
144 | for (const pattern of examplePatterns) {
145 | const exampleSection = sections.find((section) => pattern.test(section));
146 | if (exampleSection) {
147 | // Extract examples - look for command lines that start with $ or sf
148 | const exampleLines = exampleSection
149 | .split('\n')
150 | .filter((line) => {
151 | const trimmed = line.trim();
152 | return trimmed.startsWith('$') || trimmed.startsWith('sf ') || /^\s*\d+\.\s+sf\s+/.test(line); // Numbered examples: "1. sf ..."
153 | })
154 | .map((line) => line.trim().replace(/^\d+\.\s+/, '')); // Remove numbering if present
155 |
156 | examples.push(...exampleLines);
157 | }
158 | }
159 |
160 | // Look for flags section with improved pattern matching
161 | const flagPatterns = [/FLAGS/i, /OPTIONS/i, /PARAMETERS/i, /ARGUMENTS/i];
162 |
163 | for (const pattern of flagPatterns) {
164 | const flagSections = sections.filter((section) => pattern.test(section));
165 |
166 | for (const flagSection of flagSections) {
167 | // Skip the section header line
168 | const sectionLines = flagSection.split('\n').slice(1);
169 |
170 | // Different patterns for flag lines
171 | const flagPatterns = [
172 | // Pattern 1: Classic -c, --char= Description
173 | /^\s*(?:-([a-zA-Z]),\s+)?--([a-zA-Z][a-zA-Z0-9-]+)(?:=([a-zA-Z0-9_\-\[\]|]+)>?)?\s+(.+)$/,
174 |
175 | // Pattern 2: Indented flag with details (common in newer SF CLI)
176 | /^\s+(?:-([a-zA-Z]),\s+)?--([a-zA-Z][a-zA-Z0-9-]+)(?:\s+|\=)(?:<([a-zA-Z0-9_\-\[\]|]+)>)?\s*\n\s+(.+)/,
177 |
178 | // Pattern 3: Simple flag with no/minimal formatting
179 | /^\s*(?:-([a-zA-Z]),\s*)?--([a-zA-Z][a-zA-Z0-9-]+)(?:\s+|\=)?(?:\s*<([a-zA-Z0-9_\-\[\]|]+)>)?\s+(.+)$/,
180 | ];
181 |
182 | // Process the flag section
183 | let i = 0;
184 | while (i < sectionLines.length) {
185 | const line = sectionLines[i];
186 | const nextLine = i < sectionLines.length - 1 ? sectionLines[i + 1] : '';
187 | const combinedLines = line + '\n' + nextLine;
188 |
189 | let matched = false;
190 |
191 | // Try all patterns
192 | for (const pattern of flagPatterns) {
193 | const match = line.match(pattern) || combinedLines.match(pattern);
194 |
195 | if (match) {
196 | matched = true;
197 | const char = match[1];
198 | const name = match[2];
199 | const type = match[3] || 'boolean';
200 | const description = match[4].trim();
201 |
202 | // Check if this flag is required
203 | const required =
204 | description.toLowerCase().includes('(required)') ||
205 | description.toLowerCase().includes('[required]') ||
206 | description.toLowerCase().includes('required:') ||
207 | description.toLowerCase().includes('required -');
208 |
209 | // Normalize the type
210 | let normalizedType = type.toLowerCase();
211 | if (normalizedType.includes('number') || normalizedType.includes('int')) {
212 | normalizedType = 'number';
213 | } else if (normalizedType.includes('boolean') || normalizedType === 'flag') {
214 | normalizedType = 'boolean';
215 | } else if (normalizedType.includes('array') || normalizedType.includes('[]')) {
216 | normalizedType = 'array';
217 | } else if (normalizedType.includes('json') || normalizedType.includes('object')) {
218 | normalizedType = 'json';
219 | } else {
220 | normalizedType = 'string';
221 | }
222 |
223 | flags[name] = {
224 | name,
225 | char,
226 | description: description
227 | .replace(/\([Rr]equired\)|\[[Rr]equired\]|[Rr]equired:?/g, '')
228 | .trim(),
229 | required,
230 | type: normalizedType,
231 | };
232 |
233 | // Skip the next line if we matched against a two-line pattern
234 | if (combinedLines.match(pattern) && !line.match(pattern)) {
235 | i++;
236 | }
237 |
238 | break;
239 | }
240 | }
241 |
242 | // If no pattern matched and this line looks like it might be a flag
243 | if (!matched && (line.includes('--') || line.trim().startsWith('-'))) {
244 | console.error(`No pattern matched for potential flag line: "${line.trim()}"`);
245 | }
246 |
247 | i++;
248 | }
249 | }
250 | }
251 |
252 | return {
253 | description: description.join('\n\n'),
254 | examples,
255 | flags,
256 | };
257 | }
258 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
16 |
--------------------------------------------------------------------------------