Skip to content

Commit 4680070

Browse files
authored
fix: tools use org service W-19828433 (#287)
* chore: stricter e2e test * chore: org resume returns username * chore: stricter access * chore: manual allowlist org check
1 parent e9a21ed commit 4680070

File tree

8 files changed

+108
-29
lines changed

8 files changed

+108
-29
lines changed

packages/mcp-provider-dx-core/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ export class DxCoreMcpProvider extends McpProvider {
3838
return Promise.resolve([
3939
new AssignPermissionSetMcpTool(services),
4040
new CreateOrgSnapshotMcpTool(services),
41-
new CreateScratchOrgMcpTool(),
42-
new DeleteOrgMcpTool(),
41+
new CreateScratchOrgMcpTool(services),
42+
new DeleteOrgMcpTool(services),
4343
new DeployMetadataMcpTool(services),
4444
new GetUsernameMcpTool(services),
4545
new ListAllOrgsMcpTool(services),
46-
new OrgOpenMcpTool(),
46+
new OrgOpenMcpTool(services),
4747
new QueryOrgMcpTool(services),
4848
new ResumeMcpTool(services),
4949
new RetrieveMetadataMcpTool(services),

packages/mcp-provider-dx-core/src/tools/create_org_snapshot.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ create a snapshot of my MyScratch in myDevHub`,
8888
try {
8989
process.chdir(input.directory);
9090

91-
const sourceOrgId = (await Org.create({ aliasOrUsername: input.sourceOrg })).getOrgId();
91+
const connection = await this.services.getOrgService().getConnection(input.sourceOrg);
92+
93+
const sourceOrgId = (await Org.create({ connection })).getOrgId();
9294
const devHubConnection = await this.services.getOrgService().getConnection(input.devHub);
9395
const createResponse = await devHubConnection.sobject('OrgSnapshot').create({
9496
SourceOrg: sourceOrgId,

packages/mcp-provider-dx-core/src/tools/create_scratch_org.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { z } from 'zod';
2020
import { Org, scratchOrgCreate, ScratchOrgCreateOptions } from '@salesforce/core';
2121
import { Duration } from '@salesforce/kit';
2222
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
23-
import { McpTool, McpToolConfig, ReleaseState, Toolset } from '@salesforce/mcp-provider-api';
23+
import { McpTool, McpToolConfig, ReleaseState, Services, Toolset } from '@salesforce/mcp-provider-api';
2424
import { ensureString } from '@salesforce/ts-types/lib/narrowing/ensure.js';
2525
import { textResponse } from '../shared/utils.js';
2626
import { directoryParam, usernameOrAliasParam } from '../shared/params.js';
@@ -95,6 +95,10 @@ type InputArgsShape = typeof createScratchOrgParams.shape;
9595
type OutputArgsShape = z.ZodRawShape;
9696

9797
export class CreateScratchOrgMcpTool extends McpTool<InputArgsShape, OutputArgsShape> {
98+
public constructor(private readonly services: Services) {
99+
super();
100+
}
101+
98102
public getReleaseState(): ReleaseState {
99103
return ReleaseState.NON_GA;
100104
}
@@ -127,6 +131,27 @@ create a scratch org aliased as MyNewOrg and set as default and don't wait for i
127131
public async exec(input: InputArgs): Promise<CallToolResult> {
128132
try {
129133
process.chdir(input.directory);
134+
135+
// NOTE:
136+
// this should be:
137+
// ```ts
138+
// const connection = await this.services.getOrgService().getConnection(input.devHub);
139+
// const hubOrProd = await Org.create({ connection });
140+
// ```
141+
//
142+
// but there's a bug where if you create scratch synchronously, sfdx-core throws while polling;
143+
// ```
144+
// [NamedOrgNotFoundError]: No authorization information found for <devhub-username>.
145+
// ```
146+
//
147+
// it doesn't happen when creating asynchronously.
148+
// will be fixed in W-19828802
149+
const allowedOrgs = await this.services.getOrgService().getAllowedOrgs()
150+
if (!allowedOrgs.find(o => o.aliases?.includes(input.devHub) || o.username === input.devHub)) {
151+
throw new Error(
152+
'No org found with the provided devhub username/alias. Ask the user to specify one or check their MCP Server startup config.'
153+
)}
154+
130155
const hubOrProd = await Org.create({ aliasOrUsername: input.devHub });
131156

132157
const requestParams: ScratchOrgCreateOptions = {

packages/mcp-provider-dx-core/src/tools/delete_org.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { z } from 'zod';
1818
import { AuthRemover, Org } from '@salesforce/core';
1919
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
20-
import { McpTool, McpToolConfig, ReleaseState, Toolset } from '@salesforce/mcp-provider-api';
20+
import { McpTool, McpToolConfig, ReleaseState, Services, Toolset } from '@salesforce/mcp-provider-api';
2121
import { textResponse } from '../shared/utils.js';
2222
import { directoryParam, usernameOrAliasParam } from '../shared/params.js';
2323

@@ -42,6 +42,10 @@ type InputArgsShape = typeof deleteOrgParams.shape;
4242
type OutputArgsShape = z.ZodRawShape;
4343

4444
export class DeleteOrgMcpTool extends McpTool<InputArgsShape, OutputArgsShape> {
45+
public constructor(private readonly services: Services) {
46+
super();
47+
}
48+
4549
public getReleaseState(): ReleaseState {
4650
return ReleaseState.NON_GA;
4751
}
@@ -75,7 +79,9 @@ Can you delete [email protected]`,
7579
public async exec(input: InputArgs): Promise<CallToolResult> {
7680
try {
7781
process.chdir(input.directory);
78-
const org = await Org.create({ aliasOrUsername: input.usernameOrAlias });
82+
const connection = await this.services.getOrgService().getConnection(input.usernameOrAlias);
83+
const org = await Org.create({ connection });
84+
7985
await org.delete();
8086
return textResponse(`Successfully deleted ${input.usernameOrAlias}`);
8187
} catch (e) {

packages/mcp-provider-dx-core/src/tools/open_org.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { z } from 'zod';
1818
import { Org } from '@salesforce/core';
1919
import { MetadataResolver } from '@salesforce/source-deploy-retrieve';
2020
import open from 'open';
21-
import { McpTool, McpToolConfig, ReleaseState, Toolset } from '@salesforce/mcp-provider-api';
21+
import { McpTool, McpToolConfig, ReleaseState, Services, Toolset } from '@salesforce/mcp-provider-api';
2222
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2323
import { textResponse } from '../shared/utils.js';
2424
import { directoryParam, usernameOrAliasParam } from '../shared/params.js';
@@ -37,6 +37,10 @@ type InputArgsShape = typeof orgOpenParamsSchema.shape;
3737
type OutputArgsShape = z.ZodRawShape;
3838

3939
export class OrgOpenMcpTool extends McpTool<InputArgsShape, OutputArgsShape> {
40+
public constructor(private readonly services: Services) {
41+
super();
42+
}
43+
4044
public getReleaseState(): ReleaseState {
4145
return ReleaseState.NON_GA;
4246
}
@@ -67,9 +71,11 @@ You can specify a metadata file you want to open.`,
6771
public async exec(input: InputArgs): Promise<CallToolResult> {
6872
process.chdir(input.directory);
6973

74+
const connection = await this.services.getOrgService().getConnection(input.usernameOrAlias);
75+
7076
const org = await Org.create({
71-
aliasOrUsername: input.usernameOrAlias,
72-
});
77+
connection
78+
})
7379

7480
if (input.filePath) {
7581
const metadataResolver = new MetadataResolver();

packages/mcp-provider-dx-core/src/tools/resume_tool_operation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Duration } from '@salesforce/kit';
2121
import { MetadataApiDeploy } from '@salesforce/source-deploy-retrieve';
2222
import { McpTool, McpToolConfig, ReleaseState, Services, Toolset } from '@salesforce/mcp-provider-api';
2323
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
24+
import { ensureString } from '@salesforce/ts-types';
2425
import { textResponse } from '../shared/utils.js';
2526
import { directoryParam, usernameOrAliasParam } from '../shared/params.js';
2627
import { type ToolTextResponse } from '../shared/types.js';
@@ -46,7 +47,7 @@ const resumableIdPrefixes = new Map<string, string>([
4647
* Returns:
4748
* - textResponse: Username/alias and org configuration
4849
*/
49-
const resumeParamsSchema = z.object({
50+
export const resumeParamsSchema = z.object({
5051
jobId: z.string().describe('The job id of the long running operation to resume (required)'),
5152
wait: z
5253
.number()
@@ -178,7 +179,7 @@ async function resumeOrgSnapshot(connection: Connection, jobId: string, wait: nu
178179
async function resumeScratchOrg(jobId: string, wait: number): Promise<ToolTextResponse> {
179180
try {
180181
const result = await scratchOrgResume(jobId, Duration.minutes(wait));
181-
return textResponse(`Scratch org created: ${JSON.stringify(result)}`, false);
182+
return textResponse(`Successfully created scratch org, username: ${ensureString(result.username)}`);
182183
} catch (error) {
183184
return textResponse(
184185
`Resumed scratch org creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,

packages/mcp-provider-dx-core/test/e2e/create_scratch_org.test.ts

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,23 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { expect } from 'chai';
17+
import { assert, expect } from 'chai';
1818
import { McpTestClient, DxMcpTransport } from '@salesforce/mcp-test-client';
1919
import { TestSession } from '@salesforce/cli-plugins-testkit';
2020
import { z } from 'zod';
2121
import { matchesAccessToken } from '@salesforce/core';
22+
import { ensureString } from '@salesforce/ts-types';
2223
import { createScratchOrgParams } from '../../src/tools/create_scratch_org.js';
24+
import { resumeParamsSchema } from '../../src/tools/resume_tool_operation.js';
2325

2426
describe('create_scratch_org', () => {
2527
const client = new McpTestClient({
2628
timeout: 120_000,
2729
});
2830

29-
const devHubUsername = process.env.TESTKIT_HUB_USERNAME as string;
31+
let devHubUsername: string;
3032

3133
let testSession: TestSession;
32-
let resolvedDevHubUsername: string;
3334

3435
const createScratchOrgSchema = {
3536
name: z.literal('create_scratch_org'),
@@ -44,15 +45,10 @@ describe('create_scratch_org', () => {
4445
devhubAuthStrategy: 'AUTO',
4546
});
4647

47-
const hubUsername = testSession.hubOrg?.username;
48-
resolvedDevHubUsername = hubUsername ?? devHubUsername;
49-
50-
if (!resolvedDevHubUsername) {
51-
throw new Error('No DevHub username available from TestSession or TESTKIT_HUB_USERNAME environment variable');
52-
}
48+
devHubUsername = ensureString(testSession.hubOrg?.username);
5349

5450
const transport = DxMcpTransport({
55-
args: ['--orgs', 'ALLOW_ALL_ORGS', '--no-telemetry', '--toolsets', 'all', '--allow-non-ga-tools'],
51+
args: ['--orgs', `DEFAULT_TARGET_ORG,${devHubUsername}`, '--no-telemetry', '--toolsets', 'all', '--allow-non-ga-tools'],
5652
});
5753

5854
await client.connect(transport);
@@ -72,7 +68,7 @@ describe('create_scratch_org', () => {
7268
name: 'create_scratch_org',
7369
params: {
7470
directory: testSession.project.dir,
75-
devHub: resolvedDevHubUsername,
71+
devHub: devHubUsername,
7672
},
7773
});
7874

@@ -81,7 +77,7 @@ describe('create_scratch_org', () => {
8177
expect(result.content[0].type).to.equal('text');
8278

8379
const responseText = result.content[0].text as string;
84-
expect(matchesAccessToken(responseText)).to.be.false;
80+
assertNoSensitiveInfo(responseText)
8581
expect(responseText).to.include('Successfully created scratch org');
8682
});
8783

@@ -90,25 +86,56 @@ describe('create_scratch_org', () => {
9086
name: 'create_scratch_org',
9187
params: {
9288
directory: testSession.project.dir,
93-
devHub: resolvedDevHubUsername,
89+
devHub: devHubUsername,
9490
async: true,
9591
alias: 'test-async-org'
9692
},
9793
});
9894
expect(asyncResult.isError).to.be.false;
9995
expect(asyncResult.content.length).to.equal(1);
100-
expect(asyncResult.content[0].type).to.equal('text');
10196

97+
if (asyncResult.content[0].type !== 'text') assert.fail();
98+
10299
const asyncResponseText = asyncResult.content[0].text;
103100
expect(asyncResponseText).to.include('Successfully enqueued scratch org with job Id:');
101+
102+
// now validate it was created by resuming the operation
103+
104+
const jobIdMatch = asyncResponseText.match(/job Id: ([A-Za-z0-9]+)/);
105+
expect(jobIdMatch).to.not.be.null;
106+
107+
const jobId: string = jobIdMatch![1]
108+
109+
const asyncResumeResult = await client.callTool({
110+
name: z.literal('resume_tool_operation'),
111+
params: resumeParamsSchema
112+
}, {
113+
name: 'resume_tool_operation',
114+
params: {
115+
directory: testSession.project.dir,
116+
jobId,
117+
usernameOrAlias: ensureString(testSession.hubOrg.username)
118+
},
119+
});
120+
121+
expect(asyncResumeResult.isError).to.be.false;
122+
expect(asyncResumeResult.content.length).to.equal(1);
123+
124+
if (asyncResumeResult.content[0].type !== 'text') assert.fail();
125+
126+
const asyncResumeResponseText = asyncResumeResult.content[0].text;
127+
128+
// tool output shouldn't access tokens/auth info other than the username
129+
assertNoSensitiveInfo(asyncResumeResponseText);
130+
expect(asyncResumeResponseText).to.include('Successfully created scratch org');
104131
});
105132

106133
it('should create scratch org with optional parameters', async () => {
107134
const result = await client.callTool(createScratchOrgSchema, {
108135
name: 'create_scratch_org',
109136
params: {
110137
directory: testSession.project.dir,
111-
devHub: resolvedDevHubUsername,
138+
devHub: devHubUsername,
112139
alias: 'test-custom-org',
113140
duration: 3,
114141
edition: 'developer',
@@ -142,4 +169,16 @@ describe('create_scratch_org', () => {
142169
const responseText = result.content[0].text;
143170
expect(responseText).to.include('Failed to create org:');
144171
});
145-
});
172+
});
173+
174+
/**
175+
* Helper function to assert that response text doesn't contain sensitive authentication information
176+
*/
177+
function assertNoSensitiveInfo(responseText: string): void {
178+
expect(matchesAccessToken(responseText)).to.be.false;
179+
expect(responseText).to.not.match(/authcode/i);
180+
expect(responseText).to.not.match(/token/i);
181+
expect(responseText).to.not.match(/privatekey/i);
182+
expect(responseText).to.not.match(/clientid/i);
183+
expect(responseText).to.not.match(/connectedappconsumerkey/i);
184+
}

packages/mcp-provider-dx-core/test/e2e/retrieve_metadata.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { retrieveMetadataParams } from '../../src/tools/retrieve_metadata.js';
2525

2626
describe('retrieve_metadata', () => {
2727
const client = new McpTestClient({
28-
timeout: 60000,
28+
timeout: 60_000,
2929
});
3030

3131
let testSession: TestSession;

0 commit comments

Comments
 (0)