Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. <br/> <br/>**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.<br/> <br/>**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. |

</details>
<details>
Expand Down
16 changes: 16 additions & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = [
Expand All @@ -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;
Expand All @@ -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',
Expand Down
57 changes: 57 additions & 0 deletions packages/mcp/src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,60 @@ export async function getDefaultTargetOrg(): Promise<OrgConfigInfo | undefined>
export async function getDefaultTargetDevHub(): Promise<OrgConfigInfo | undefined> {
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<void> {
const productionOrgs: string[] = [];

// eslint-disable-next-line no-await-in-loop
for (const org of allowedOrgs) {
Copy link
Contributor Author

@zerkz zerkz Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could possibly speed up this section/logic by putting this unit of work into it's own promise, tossing it in an array, and doing Promise.all(). Might get rid of the need for all these eslint disables!

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.'
);
}
}
212 changes: 211 additions & 1 deletion packages/mcp/test/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +32,7 @@ import {
sanitizeOrgs,
findOrgByUsernameOrAlias,
filterAllowedOrgs,
validateSandboxOrgs,
} from '../../src/utils/auth.js';
import Cache from '../../src/utils/cache.js';

Expand Down Expand Up @@ -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: '[email protected]',
aliases: ['sandbox1'],
instanceUrl: 'https://sandbox1.salesforce.com',
isScratchOrg: false,
isDevHub: false,
isSandbox: true,
orgId: '00D000000000001EAA',
oauthMethod: 'web',
isExpired: false,
configs: null,
},
{
username: '[email protected]',
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: '[email protected]',
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: '[email protected]',
aliases: ['prod1'],
instanceUrl: 'https://login.salesforce.com',
isScratchOrg: false,
isDevHub: false,
isSandbox: false,
orgId: '00D000000000001EAA',
oauthMethod: 'web',
isExpired: false,
configs: null,
},
{
username: '[email protected]',
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: '[email protected]',
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: '[email protected]',
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('[email protected]');
}
});
});
});