diff --git a/CLAUDE.md b/CLAUDE.md index a8e8913..9d8f4de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/docs/mcp-pilot-guide.md b/docs/mcp-pilot-guide.md index 571bbd8..9fa7384 100644 --- a/docs/mcp-pilot-guide.md +++ b/docs/mcp-pilot-guide.md @@ -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/`. | | 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 | @@ -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. @@ -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 { @@ -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` @@ -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** diff --git a/docs/mcp.md b/docs/mcp.md index 6756366..da025c9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 @@ -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 { @@ -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 { diff --git a/src/commands/provar/auth/status.ts b/src/commands/provar/auth/status.ts index 4a2340a..30744bd 100644 --- a/src/commands/provar/auth/status.ts +++ b/src/commands/provar/auth/status.ts @@ -69,7 +69,24 @@ export default class SfProvarAuthStatus extends SfCommand { 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.'); + } + } this.log(''); this.log(' Validation mode: Quality Hub API'); return; diff --git a/src/mcp/licensing/licenseValidator.ts b/src/mcp/licensing/licenseValidator.ts index 2634e2a..664884a 100644 --- a/src/mcp/licensing/licenseValidator.ts +++ b/src/mcp/licensing/licenseValidator.ts @@ -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 @@ -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 @@ -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, }); diff --git a/src/services/auth/loginFlow.ts b/src/services/auth/loginFlow.ts index c3f8191..ebe1ea0 100644 --- a/src/services/auth/loginFlow.ts +++ b/src/services/auth/loginFlow.ts @@ -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 ───────────────────────────────────────────────────────────────────── @@ -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) => { @@ -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; @@ -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); diff --git a/test/unit/commands/provar/auth/status.test.ts b/test/unit/commands/provar/auth/status.test.ts index b379128..c6763c4 100644 --- a/test/unit/commands/provar/auth/status.test.ts +++ b/test/unit/commands/provar/auth/status.test.ts @@ -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); + }); +});