diff --git a/README.md b/README.md
index 930c9868..66b1b399 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ These are the flags that you can pass to the `args` option.
| `--debug` | Boolean flag that requests that the DX MCP Server print debug logs. | No | Debug mode is disabled by default.
**NOTE:** Not all MCP clients expose MCP logs, so this flag might not work for all IDEs. |
| `--allow-non-ga-tools` | Boolean flag to allow the DX MCP Server to use both the generally available (GA) and NON-GA tools that are in the toolsets or tools you specify. | No | By default, the DX MCP server uses only the tools marked GA. |
| `--dynamic-tools` | (experimental) Boolean flag that enables dynamic tool discovery and loading. When specified, the DX MCP server starts with a minimal set of core tools and loads new tools as needed. | No| This flag is useful for reducing the initial context size and improving LLM performance. Dynamic tool discovery is disabled by default.
**NOTE:** This feature works in VSCode and Cline but may not work in other environments.|
+| `--sandbox-only` | Boolean flag that requires all allowed orgs to be sandboxes (not production orgs). When enabled, the server validates on startup that all orgs are sandboxes by querying the Organization.IsSandbox field. | No | If any production orgs are detected, the server will fail to start with an error message. This is a safety feature to prevent accidental operations on production data. |
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
index 36df3a1b..62fdf8a2 100644
--- a/packages/mcp/src/index.ts
+++ b/packages/mcp/src/index.ts
@@ -24,6 +24,7 @@ import { Telemetry } from './telemetry.js';
import { SfMcpServer } from './sf-mcp-server.js';
import { registerToolsets } from './utils/registry-utils.js';
import { Services } from './services.js';
+import { getAllAllowedOrgs, validateSandboxOrgs } from './utils/auth.js';
/**
* Sanitizes an array of org usernames by replacing specific orgs with a placeholder.
@@ -115,6 +116,10 @@ You can also use special values to control access to orgs:
'allow-non-ga-tools': Flags.boolean({
summary: 'Enable the ability to register tools that are not yet generally available (GA)',
}),
+ 'sandbox-only': Flags.boolean({
+ summary: 'Require all allowed orgs to be sandboxes (not production orgs)',
+ description: 'When enabled, the server will check on startup that all allowed orgs are sandboxes by querying the Organization.IsSandbox field. If any production orgs are detected, the server will fail to start with an error message.',
+ }),
};
public static examples = [
@@ -138,6 +143,10 @@ You can also use special values to control access to orgs:
description: 'Allow tools that are not generally available (NON-GA) to be registered with the server',
command: '<%= config.bin %> --toolsets all --orgs DEFAULT_TARGET_ORG --allow-non-ga-tools',
},
+ {
+ description: 'Start the server with sandbox-only validation to ensure no production orgs are used',
+ command: '<%= config.bin %> --toolsets all --orgs DEFAULT_TARGET_ORG --sandbox-only',
+ },
];
private telemetry?: Telemetry;
@@ -161,6 +170,13 @@ You can also use special values to control access to orgs:
await Cache.safeSet('allowedOrgs', new Set(flags.orgs));
this.logToStderr(`Allowed orgs:\n${flags.orgs.map((org) => `- ${org}`).join('\n')}`);
+
+ // Validate sandbox-only requirement if flag is set
+ if (flags['sandbox-only']) {
+ const allowedOrgs = await getAllAllowedOrgs();
+ await validateSandboxOrgs(allowedOrgs);
+ }
+
const server = new SfMcpServer(
{
name: 'sf-mcp-server',
diff --git a/packages/mcp/src/utils/auth.ts b/packages/mcp/src/utils/auth.ts
index 9ecb5ce2..2b54da74 100644
--- a/packages/mcp/src/utils/auth.ts
+++ b/packages/mcp/src/utils/auth.ts
@@ -152,3 +152,60 @@ export async function getDefaultTargetOrg(): Promise
export async function getDefaultTargetDevHub(): Promise {
return getDefaultConfig(OrgConfigProperties.TARGET_DEV_HUB);
}
+
+/**
+ * Validates that all allowed orgs are sandboxes (not production orgs)
+ * by querying the Organization.IsSandbox field via SOQL.
+ *
+ * @param allowedOrgs - Array of allowed org authorizations to validate
+ * @throws {Error} If any org is found to be a production org (IsSandbox = false)
+ */
+export async function validateSandboxOrgs(allowedOrgs: SanitizedOrgAuthorization[]): Promise {
+ const productionOrgs: string[] = [];
+
+ // eslint-disable-next-line no-await-in-loop
+ for (const org of allowedOrgs) {
+ if (!org.username) {
+ continue;
+ }
+
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ const authInfo = await AuthInfo.create({ username: org.username });
+ // eslint-disable-next-line no-await-in-loop
+ const connection = await Connection.create({ authInfo });
+
+ // Query the Organization object to check IsSandbox field
+ // eslint-disable-next-line no-await-in-loop
+ const result = await connection.query<{ IsSandbox: boolean }>('SELECT IsSandbox FROM Organization');
+
+ if (result.records && result.records.length > 0) {
+ const isSandbox = result.records[0].IsSandbox;
+
+ if (!isSandbox) {
+ // This is a production org
+ const orgIdentifier = org.aliases?.[0] ?? org.username;
+ productionOrgs.push(orgIdentifier);
+ }
+ }
+ } catch (error) {
+ // If we can't validate an org, we should warn but not fail
+ // This allows the server to start even if some orgs are temporarily unavailable
+ // eslint-disable-next-line no-console
+ console.error(
+ `Warning: Unable to validate sandbox status for org ${org.username}: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`
+ );
+ }
+ }
+
+ if (productionOrgs.length > 0) {
+ const orgList = productionOrgs.map((org) => ` - ${org}`).join('\n');
+ throw new Error(
+ 'Production org(s) detected. The --sandbox-only flag requires all orgs to be sandboxes.\n\n' +
+ `Production orgs found:\n${orgList}\n\n` +
+ 'To proceed with production orgs, run the MCP server without the --sandbox-only flag.'
+ );
+ }
+}
diff --git a/packages/mcp/test/unit/auth.test.ts b/packages/mcp/test/unit/auth.test.ts
index d8a0c108..8b55bf38 100644
--- a/packages/mcp/test/unit/auth.test.ts
+++ b/packages/mcp/test/unit/auth.test.ts
@@ -16,7 +16,14 @@
import { expect } from 'chai';
import sinon from 'sinon';
-import { AuthInfo, ConfigAggregator, ConfigInfo, OrgConfigProperties, type OrgAuthorization } from '@salesforce/core';
+import {
+ AuthInfo,
+ Connection,
+ ConfigAggregator,
+ ConfigInfo,
+ OrgConfigProperties,
+ type OrgAuthorization,
+} from '@salesforce/core';
import { type SanitizedOrgAuthorization } from '@salesforce/mcp-provider-api';
import {
getAllAllowedOrgs,
@@ -25,6 +32,7 @@ import {
sanitizeOrgs,
findOrgByUsernameOrAlias,
filterAllowedOrgs,
+ validateSandboxOrgs,
} from '../../src/utils/auth.js';
import Cache from '../../src/utils/cache.js';
@@ -941,4 +949,206 @@ describe('auth tests', () => {
expect(configAggregatorCreateStub.calledTwice).to.be.true; // ConfigAggregator is called every time to get the path.
});
});
+
+ describe('validateSandboxOrgs', () => {
+ let authInfoCreateStub: sinon.SinonStub;
+ let connectionCreateStub: sinon.SinonStub;
+ let connectionQueryStub: sinon.SinonStub;
+ let consoleErrorStub: sinon.SinonStub;
+
+ beforeEach(() => {
+ authInfoCreateStub = sandbox.stub(AuthInfo, 'create');
+ connectionQueryStub = sandbox.stub();
+ connectionCreateStub = sandbox.stub(Connection, 'create');
+ consoleErrorStub = sandbox.stub(console, 'error');
+
+ // Default connection setup
+ const mockConnection = {
+ query: connectionQueryStub,
+ };
+ connectionCreateStub.resolves(mockConnection);
+ });
+
+ it('should pass validation when all orgs are sandboxes', async () => {
+ const mockOrgs: SanitizedOrgAuthorization[] = [
+ {
+ username: 'sandbox1@example.com',
+ aliases: ['sandbox1'],
+ instanceUrl: 'https://sandbox1.salesforce.com',
+ isScratchOrg: false,
+ isDevHub: false,
+ isSandbox: true,
+ orgId: '00D000000000001EAA',
+ oauthMethod: 'web',
+ isExpired: false,
+ configs: null,
+ },
+ {
+ username: 'sandbox2@example.com',
+ aliases: ['sandbox2'],
+ instanceUrl: 'https://sandbox2.salesforce.com',
+ isScratchOrg: false,
+ isDevHub: false,
+ isSandbox: true,
+ orgId: '00D000000000002EAA',
+ oauthMethod: 'web',
+ isExpired: false,
+ configs: null,
+ },
+ ];
+
+ const mockAuthInfo = { username: 'test' };
+ authInfoCreateStub.resolves(mockAuthInfo);
+
+ // Mock SOQL query returning IsSandbox = true
+ connectionQueryStub.resolves({
+ records: [{ IsSandbox: true }],
+ });
+
+ // Should not throw
+ await validateSandboxOrgs(mockOrgs);
+
+ expect(authInfoCreateStub.calledTwice).to.be.true;
+ expect(connectionQueryStub.calledTwice).to.be.true;
+ });
+
+ it('should throw error when production org is detected', async () => {
+ const mockOrgs: SanitizedOrgAuthorization[] = [
+ {
+ username: 'production@example.com',
+ aliases: ['prod'],
+ instanceUrl: 'https://login.salesforce.com',
+ isScratchOrg: false,
+ isDevHub: false,
+ isSandbox: false,
+ orgId: '00D000000000001EAA',
+ oauthMethod: 'web',
+ isExpired: false,
+ configs: null,
+ },
+ ];
+
+ const mockAuthInfo = { username: 'test' };
+ authInfoCreateStub.resolves(mockAuthInfo);
+
+ // Mock SOQL query returning IsSandbox = false (production org)
+ connectionQueryStub.resolves({
+ records: [{ IsSandbox: false }],
+ });
+
+ try {
+ await validateSandboxOrgs(mockOrgs);
+ expect.fail('Should have thrown an error');
+ } catch (error) {
+ expect(error).to.be.instanceOf(Error);
+ expect((error as Error).message).to.include('Production org(s) detected');
+ expect((error as Error).message).to.include('prod');
+ }
+ });
+
+ it('should throw error with multiple production orgs', async () => {
+ const mockOrgs: SanitizedOrgAuthorization[] = [
+ {
+ username: 'production1@example.com',
+ aliases: ['prod1'],
+ instanceUrl: 'https://login.salesforce.com',
+ isScratchOrg: false,
+ isDevHub: false,
+ isSandbox: false,
+ orgId: '00D000000000001EAA',
+ oauthMethod: 'web',
+ isExpired: false,
+ configs: null,
+ },
+ {
+ username: 'production2@example.com',
+ aliases: ['prod2'],
+ instanceUrl: 'https://login.salesforce.com',
+ isScratchOrg: false,
+ isDevHub: false,
+ isSandbox: false,
+ orgId: '00D000000000002EAA',
+ oauthMethod: 'web',
+ isExpired: false,
+ configs: null,
+ },
+ ];
+
+ const mockAuthInfo = { username: 'test' };
+ authInfoCreateStub.resolves(mockAuthInfo);
+
+ // Mock SOQL query returning IsSandbox = false for both orgs
+ connectionQueryStub.resolves({
+ records: [{ IsSandbox: false }],
+ });
+
+ try {
+ await validateSandboxOrgs(mockOrgs);
+ expect.fail('Should have thrown an error');
+ } catch (error) {
+ expect(error).to.be.instanceOf(Error);
+ expect((error as Error).message).to.include('Production org(s) detected');
+ expect((error as Error).message).to.include('prod1');
+ expect((error as Error).message).to.include('prod2');
+ }
+ });
+
+
+ it('should warn but not fail when org validation connection fails', async () => {
+ const mockOrgs: SanitizedOrgAuthorization[] = [
+ {
+ username: 'broken@example.com',
+ aliases: ['broken'],
+ instanceUrl: 'https://broken.salesforce.com',
+ isScratchOrg: false,
+ isDevHub: false,
+ isSandbox: false,
+ orgId: '00D000000000001EAA',
+ oauthMethod: 'web',
+ isExpired: false,
+ configs: null,
+ },
+ ];
+
+ authInfoCreateStub.rejects(new Error('Connection failed'));
+
+ // Should not throw, but should log warning
+ await validateSandboxOrgs(mockOrgs);
+
+ expect(consoleErrorStub.called).to.be.true;
+ expect(consoleErrorStub.firstCall.args[0]).to.include('Warning: Unable to validate sandbox status');
+ });
+
+ it('should use username when no alias is available', async () => {
+ const mockOrgs: SanitizedOrgAuthorization[] = [
+ {
+ username: 'production@example.com',
+ aliases: null,
+ instanceUrl: 'https://login.salesforce.com',
+ isScratchOrg: false,
+ isDevHub: false,
+ isSandbox: false,
+ orgId: '00D000000000001EAA',
+ oauthMethod: 'web',
+ isExpired: false,
+ configs: null,
+ },
+ ];
+
+ const mockAuthInfo = { username: 'test' };
+ authInfoCreateStub.resolves(mockAuthInfo);
+
+ connectionQueryStub.resolves({
+ records: [{ IsSandbox: false }],
+ });
+
+ try {
+ await validateSandboxOrgs(mockOrgs);
+ expect.fail('Should have thrown an error');
+ } catch (error) {
+ expect(error).to.be.instanceOf(Error);
+ expect((error as Error).message).to.include('production@example.com');
+ }
+ });
+ });
});