Skip to content

Commit 14368b9

Browse files
authored
Merge pull request #143 from ProvarTesting/fix/mcp-tool-names-underscore
v1.5.0-beta.16 fix(mcp): Windows path case-insensitivity + CI hardening
2 parents f7296bf + 18216d8 commit 14368b9

15 files changed

Lines changed: 410 additions & 49 deletions

.github/workflows/CI_Execution.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
provardx-ci-execution:
2323
strategy:
2424
matrix:
25-
os: ${{ fromJSON(inputs.OS && format('[{0}]', inputs.OS) || '["ubuntu-latest", "macos-latest"]') }}
25+
os: ${{ fromJSON(inputs.OS && format('[{0}]', inputs.OS) || '["ubuntu-latest", "macos-latest", "windows-latest"]') }}
2626
nodeversion: [20]
2727
runs-on: ${{ matrix.os }}
2828
steps:
@@ -99,6 +99,8 @@ jobs:
9999
run: |
100100
sf plugins link .
101101
yarn prepack
102+
- name: Unit tests
103+
run: npx mocha "test/**/*.test.ts" --timeout 30000
102104
- name: MCP smoke test
103105
timeout-minutes: 5
104106
env:

.github/workflows/DeployManual.yml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,21 @@ jobs:
6565
else
6666
# Auto-extract from git log since the previous tag
6767
if [ "${{ github.event_name }}" = "release" ]; then
68-
# For a release event HEAD is already the new tag; find the one before it
69-
PREV=$(git tag --sort=-creatordate | awk -v tag="${GITHUB_REF_NAME}" '$0==tag{found=1;next} found{print;exit}')
68+
# Release event: HEAD is the new tagfind the nearest ancestor tag before it
69+
PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || git tag --sort=-version:refname | tail -1)
7070
else
71-
# For a manual dispatch use the most recent existing tag
72-
PREV=$(git tag --sort=-creatordate | head -1)
71+
# Manual dispatch: find the nearest ancestor tag from HEAD
72+
# (git describe respects branch ancestry; avoids pulling in commits from sibling branches)
73+
PREV=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true)
7374
fi
7475
7576
RANGE="${PREV:+${PREV}..}HEAD"
7677
77-
RAW=$(git log --pretty=format:"%s" $RANGE \
78+
RAW=$(git log --pretty=format:"%s" "$RANGE" \
7879
| sed 's/^[A-Z][A-Z0-9]*-[0-9]*: //' \
79-
| grep -Ev "^(Merge |chore)" \
80+
| grep -Ev "^(Merge |chore|bump|increment)" \
81+
| grep -Ev "\(ci\):" \
82+
| grep -Eiv "review comments?|merge conflict|feedback items?|test fixtures?|pre-landing|address session|address PR|session [0-9]|resolving merge|PR #[0-9]|dot\.notation|server\.tool|registerTool|bump version|increment version|testCasePath|planitem|uuid lookup|coverage (count|skew)|buildTestCase|awk regex|deploy workflow" \
8083
| head -20)
8184
8285
FEATS=$(printf '%s\n' "$RAW" | grep '^feat' | sed 's/^feat[^:]*: /• /' | head -8)

docs/mcp-pilot-guide.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,8 @@ PathPolicy: assertPathAllowed(filePath, allowedPaths)
502502

503503
This check runs before every file read and write, including all path-type input fields — not just output file paths. Symlinks are dereferenced so that a symlink inside an allowed directory cannot escape containment. The allowed roots are set at server startup via `--allowed-paths` and cannot be changed while the server is running.
504504

505+
On **Windows**, all path comparisons are performed case-insensitively. `fs.realpathSync` does not always canonicalize drive-letter case (e.g. `c:\` vs `C:\`), so the policy normalizes both the candidate path and the allowed roots to lowercase before comparing. This means `C:\Projects\MyProject` and `c:\projects\myproject` are treated as the same path for containment purposes.
506+
505507
### Audit log
506508

507509
All tool invocations are logged to **stderr** with a unique `requestId` per call. The log format is structured JSON:

docs/mcp.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,8 @@ All file-system operations (read, write, generate) are restricted to the paths s
473473

474474
Symlinks are resolved via `fs.realpathSync` before the containment check, so a symlink inside an allowed directory that points outside it cannot bypass the restriction. For tools that accept multiple path inputs (such as `provar_ant_generate`'s `provar_home`, `project_path`, and `results_path`), all path fields are validated before any file operation occurs — not just the output path.
475475

476+
On **Windows**, path comparisons are performed case-insensitively to account for the fact that `fs.realpathSync` does not always canonicalize drive-letter case (e.g. `c:\` vs `C:\`). This means `C:\Projects\my-project` and `c:\projects\my-project` are treated as equivalent when checking against `--allowed-paths`.
477+
476478
---
477479

478480
## Available tools
@@ -750,6 +752,7 @@ Validates an XML test case for schema correctness (validity score) and best prac
750752
- **UI-LOCATOR-001** — A UiDoAction or UiAssert `locator` argument uses the wrong XML class. Must be `class="uiLocator"` or Provar cannot resolve the element.
751753
- **SETVALUES-STRUCTURE-001** (ERROR) — A `SetValues` step's `values` argument uses `class="value"` (plain string) instead of `class="valueList"` with `<namedValues>` children. This causes an immediate `ClassCastException` at runtime.
752754
- **VAR-REF-001** — An argument value looks like a variable reference (`{VarName}` or `{Obj.Field}`) but is stored as `class="value" valueClass="string"`. Provar will treat it as a literal string, not resolve the variable. Replace with `class="variable"` and `<path>` elements.
755+
- **VAR-REF-002** — A `{VarName}` token is embedded inside a larger plain string (e.g. `SELECT Id FROM Account WHERE Id = '{AccountId}'`). Provar does not perform `{…}` interpolation in string values at runtime; the braces are emitted literally. Use `class="compound"` with `<parts>` children to split the literal text and variable references. In `provar_testcase_generate`, pass the value with `{VarName}` placeholders — the generator emits compound XML automatically.
753756

754757
---
755758

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@provartesting/provardx-cli",
33
"description": "A plugin for the Salesforce CLI to orchestrate testing activities and report quality metrics to Provar Quality Hub",
4-
"version": "1.5.0-beta.15",
4+
"version": "1.5.0-beta.16",
55
"mcpName": "io.github.ProvarTesting/provar",
66
"license": "BSD-3-Clause",
77
"plugins": [

server.json

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,28 @@
1414
"url": "https://github.com/ProvarTesting/provardx-cli",
1515
"source": "github"
1616
},
17-
"version": "1.5.0-beta.15",
17+
"version": "1.5.0-beta.16",
1818
"packages": [
1919
{
2020
"registryType": "npm",
2121
"identifier": "@provartesting/provardx-cli",
22-
"version": "1.5.0-beta.15",
22+
"version": "1.5.0-beta.16",
2323
"transport": {
2424
"type": "stdio"
2525
},
2626
"runtimeArguments": [
27-
{ "type": "positional", "value": "provar" },
28-
{ "type": "positional", "value": "mcp" },
29-
{ "type": "positional", "value": "start" },
27+
{
28+
"type": "positional",
29+
"value": "provar"
30+
},
31+
{
32+
"type": "positional",
33+
"value": "mcp"
34+
},
35+
{
36+
"type": "positional",
37+
"value": "start"
38+
},
3039
{
3140
"type": "named",
3241
"name": "--allowed-paths",

src/mcp/security/pathPolicy.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,21 @@ export function assertPathAllowed(filePath: string, allowedPaths: string[]): voi
4444
try {
4545
resolved = fs.realpathSync(filePath);
4646
} catch {
47-
// Path doesn't exist — resolve the parent (which should exist) to catch symlinks there
48-
const parent = path.dirname(path.resolve(filePath));
47+
// Path doesn't exist — walk up the ancestor hierarchy to find the deepest existing directory,
48+
// resolve symlinks there, then re-attach the non-existent tail segments. This handles macOS
49+
// where os.tmpdir() returns /var/... (a symlink to /private/var/...) and intermediate dirs
50+
// for a new output path may not yet exist.
51+
const full = path.resolve(filePath);
52+
let cur = full;
53+
const tail: string[] = [];
54+
while (!fs.existsSync(cur) && cur !== path.dirname(cur)) {
55+
tail.unshift(path.basename(cur));
56+
cur = path.dirname(cur);
57+
}
4958
try {
50-
resolved = path.join(fs.realpathSync(parent), path.basename(filePath));
59+
resolved = path.join(fs.realpathSync(cur), ...tail);
5160
} catch {
52-
resolved = path.resolve(filePath);
61+
resolved = full;
5362
}
5463
}
5564

@@ -61,9 +70,18 @@ export function assertPathAllowed(filePath: string, allowedPaths: string[]): voi
6170
}
6271
});
6372

73+
// Windows file paths are case-insensitive; fs.realpathSync does not always
74+
// canonicalize drive-letter case (e.g. `c:\` vs `C:\`), so compare case-insensitively.
75+
const isWindows = process.platform === 'win32';
76+
const normalizeForCompare = (p: string): string => (isWindows ? p.toLowerCase() : p);
77+
const resolvedKey = normalizeForCompare(resolved);
78+
6479
if (
6580
resolvedAllowed.length > 0 &&
66-
!resolvedAllowed.some((base) => resolved === base || resolved.startsWith(base + path.sep))
81+
!resolvedAllowed.some((base) => {
82+
const baseKey = normalizeForCompare(base);
83+
return resolvedKey === baseKey || resolvedKey.startsWith(baseKey + path.sep);
84+
})
6785
) {
6886
throw new PathPolicyError(
6987
'PATH_NOT_ALLOWED',

src/mcp/tools/testCaseGenerate.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ const TOOL_DESCRIPTION = [
150150
'Cleanup warning: ApexDeleteObject steps near end of test will be skipped if an earlier step fails (stopOnError=false). Use a TearDown callable.',
151151
'Validation: when validate_after_edit=true (default) the response includes a validation field and returns TESTCASE_INVALID if the generated XML fails structural checks.',
152152
'Grounding: call provar_qualityhub_examples_retrieve before generating to get corpus examples for the scenario — correct XML structure for the step types you need.',
153+
'If the response has count: 0 with a warning field (API unavailable or not configured), fall back: read the provar://docs/step-reference MCP resource for step types and attribute formats, then continue.',
153154
].join(' ');
154155

155156
export function registerTestCaseGenerate(server: McpServer, config: ServerConfig): void {
@@ -278,7 +279,29 @@ export function registerTestCaseGenerate(server: McpServer, config: ServerConfig
278279

279280
// ── XML builder ───────────────────────────────────────────────────────────────
280281

281-
// Build the <value> element for a single argument (D2/D4 aware).
282+
// F1/F3: build class="compound" for strings that mix literal text with {VarName} tokens.
283+
function buildCompoundValue(val: string, indent: string): string {
284+
const i = `${indent} `;
285+
const parts: string[] = [];
286+
const tokenRe = /\{([\w.]+)\}/g;
287+
let last = 0;
288+
let m: RegExpExecArray | null;
289+
while ((m = tokenRe.exec(val)) !== null) {
290+
const before = val.slice(last, m.index);
291+
if (before) parts.push(`${i}<value valueClass="string">${escapeXmlContent(before)}</value>`);
292+
const pathElements = m[1]
293+
.split('.')
294+
.map((p) => `${i} <path element="${escapeXmlAttr(p)}"/>`)
295+
.join('\n');
296+
parts.push(`${i}<variable>\n${pathElements}\n${i}</variable>`);
297+
last = m.index + m[0].length;
298+
}
299+
const tail = val.slice(last);
300+
if (tail) parts.push(`${i}<value valueClass="string">${escapeXmlContent(tail)}</value>`);
301+
return `${indent}<value class="compound">\n${i}<parts>\n${parts.join('\n')}\n${i}</parts>\n${indent}</value>`;
302+
}
303+
304+
// Build the <value> element for a single argument (D2/D4/F1 aware).
282305
// inNamedValues: when true (inside SetValues namedValues), skip uiTarget/uiLocator dispatch.
283306
// apiId: resolved API ID used to restrict key-name dispatch to the correct UI APIs.
284307
function buildArgumentValue(key: string, val: string, indent: string, inNamedValues = false, apiId = ''): string {
@@ -291,6 +314,10 @@ function buildArgumentValue(key: string, val: string, indent: string, inNamedVal
291314
.join('\n');
292315
return `${indent}<value class="variable">\n${pathElements}\n${indent}</value>`;
293316
}
317+
// F1/F3: {VarName} embedded in surrounding text → class="compound" with <parts>.
318+
if (/\{[\w.]+\}/.test(val)) {
319+
return buildCompoundValue(val, indent);
320+
}
294321
if (!inNamedValues) {
295322
// D2: 'target' argument → class="uiTarget" (only for UiWithScreen / UiWithRow).
296323
if (key === 'target' && (apiId.includes('UiWithScreen') || apiId.includes('UiWithRow'))) {

src/mcp/tools/testCaseStepTools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export function registerTestCaseStepEdit(server: McpServer, config: ServerConfig
9898
'Returns STEP_NOT_FOUND (with all_test_item_ids list) when the target step is absent.',
9999
'Returns INVALID_STEP_XML when step_xml cannot be parsed or contains ≠1 <apiCall> elements.',
100100
'Returns INVALID_XML_AFTER_EDIT (backup restored) when the mutated file fails validation.',
101+
'Grounding for step_xml: call provar_qualityhub_examples_retrieve for corpus examples of the step type you need; if the response has count: 0 with a warning field, fall back: read the provar://docs/step-reference MCP resource.',
101102
].join(' '),
102103
inputSchema: {
103104
test_case_path: z.string().describe('Absolute path to the .testcase XML file; must be within --allowed-paths'),

src/mcp/tools/testCaseValidate.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function registerTestCaseValidate(server: McpServer, config: ServerConfig
4747
{
4848
title: 'Validate Test Case',
4949
description:
50-
'Validate a Provar XML test case for structural correctness and quality. Checks XML declaration, root element, required attributes (guid UUID v4, testItemId integer), <steps> presence, and applies best-practice rules. When a Provar API key is configured (via sf provar auth login or PROVAR_API_KEY env var), calls the Quality Hub API for full 170-rule scoring. Falls back to local validation if no key is set or the API is unavailable. Returns validity_score (schema compliance), quality_score (best practices, 0–100), and validation_source indicating which ruleset was applied.',
50+
'Validate a Provar XML test case for structural correctness and quality. Checks XML declaration, root element, required attributes (guid UUID v4, testItemId integer), <steps> presence, and applies best-practice rules. When a Provar API key is configured (via sf provar auth login or PROVAR_API_KEY env var), calls the Quality Hub API for full 170-rule scoring. Falls back to local validation if no key is set or the API is unavailable. Returns validity_score (schema compliance), quality_score (best practices, 0–100), and validation_source indicating which ruleset was applied. When structural errors are returned, consult the provar://docs/step-reference MCP resource for correct step attribute schemas.',
5151
inputSchema: {
5252
content: z.string().optional().describe('XML content to validate directly (alias: xml)'),
5353
xml: z.string().optional().describe('XML content to validate — API-compatible alias for content'),
@@ -329,23 +329,43 @@ export function validateTestCase(xmlContent: string, testName?: string): TestCas
329329
validateApiCall(call, issues);
330330
}
331331

332-
// VAR-REF-001 (gap in both local and quality-hub-agents backend):
333-
// Detect {VarName} or {Obj.Field} literals stored as plain string values.
334-
// Provar will pass these as-is to the API rather than resolving them as variable references.
335-
const varLiteralRe = /<value[^>]+valueClass="string"[^>]*>\{([\w.]+)\}<\/value>/g;
336-
let varMatch: RegExpExecArray | null;
337-
while ((varMatch = varLiteralRe.exec(xmlContent)) !== null) {
338-
issues.push({
339-
rule_id: 'VAR-REF-001',
340-
severity: 'WARNING',
341-
message: `Argument value "{${varMatch[1]}}" looks like a variable reference but is stored as a plain string — Provar will not resolve it at runtime.`,
342-
applies_to: 'argument',
343-
suggestion: `Replace with <value class="variable"><path element="${varMatch[1]
344-
.split('.')
345-
.join(
346-
'"/><path element="'
347-
)}"/></value>. In provar_testcase_generate, use the {VarName} syntax in the attributes object — the generator converts it automatically.`,
348-
});
332+
// VAR-REF-001 / VAR-REF-002: detect {VarName} tokens inside valueClass="string" elements.
333+
// Provar does not interpolate {…} tokens in plain string values at runtime — they must use
334+
// class="variable" (pure reference) or class="compound" (embedded in surrounding text).
335+
const stringValueRe = /<value[^>]+valueClass="string"[^>]*>([^<]+)<\/value>/g;
336+
let stringMatch: RegExpExecArray | null;
337+
while ((stringMatch = stringValueRe.exec(xmlContent)) !== null) {
338+
const rawContent = stringMatch[1];
339+
if (!/\{[\w.]+\}/.test(rawContent)) continue;
340+
const isPure = /^\{[\w.]+\}$/.test(rawContent.trim());
341+
const varNames = [...rawContent.matchAll(/\{([\w.]+)\}/g)].map((m) => m[1]);
342+
if (isPure) {
343+
const varName = varNames[0];
344+
issues.push({
345+
rule_id: 'VAR-REF-001',
346+
severity: 'WARNING',
347+
message: `Argument value "{${varName}}" looks like a variable reference but is stored as a plain string — Provar will not resolve it at runtime.`,
348+
applies_to: 'argument',
349+
suggestion: `Replace with <value class="variable"><path element="${varName
350+
.split('.')
351+
.join(
352+
'"/><path element="'
353+
)}"/></value>. In provar_testcase_generate, use the {VarName} syntax in the attributes object — the generator converts it automatically.`,
354+
});
355+
} else {
356+
const preview = rawContent.length > 60 ? rawContent.slice(0, 57) + '…' : rawContent;
357+
issues.push({
358+
rule_id: 'VAR-REF-002',
359+
severity: 'WARNING',
360+
message: `Argument value "${preview}" contains {${varNames.join(
361+
'}, {'
362+
)}} embedded in a plain string — Provar does not interpolate {…} tokens in string values at runtime.`,
363+
applies_to: 'argument',
364+
suggestion:
365+
'Use class="compound" with <parts> to split literal text and variable references at each {VarName} boundary. ' +
366+
'In provar_testcase_generate, pass the value with {VarName} placeholders in the attributes object — the generator emits compound XML automatically.',
367+
});
368+
}
349369
}
350370

351371
return finalize(issues, tcId, tcName, apiCalls.length, xmlContent, testName);

0 commit comments

Comments
 (0)