Skip to content

Commit 5917bb6

Browse files
committed
First draft e2e mcp tests
1 parent 3446b76 commit 5917bb6

8 files changed

Lines changed: 581 additions & 3 deletions

File tree

.github/workflows/e2e-tests.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,39 @@ jobs:
166166
if (context.eventName === 'schedule') {
167167
github.rest.issues.create(issue);
168168
}
169+
170+
mcp-e2e-tests:
171+
name: MCP E2E
172+
runs-on: ubuntu-latest
173+
timeout-minutes: 15
174+
steps:
175+
- uses: actions/checkout@v4
176+
- uses: actions/setup-node@v4
177+
with:
178+
node-version: '22.x'
179+
- name: Setup pnpm
180+
uses: pnpm/action-setup@v4
181+
with:
182+
version: 10.17.1
183+
- name: Get pnpm store directory
184+
id: pnpm-store
185+
shell: bash
186+
run: |
187+
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
188+
- name: Setup pnpm cache
189+
uses: actions/cache@v4
190+
with:
191+
path: ${{ steps.pnpm-store.outputs.store_path }}
192+
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
193+
restore-keys: |
194+
${{ runner.os }}-pnpm-store-
195+
- name: Install dependencies
196+
run: pnpm install --frozen-lockfile
197+
- name: Build packages
198+
run: pnpm -r run build
199+
- name: Run MCP E2E tests
200+
working-directory: packages/b2c-dx-mcp
201+
env:
202+
SFCC_DISABLE_TELEMETRY: 'true'
203+
NODE_ENV: test
204+
run: pnpm run test:e2e:ci

packages/b2c-dx-mcp/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,11 @@
8484
"inspect": "mcp-inspector node bin/run.js --toolsets all --allow-non-ga-tools",
8585
"inspect:dev": "mcp-inspector node --conditions development bin/dev.js --toolsets all --allow-non-ga-tools",
8686
"pretest": "tsc --noEmit -p test",
87-
"test": "c8 mocha --forbid-only \"test/**/*.test.ts\"",
88-
"test:ci": "c8 mocha --forbid-only --reporter json --reporter-option output=test-results.json \"test/**/*.test.ts\"",
89-
"test:agent": "mocha --forbid-only --reporter min \"test/**/*.test.ts\"",
87+
"test": "c8 mocha --forbid-only --ignore 'test/e2e/**' \"test/**/*.test.ts\"",
88+
"test:ci": "c8 mocha --forbid-only --reporter json --reporter-option output=test-results.json --ignore 'test/e2e/**' \"test/**/*.test.ts\"",
89+
"test:agent": "mocha --forbid-only --reporter min --ignore 'test/e2e/**' \"test/**/*.test.ts\"",
90+
"test:e2e": "mocha --forbid-only \"test/e2e/**/*.test.ts\"",
91+
"test:e2e:ci": "mocha --forbid-only --reporter json --reporter-option output=test-results-e2e.json \"test/e2e/**/*.test.ts\"",
9092
"coverage": "c8 report",
9193
"posttest": "pnpm run lint",
9294
"prepack": "oclif manifest",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project name="app_custom" description="E2E test cartridge"/>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name": "e2e-empty", "private": true}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name": "e2e-pwav3", "dependencies": {"@salesforce/pwa-kit-react-sdk": "1.0.0"}}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name": "e2e-storefront-next", "dependencies": {"@salesforce/storefront-next-runtime": "1.0.0"}}
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
/**
8+
* E2E tests for the MCP server: real subprocess, JSON-RPC over stdin/stdout.
9+
* Run with: pnpm run test:e2e
10+
*/
11+
12+
import {dirname, join} from 'node:path';
13+
import {fileURLToPath} from 'node:url';
14+
import {expect} from 'chai';
15+
import {McpE2EClient} from './stdio-client.js';
16+
17+
const __dirname = dirname(fileURLToPath(import.meta.url));
18+
const FIXTURES_DIR = join(__dirname, 'fixtures');
19+
20+
describe('MCP Server E2E', function () {
21+
this.timeout(30_000);
22+
23+
describe('1. Server Lifecycle', () => {
24+
it('starts successfully with default options', async () => {
25+
const client = new McpE2EClient({args: ['--allow-non-ga-tools']});
26+
await client.start();
27+
const result = await client.call('tools/list');
28+
expect(result).to.have.property('tools').that.is.an('array');
29+
await client.stop();
30+
});
31+
32+
it('starts with --toolsets all', async () => {
33+
const client = new McpE2EClient({args: ['--toolsets', 'all', '--allow-non-ga-tools']});
34+
await client.start();
35+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
36+
expect(result.tools.length).to.be.greaterThan(0);
37+
await client.stop();
38+
});
39+
40+
it('starts with specific toolsets (--toolsets SCAPI,MRT)', async () => {
41+
const client = new McpE2EClient({args: ['--toolsets', 'SCAPI,MRT', '--allow-non-ga-tools']});
42+
await client.start();
43+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
44+
const names = result.tools.map((t) => t.name);
45+
expect(names).to.include('scapi_schemas_list');
46+
await client.stop();
47+
});
48+
49+
it('starts with specific tool (--tools scapi_schemas_list)', async () => {
50+
const client = new McpE2EClient({args: ['--tools', 'scapi_schemas_list', '--allow-non-ga-tools']});
51+
await client.start();
52+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
53+
expect(result.tools).to.have.lengthOf(1);
54+
expect(result.tools[0].name).to.equal('scapi_schemas_list');
55+
await client.stop();
56+
});
57+
58+
it('lists more tools with --allow-non-ga-tools', async () => {
59+
const client = new McpE2EClient({args: ['--toolsets', 'all', '--allow-non-ga-tools']});
60+
await client.start();
61+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
62+
await client.stop();
63+
expect(result.tools.length).to.be.greaterThan(0);
64+
});
65+
66+
it('exits cleanly on connection close', async () => {
67+
const client = new McpE2EClient({args: ['--toolsets', 'SCAPI']});
68+
await client.start();
69+
await client.stop();
70+
// stop() closes stdin and waits for process exit; no assertion needed beyond no throw
71+
});
72+
});
73+
74+
describe('2. MCP Protocol (tools/list)', () => {
75+
it('lists tools with --toolsets all --allow-non-ga-tools', async () => {
76+
const client = new McpE2EClient({args: ['--toolsets', 'all', '--allow-non-ga-tools']});
77+
await client.start();
78+
const result = (await client.call('tools/list')) as {
79+
tools: Array<{name: string; description?: string; inputSchema?: unknown}>;
80+
};
81+
expect(result.tools.length).to.be.greaterThan(0);
82+
const first = result.tools[0];
83+
expect(first).to.have.property('name').that.is.a('string');
84+
expect(first).to.have.property('description');
85+
expect(first).to.have.property('inputSchema');
86+
await client.stop();
87+
});
88+
89+
it('filters tools by toolset', async () => {
90+
const client = new McpE2EClient({args: ['--toolsets', 'SCAPI', '--allow-non-ga-tools']});
91+
await client.start();
92+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
93+
const names = result.tools.map((t) => t.name);
94+
expect(names.some((n) => n.startsWith('scapi_'))).to.be.true;
95+
await client.stop();
96+
});
97+
98+
it('filters tools by individual tool name', async () => {
99+
const client = new McpE2EClient({
100+
args: ['--tools', 'scapi_schemas_list,scapi_custom_apis_status', '--allow-non-ga-tools'],
101+
});
102+
await client.start();
103+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
104+
expect(result.tools).to.have.lengthOf(2);
105+
expect(result.tools.map((t) => t.name).sort()).to.deep.equal(['scapi_custom_apis_status', 'scapi_schemas_list']);
106+
await client.stop();
107+
});
108+
109+
it('ignores invalid --tools names and returns tools from enabled toolsets', async () => {
110+
const client = new McpE2EClient({
111+
args: ['--toolsets', 'SCAPI', '--tools', 'nonexistent_tool_xyz', '--allow-non-ga-tools'],
112+
});
113+
await client.start();
114+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
115+
expect(result.tools.length).to.be.greaterThan(0);
116+
expect(result.tools.some((t) => t.name.startsWith('scapi_'))).to.be.true;
117+
await client.stop();
118+
});
119+
120+
it('tool metadata includes name, description, inputSchema', async () => {
121+
const client = new McpE2EClient({args: ['--tools', 'scapi_schemas_list', '--allow-non-ga-tools']});
122+
await client.start();
123+
const result = (await client.call('tools/list')) as {
124+
tools: Array<{name: string; description: string; inputSchema: unknown}>;
125+
};
126+
expect(result.tools[0].name).to.equal('scapi_schemas_list');
127+
expect(result.tools[0].description).to.be.a('string').and.not.empty;
128+
expect(result.tools[0].inputSchema).to.be.an('object');
129+
await client.stop();
130+
});
131+
});
132+
133+
describe('3. MCP Protocol (tools/call)', () => {
134+
it('calls a tool and returns a response (result or structured error)', async () => {
135+
const client = new McpE2EClient({args: ['--tools', 'scapi_schemas_list', '--allow-non-ga-tools']});
136+
await client.start();
137+
const result = await client.call('tools/call', {
138+
name: 'scapi_schemas_list',
139+
arguments: {},
140+
});
141+
expect(result).to.be.an('object');
142+
// Without credentials we may get content with an error message or empty result; either is valid
143+
if ((result as {content?: unknown[]}).content) {
144+
expect((result as {content: unknown[]}).content).to.be.an('array');
145+
}
146+
await client.stop();
147+
});
148+
149+
it('returns proper error for unknown tool name', async () => {
150+
const client = new McpE2EClient({args: ['--toolsets', 'SCAPI', '--allow-non-ga-tools']});
151+
await client.start();
152+
let thrown: Error | undefined;
153+
let result: unknown;
154+
try {
155+
result = await client.call('tools/call', {
156+
name: 'nonexistent_tool_xyz',
157+
arguments: {},
158+
});
159+
} catch (error) {
160+
thrown = error instanceof Error ? error : new Error(String(error));
161+
}
162+
await client.stop();
163+
if (thrown) {
164+
expect(thrown.message).to.match(/unknown|not found|invalid/i);
165+
} else {
166+
const content = (result as {content?: Array<{type?: string; text?: string}>})?.content;
167+
const text = content?.map((c) => c?.text ?? '').join(' ') ?? '';
168+
expect(text.toLowerCase()).to.match(/not found|invalid|unknown/);
169+
}
170+
});
171+
172+
it('returns proper error for invalid input when required param missing', async () => {
173+
const client = new McpE2EClient({
174+
args: ['--tools', 'storefront_next_page_designer_decorator', '--allow-non-ga-tools'],
175+
});
176+
await client.start();
177+
try {
178+
await client.call('tools/call', {
179+
name: 'storefront_next_page_designer_decorator',
180+
arguments: {}, // missing required componentName etc.
181+
});
182+
// May throw or return content with error
183+
} catch (error) {
184+
expect(error).to.be.an('Error');
185+
}
186+
await client.stop();
187+
});
188+
});
189+
190+
describe('4. Workspace Auto-Discovery', () => {
191+
it('detects PWA Kit v3 from package.json', async () => {
192+
const cwd = join(FIXTURES_DIR, 'pwav3');
193+
const client = new McpE2EClient({args: ['--allow-non-ga-tools'], cwd});
194+
await client.start();
195+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
196+
const names = result.tools.map((t) => t.name);
197+
expect(names.some((n) => n.includes('pwa') || n.includes('mrt') || n.includes('scapi'))).to.be.true;
198+
await client.stop();
199+
});
200+
201+
it('detects Storefront Next from package.json', async () => {
202+
const cwd = join(FIXTURES_DIR, 'storefront-next');
203+
const client = new McpE2EClient({args: ['--allow-non-ga-tools'], cwd});
204+
await client.start();
205+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
206+
const names = result.tools.map((t) => t.name);
207+
expect(names.some((n) => n.includes('storefront_next') || n.includes('scapi'))).to.be.true;
208+
await client.stop();
209+
});
210+
211+
it('detects cartridge project from .project files', async () => {
212+
const cwd = join(FIXTURES_DIR, 'cartridge');
213+
const client = new McpE2EClient({args: ['--allow-non-ga-tools'], cwd});
214+
await client.start();
215+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
216+
const names = result.tools.map((t) => t.name);
217+
expect(names).to.include('cartridge_deploy');
218+
await client.stop();
219+
});
220+
221+
it('falls back to SCAPI toolset when no project detected', async () => {
222+
const cwd = join(FIXTURES_DIR, 'empty');
223+
const client = new McpE2EClient({args: ['--allow-non-ga-tools'], cwd});
224+
await client.start();
225+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
226+
const names = result.tools.map((t) => t.name);
227+
expect(names.some((n) => n.startsWith('scapi_'))).to.be.true;
228+
await client.stop();
229+
});
230+
});
231+
232+
describe('5. Flag Inheritance', () => {
233+
it('accepts --config flag', async () => {
234+
const client = new McpE2EClient({
235+
args: ['--toolsets', 'SCAPI', '--config', '/nonexistent/dw.json', '--allow-non-ga-tools'],
236+
});
237+
await client.start();
238+
const result = (await client.call('tools/list')) as {tools: unknown[]};
239+
expect(result.tools).to.be.an('array');
240+
await client.stop();
241+
});
242+
243+
it('accepts --log-level silent', async () => {
244+
const client = new McpE2EClient({
245+
args: ['--toolsets', 'SCAPI', '--log-level', 'silent', '--allow-non-ga-tools'],
246+
});
247+
await client.start();
248+
const result = (await client.call('tools/list')) as {tools: unknown[]};
249+
expect(result.tools).to.be.an('array');
250+
await client.stop();
251+
});
252+
253+
it('accepts --debug flag', async () => {
254+
const client = new McpE2EClient({args: ['--toolsets', 'SCAPI', '--debug', '--allow-non-ga-tools']});
255+
await client.start();
256+
const result = (await client.call('tools/list')) as {tools: unknown[]};
257+
expect(result.tools).to.be.an('array');
258+
await client.stop();
259+
});
260+
261+
it('accepts instance flags (--server, --client-id, etc.)', async () => {
262+
const client = new McpE2EClient({
263+
args: [
264+
'--toolsets',
265+
'SCAPI',
266+
'--server',
267+
'example.demandware.net',
268+
'--client-id',
269+
'cid',
270+
'--allow-non-ga-tools',
271+
],
272+
});
273+
await client.start();
274+
const result = (await client.call('tools/list')) as {tools: unknown[]};
275+
expect(result.tools).to.be.an('array');
276+
await client.stop();
277+
});
278+
279+
it('accepts MRT flags (--api-key, --project)', async () => {
280+
const client = new McpE2EClient({
281+
args: ['--toolsets', 'MRT', '--api-key', 'key', '--project', 'proj', '--allow-non-ga-tools'],
282+
});
283+
await client.start();
284+
const result = (await client.call('tools/list')) as {tools: unknown[]};
285+
expect(result.tools).to.be.an('array');
286+
await client.stop();
287+
});
288+
289+
it('respects environment variables as flag alternatives', async () => {
290+
const client = new McpE2EClient({
291+
args: [],
292+
env: {SFCC_TOOLSETS: 'SCAPI', SFCC_ALLOW_NON_GA_TOOLS: 'true'},
293+
});
294+
await client.start();
295+
const result = (await client.call('tools/list')) as {tools: Array<{name: string}>};
296+
expect(result.tools.some((t) => t.name.startsWith('scapi_'))).to.be.true;
297+
await client.stop();
298+
});
299+
});
300+
301+
describe('6. Error Handling', () => {
302+
it('server continues to respond after invalid JSON-RPC line', async () => {
303+
const client = new McpE2EClient({args: ['--toolsets', 'SCAPI', '--allow-non-ga-tools']});
304+
await client.start();
305+
client.sendRaw('not json\n');
306+
const result = (await client.call('tools/list')) as {tools: unknown[]};
307+
expect(result.tools).to.be.an('array');
308+
await client.stop();
309+
});
310+
311+
it('unknown method returns proper error', async () => {
312+
const client = new McpE2EClient({args: ['--toolsets', 'SCAPI', '--allow-non-ga-tools']});
313+
await client.start();
314+
try {
315+
await client.call('nonexistent/method', {});
316+
expect.fail('expected error');
317+
} catch (error) {
318+
expect(error).to.be.an('Error');
319+
expect((error as Error).message).to.match(/method|not found|invalid/i);
320+
}
321+
await client.stop();
322+
});
323+
});
324+
});

0 commit comments

Comments
 (0)