Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This file is read automatically by Claude Code at the start of every session. Fo
| Modified tool description / parameters / errors | `docs/mcp.md` (update the relevant tool section) |
| Removed tool | `docs/mcp.md`, `docs/mcp-pilot-guide.md`, `README.md` |
| New error code or suggestion field | `docs/mcp.md` (Troubleshooting or Error Codes section) |
| Security model change (path policy, licence, transport) | `docs/mcp.md` (Security Model section), `docs/mcp-pilot-guide.md` (Security Model section) |
| Security model change (path policy, license, transport) | `docs/mcp.md` (Security Model section), `docs/mcp-pilot-guide.md` (Security Model section) |
| New NitroX tool or schema rule | `docs/mcp.md` (NitroX section), `docs/mcp-pilot-guide.md` (Scenario 7) |
| Smoke test count change | Update `TOTAL_EXPECTED` in `scripts/mcp-smoke.cjs` |

Expand Down
22 changes: 11 additions & 11 deletions docs/mcp-pilot-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The server runs **locally on your machine**. It does not phone home, transmit yo

| Requirement | Version | Notes |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Provar Automation IDE | ≥ 2.x | Must be installed with an **activated licence** on the same machine. The MCP server reads the licence from `~/Provar/.licenses/`. |
| Provar Automation IDE | ≥ 3.x | Must be installed with an **activated license** on the same machine. The MCP server reads the license from `~/Provar/.licenses/`. |
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

This guide now states Provar Automation IDE version "≥ 3.x". No other docs in the repo specify this higher minimum (e.g. docs/mcp.md only requires an activated IDE license), so this introduces an inconsistency for users. Either align the other docs to the same requirement or add a brief note explaining why IDE 2.x is no longer supported.

Copilot uses AI. Check for mistakes.
| Salesforce CLI (`sf`) | ≥ 2.x | `npm install -g @salesforce/cli` |
| Provar DX CLI plugin | ≥ 1.5.0 | `sf plugins install @provartesting/provardx-cli@beta` |
| An MCP-compatible AI client | — | Claude Desktop, Claude Code, GitHub Copilot (VS Code), Cursor, or Agentforce Vibes |
Expand Down Expand Up @@ -74,7 +74,7 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) o
}
```

> **Licence:** The server reads your Provar Automation IDE licence automatically from `~/Provar/.licenses/`. No extra configuration is required — just ensure Provar Automation IDE is installed and activated on this machine.
> **License:** The server reads your Provar Automation IDE license automatically from `~/Provar/.licenses/`. No extra configuration is required — just ensure Provar Automation IDE is installed and activated on this machine.

Restart Claude Desktop after saving the file. The Provar tools will appear in the tool list.

Expand Down Expand Up @@ -422,11 +422,11 @@ NitroX is Provar's Hybrid Model for locators — it maps Salesforce component-ba
- Validates all path-type input fields (e.g. `provar_home`, `project_path`, `results_path` in `provar.ant.generate`) before any file operation, not just the output path
- Invokes `sf` CLI subprocesses for Quality Hub and Automation tools — these use the SF CLI's existing credential store (`~/.sf/credentials.json`), which the MCP server does not read directly

### Licence validation
### License validation

At startup the server reads `~/Provar/.licenses/*.properties` to verify that a Provar Automation IDE licence is activated on the machine. No network call is made during this check — it relies entirely on the licence state that the IDE has already validated and written to disk. The server makes no outbound connections for licence purposes.
At startup the server reads `~/Provar/.licenses/*.properties` to verify that a Provar Automation IDE license is activated on the machine. No network call is made during this check — it relies entirely on the license state that the IDE has already validated and written to disk. The server makes no outbound connections for license purposes.

**Evaluating without a local Provar IDE installation?** Set the `PROVAR_DEV_WHITELIST_KEYS` environment variable to one or more comma-separated licence keys to bypass the licence check entirely. This is intended for Provar engineering and CI environments:
**Evaluating without a local Provar IDE installation?** Set the `PROVAR_DEV_WHITELIST_KEYS` environment variable to one or more comma-separated license keys to bypass the license check entirely. This is intended for Provar engineering and CI environments:

```json
{
Expand All @@ -435,19 +435,19 @@ At startup the server reads `~/Provar/.licenses/*.properties` to verify that a P
"command": "sf",
"args": ["provar", "mcp", "start", "--allowed-paths", "/path/to/project"],
"env": {
"PROVAR_DEV_WHITELIST_KEYS": "your-provar-licence-key"
"PROVAR_DEV_WHITELIST_KEYS": "your-provar-license-key"
}
}
}
}
```

> ⚠️ Do not use `PROVAR_DEV_WHITELIST_KEYS` in production environments — it bypasses all licence enforcement.
> ⚠️ Do not use `PROVAR_DEV_WHITELIST_KEYS` in production environments — it bypasses all license enforcement.

### What the server does NOT do

- It does not transmit your project files, test code, or credentials to Provar servers
- It does not make any network calls (licence is validated locally from the IDE's state)
- It does not make any network calls (license is validated locally from the IDE's state)
- It does not open any network ports or HTTP listeners (stdio transport only)
- It does not store state between requests — every tool call is stateless
- It does not read or modify files outside `--allowed-paths`
Expand Down Expand Up @@ -502,13 +502,13 @@ You can capture stderr from the MCP server process to maintain an audit trail of

**"No activated Provar license found on this machine" / `LICENSE_NOT_FOUND`**

The server could not find an activated licence in `~/Provar/.licenses/`. Open Provar Automation IDE, go to **Help → Manage Licence**, and ensure your licence is activated. Then retry starting the MCP server.
The server could not find an activated license in `~/Provar/.licenses/`. Open Provar Automation IDE, go to **Help → Manage license**, and ensure your license is activated. Then retry starting the MCP server.

If you are evaluating without a local Provar IDE installation, set `PROVAR_DEV_WHITELIST_KEYS` in the MCP server environment to bypass the licence check (see below).
If you are evaluating without a local Provar IDE installation, set `PROVAR_DEV_WHITELIST_KEYS` in the MCP server environment to bypass the license check (see below).

**"[provar-mcp] Warning: license validated from offline cache" (appears on stderr)**

The server started successfully but the MCP licence cache is stale (more than 2 hours since the last validation). This is a warning only — the server is running. The grace window is 48 hours; if the cache exceeds that without a successful re-validation the next startup will fail with `LICENSE_NOT_FOUND`. To reset: restart the MCP server while Provar Automation IDE is open and connected to the internet.
The server started successfully but the MCP license cache is stale (more than 2 hours since the last validation). This is a warning only — the server is running. The grace window is 48 hours; if the cache exceeds that without a successful re-validation the next startup will fail with `LICENSE_NOT_FOUND`. To reset: restart the MCP server while Provar Automation IDE is open and connected to the internet.

**"SF_NOT_FOUND" error from quality hub / automation tools**

Expand Down
10 changes: 7 additions & 3 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ The Provar DX CLI ships with a built-in **Model Context Protocol (MCP) server**

- **Node.js 18–24** (LTS 22 recommended). Node 25+ is not supported — a transitive dependency (`buffer-equal-constant-time`) crashes on startup. Check with `node --version`.
- **Salesforce CLI** (`sf`) ≥ 2.x
- **Provar Automation IDE** installed with an activated license (see [License requirement](#license-requirement) below)
- **Provar Automation IDE** ≥ 3.x installed with an activated license (see [License requirement](#license-requirement) below)

## Quick start

Expand All @@ -90,7 +90,8 @@ claude mcp add provar -s user -- sf provar mcp start --allowed-paths /path/to/yo
**Claude Desktop** — edit your config file, then restart the app:

- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
- Windows (direct installer): `%APPDATA%\Claude\claude_desktop_config.json`
- Windows (Microsoft Store): `%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json` _(see note below about Store sandbox limitations)_

```json
{
Expand Down Expand Up @@ -211,7 +212,10 @@ claude mcp add provar -s user -- npx -y @salesforce/cli provar mcp start --allow
Edit the Claude Desktop MCP configuration file. Open it via **Claude menu → Settings → Developer → Edit Config**, or navigate to it directly:

- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
- **Windows (direct installer):** `%APPDATA%\Claude\claude_desktop_config.json`
- **Windows (Microsoft Store):** `%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json`

> **Windows Store version:** The Store edition of Claude Desktop runs in an app sandbox that can block child process spawning, causing the MCP server to disconnect immediately with "Server disconnected" errors. Use the **direct installer** from claude.ai/download instead. If you must use the Store version, run Claude Desktop as administrator.

```json
{
Expand Down
19 changes: 18 additions & 1 deletion src/commands/provar/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,24 @@ export default class SfProvarAuthStatus extends SfCommand<void> {
this.log(` Set at: ${stored.set_at}`);
if (stored.username) this.log(` Account: ${stored.username}`);
if (stored.tier) this.log(` Tier: ${stored.tier}`);
if (stored.expires_at) this.log(` Expires: ${stored.expires_at}`);
if (stored.expires_at) {
this.log(` Expires: ${stored.expires_at}`);
const expiresMs = new Date(stored.expires_at).getTime();
if (Number.isFinite(expiresMs)) {
const daysLeft = Math.ceil((expiresMs - Date.now()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 14 && daysLeft > 0) {
this.log('');
this.log(` Warning: API key expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}.`);
this.log(" Run 'sf provar auth rotate' now to avoid CI/CD disruption.");
} else if (daysLeft <= 0) {
this.log('');
this.log(' Warning: API key has expired. Run: sf provar auth login');
}
} else {
this.log('');
this.log(' Warning: API key expiry timestamp is invalid.');
}
Comment on lines +72 to +88
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

New expiry warning output (<=14 days and expired) isn’t covered by existing auth status tests. Adding NUT/unit coverage that seeds expires_at (and fixes Date.now() via a stub/fake timer) would prevent regressions in the warning thresholds and pluralization.

Copilot uses AI. Check for mistakes.
}
this.log('');
this.log(' Validation mode: Quality Hub API');
return;
Expand Down
8 changes: 4 additions & 4 deletions src/mcp/licensing/licenseValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ export interface LicenseValidationResult {
/**
* Validate the Provar license before starting the MCP server.
*
* Requires Provar Automation IDE to be installed with an activated licence.
* Requires Provar Automation IDE to be installed with an activated license.
* We trust the IDE's own licenseStatus field — if it says "Activated" we
* accept it. The IDE is responsible for setting licenseStatus to "Expired"
* or "Invalid" when a licence lapses; we do not re-check timing ourselves.
* or "Invalid" when a license lapses; we do not re-check timing ourselves.
*
* The result is cached so repeated starts within 2 hours skip the IDE file
* read entirely. The 48-hour grace window lets the MCP server keep running
Expand All @@ -48,7 +48,7 @@ export interface LicenseValidationResult {
*
* Validation flow:
* 1. MCP cache fresh (< 2h) → serve from cache, skip IDE read
* 2. Scan ~/Provar/.licenses/*.properties for an Activated licence
* 2. Scan ~/Provar/.licenses/*.properties for an Activated license
* 3. Found → write cache, accept (offlineGrace=false)
* 4. Not found but MCP cache within 48h grace → serve, offlineGrace=true
* 5. Not found, no usable cache → throw LICENSE_NOT_FOUND
Expand Down Expand Up @@ -128,7 +128,7 @@ function validateViaIdeDetection(): LicenseValidationResult {
};
writeCacheEntry(entry);

log('info', 'licenseValidator: IDE licence validated and cached', {
log('info', 'licenseValidator: IDE license validated and cached', {
name: ideState.name,
licenseType: ideState.licenseType,
});
Expand Down
10 changes: 5 additions & 5 deletions src/services/auth/loginFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import net from 'node:net';
import { spawn, type ChildProcess } from 'node:child_process';
import { URL } from 'node:url';

// All three ports must be pre-registered in the Cognito App Client.
// Cognito requires redirect_uri to exactly match a registered callback URL — no wildcards.
// All three ports must be pre-registered in both the Cognito App Client and the Salesforce External Client Application (SF ECA).
// Both providers require redirect_uri to exactly match a registered callback URL — no wildcards.
export const CALLBACK_PORTS = [1717, 7890, 8080];

// ── PKCE ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -165,7 +165,7 @@ export function listenForCallback(port: number, expectedState?: string): Promise
if (code) {
resolve(code);
} else {
reject(new Error(description ?? error ?? 'No authorisation code received from Cognito'));
reject(new Error(description ?? error ?? 'No authorisation code received from identity provider'));
}
});
server.on('connection', (socket: net.Socket) => {
Expand Down Expand Up @@ -210,7 +210,7 @@ export async function exchangeCodeForTokens(opts: {
});

if (status !== 200) {
throw new Error(`Cognito token exchange failed (${status}): ${responseBody}`);
throw new Error(`Token exchange failed (${status}): ${responseBody}`);
}

return JSON.parse(responseBody) as CognitoTokens;
Expand Down Expand Up @@ -247,7 +247,7 @@ function httpsPost(
}
);
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
req.destroy(new Error(`Cognito token exchange timed out after ${REQUEST_TIMEOUT_MS / 1000}s`));
req.destroy(new Error(`Token exchange timed out after ${REQUEST_TIMEOUT_MS / 1000}s`));
});
req.on('error', reject);
req.write(body);
Expand Down
90 changes: 90 additions & 0 deletions test/unit/commands/provar/auth/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,93 @@ describe('auth status logic', () => {
assert.equal(resolveApiKey(), 'pv_k_fromfile12345');
});
});

// ── Expiry warning calculation (unit) ────────────────────────────────────────
// These tests exercise the same arithmetic used in the status command's
// expires_at branch, keeping Date.now() deterministic via explicit timestamps.

function calcDaysLeft(expiresAt: string, nowMs: number): number | null {
const expiresMs = new Date(expiresAt).getTime();
if (!Number.isFinite(expiresMs)) return null;
return Math.ceil((expiresMs - nowMs) / (1000 * 60 * 60 * 24));
}

describe('auth status — expiry warning logic', () => {
const NOW = new Date('2026-04-22T12:00:00.000Z').getTime();

beforeEach(() => {
savedEnv = process.env.PROVAR_API_KEY;
delete process.env.PROVAR_API_KEY;
useTemp();
});

afterEach(() => {
if (savedEnv === undefined) {
delete process.env.PROVAR_API_KEY;
} else {
process.env.PROVAR_API_KEY = savedEnv;
}
restoreHome();
});

it('returns null for an unparseable timestamp', () => {
assert.equal(calcDaysLeft('not-a-date', NOW), null);
});

it('returns null for an empty string', () => {
assert.equal(calcDaysLeft('', NOW), null);
});

it('reports 1 day left (singular) when expiry is < 24 h away', () => {
const expiry = new Date(NOW + 1 * 60 * 60 * 1000).toISOString(); // +1 hour
const days = calcDaysLeft(expiry, NOW);
assert.equal(days, 1);
});

it('reports 7 days left inside the 14-day warning window', () => {
const expiry = new Date(NOW + 7 * 24 * 60 * 60 * 1000).toISOString();
const days = calcDaysLeft(expiry, NOW);
assert.equal(days, 7);
assert.ok(days !== null && days <= 14 && days > 0, 'should be in warning range');
});

it('reports 14 days left — boundary: still warns', () => {
const expiry = new Date(NOW + 14 * 24 * 60 * 60 * 1000).toISOString();
const days = calcDaysLeft(expiry, NOW);
assert.equal(days, 14);
assert.ok(days !== null && days <= 14 && days > 0);
});

it('reports 15 days left — outside warning window, no warning', () => {
const expiry = new Date(NOW + 15 * 24 * 60 * 60 * 1000).toISOString();
const days = calcDaysLeft(expiry, NOW);
assert.equal(days, 15);
assert.ok(days !== null && !(days <= 14 && days > 0));
});

it('reports 0 or negative days when already expired', () => {
const expiry = new Date(NOW - 1 * 60 * 60 * 1000).toISOString(); // 1 hour ago
const days = calcDaysLeft(expiry, NOW);
assert.ok(days !== null && days <= 0, 'should be expired');
});

it('pluralises "days" for values != 1', () => {
const days: number = 7;
const suffix = days === 1 ? '' : 's';
assert.equal(suffix, 's');
});

it('uses no suffix (singular) for exactly 1 day', () => {
const days: number = 1;
const suffix = days === 1 ? '' : 's';
assert.equal(suffix, '');
});

it('writeCredentials stores expires_at and readStoredCredentials returns it', () => {
const expires = new Date(NOW + 30 * 24 * 60 * 60 * 1000).toISOString();
writeCredentials('pv_k_expirytestkey1', 'pv_k_expiry', 'manual', { expires_at: expires });
const stored = readStoredCredentials();
assert.ok(stored, 'credentials should exist');
assert.equal(stored?.expires_at, expires);
});
});
Loading