From c783676b64685c1ca5f3f67cfa7848abbe05de6b Mon Sep 17 00:00:00 2001 From: arielam Date: Mon, 17 Aug 2026 12:24:11 +0300 Subject: [PATCH 1/4] MLD-1347 - Cursor rewrite mcp json --- .cursor-plugin/marketplace.json | 2 +- .github/scripts/sync-modules-vendor.json | 3 +- VENDOR.md | 6 +- plugins/jfrog/.cursor-plugin/plugin.json | 2 +- plugins/jfrog/README.md | 19 + plugins/jfrog/hooks/hooks.json | 4 + .../jfrog/modules/core/agent-guard-check.mjs | 334 ++++++++ plugins/jfrog/modules/core/entry.mjs | 36 + .../jfrog/modules/core/rewrite-mcp-json.mjs | 732 ++++++++++++++++++ .../jfrog/scripts/cursor-align-mcp-json.mjs | 135 ++++ .../scripts/cursor-align-mcp-json.test.mjs | 119 +++ .../scripts/cursor-mcp-json-discover.mjs | 282 +++++++ .../scripts/cursor-mcp-json-discover.test.mjs | 205 +++++ 13 files changed, 1875 insertions(+), 4 deletions(-) create mode 100644 plugins/jfrog/modules/core/agent-guard-check.mjs create mode 100644 plugins/jfrog/modules/core/entry.mjs create mode 100644 plugins/jfrog/modules/core/rewrite-mcp-json.mjs create mode 100644 plugins/jfrog/scripts/cursor-align-mcp-json.mjs create mode 100644 plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs create mode 100644 plugins/jfrog/scripts/cursor-mcp-json-discover.mjs create mode 100644 plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 8e1d4f1..d479a13 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "JFrog Platform plugins for Cursor", - "version": "0.5.13", + "version": "0.5.14", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 4ca704b..4bb93a2 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -1,6 +1,7 @@ { "repo": "JFROG/jfrog-agent-hooks", - "pin": "jfrog-agent-hooks/v0.8.1", + "pin": "jfrog-agent-hooks/v0.8.1+mld-1386-rewrite-core@a146a13", + "dest_prefix": "plugins/jfrog", "paths": [ "modules" ] diff --git a/VENDOR.md b/VENDOR.md index db278c7..37e9c24 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -38,10 +38,14 @@ The `plugins/jfrog/modules/` bundle is vendored from **jfrog-agent-hooks** (GHE) The bundle contains harness runners (`core/`, `cursor-session-start.mjs`), the `package-resolution/` capability, and `assets/agents-default-conf.json`. Automated sync PRs (`chore/sync-modules-v*`) update this tree on each `jfrog-agent-hooks` release. +Harness-specific scripts (for example `plugins/jfrog/scripts/cursor-align-mcp-json.mjs` and `cursor-mcp-json-discover.mjs`) live **outside** `modules/` so sync does not wipe them. They call shared orchestration in synced `modules/core/` (for example `rewrite-mcp-json.mjs`). + +Until the next `jfrog-agent-hooks` release that includes MLD-1386, `modules/core/{rewrite-mcp-json,agent-guard-check,entry}.mjs` are vendored from that branch on top of the pinned `v0.8.1` tree (see the vendor pin suffix). + ## Refreshing modules ```bash JFROG_AGENT_HOOKS_PATH=/path/to/jfrog-agent-hooks node .github/scripts/sync-modules.mjs ``` -The script reads `paths` from `sync-modules-vendor.json` (today: `["modules"]`) and replaces the whole `plugins/jfrog/modules/` tree. +The script reads `paths` and `dest_prefix` from `sync-modules-vendor.json` (today: `paths: ["modules"]`, `dest_prefix: "plugins/jfrog"`) and replaces the whole `plugins/jfrog/modules/` tree. diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index 693fbe1..04250c5 100644 --- a/plugins/jfrog/.cursor-plugin/plugin.json +++ b/plugins/jfrog/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "displayName": "JFrog Platform", - "version": "0.5.13", + "version": "0.5.14", "description": "JFrog Platform integration with MCP, security skills, Agent Package Resolution, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.", "author": { "name": "JFrog", diff --git a/plugins/jfrog/README.md b/plugins/jfrog/README.md index ea300c0..5000e4b 100644 --- a/plugins/jfrog/README.md +++ b/plugins/jfrog/README.md @@ -20,6 +20,7 @@ CLI authentication options: run `jf login` for browser-based setup, or set the ` |---|---|---| | **MCP** | `mcp.json` | Remote JFrog MCP server (OAuth, no API keys) | | **Hook + Skill** | `hooks/hooks.json`, `skills/jfrog-setup-package-managers/` | Agent Package Resolution (Preview) — route agent package installs through Artifactory | +| **Hook** | `hooks/hooks.json`, `scripts/cursor-align-mcp-json.mjs` (+ `cursor-mcp-json-discover.mjs`) | On session start, rewrite discovered plugin `mcp.json` / `.mcp.json` files through Agent Guard (`--rewrite-mcp-json`) so stdio MCP entries launch via `@jfrog/agent-guard` | ### Skills @@ -46,6 +47,24 @@ Agent Package Resolution is in preview and opt-in. To get started: - **Users:** see the [User Guide](https://github.com/jfrog/cursor-plugin/blob/main/docs/package-resolution-user-guide.md). - **Admins:** see the [Admin Guide](https://github.com/jfrog/cursor-plugin/blob/main/docs/package-resolution-admin-guide.md). +## Plugin MCP rewrite (Agent Guard) + +On every Cursor agent `sessionStart`, the plugin discovers MCP configs under `$CURSOR_CONFIG_DIR/plugins/local/*` (default `~/.cursor`; prefers `mcp.json`, else `.mcp.json`), plus this plugin's own `mcp.json`, and runs `npx @jfrog/agent-guard --rewrite-mcp-json` against those paths. Stdio MCP entries are rewritten to launch through Agent Guard; remote `url` / `http` / `sse` / `ws` entries are left unchanged. Workspace and user-level `.cursor/mcp.json` files are **not** rewritten. If a file is rewritten, use **Developer: Reload Window** so Cursor reloads MCP config. + +The hook soft-fails (never breaks the session): missing project key, Agent Guard gate failure, or rewrite errors log and exit 0. + +| Env | Purpose | +|---|---| +| `JF_AGENT_REWRITE_MCP_JSON_DISABLE=1` | Kill switch — skip rewrite entirely | +| `JF_PROJECT` / `JFROG_PROJECT` | Project key (also inferred from existing `_JF_ARGS project=` in discovered mcp.json) | +| `JF_SERVER` / `JFROG_SERVER_ID` | Optional server ID for the gate / `--server` | +| `JFROG_AGENT_GUARD_VERSION` | Override pinned `@jfrog/agent-guard` version | +| `JFROG_AGENT_GUARD_REPO` | Private npm registry for `@jfrog/agent-guard` | +| `JFROG_AGENT_GUARD_BIN` | Local Agent Guard binary (skips npx) | +| `JF_ALIGN_MCP_JSON_ROOTS` | Override discovery roots (POSIX `:`/`,`; Windows `;`/`,`) | +| `CURSOR_CONFIG_DIR` | Cursor config root (default `~/.cursor`) | +| `JF_ALIGN_MCP_JSON_INCLUDE_CACHE=1` | Also scan `$CURSOR_CONFIG_DIR/plugins/cache` | + ## MCP Capabilities The JFrog MCP Server provides: diff --git a/plugins/jfrog/hooks/hooks.json b/plugins/jfrog/hooks/hooks.json index a5afe5d..a6657f5 100644 --- a/plugins/jfrog/hooks/hooks.json +++ b/plugins/jfrog/hooks/hooks.json @@ -5,6 +5,10 @@ { "command": "node \"./modules/cursor-session-start.mjs\" package-resolution", "timeout": 7 + }, + { + "command": "node \"./scripts/cursor-align-mcp-json.mjs\" session-start", + "timeout": 45 } ] } diff --git a/plugins/jfrog/modules/core/agent-guard-check.mjs b/plugins/jfrog/modules/core/agent-guard-check.mjs new file mode 100644 index 0000000..8789667 --- /dev/null +++ b/plugins/jfrog/modules/core/agent-guard-check.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node +// JFrog Agent Guard activation check +// +// Silent gate for session hooks. Determines whether Agent Guard is enabled +// for the current environment. +// +// Contract (key off `code`, not `reason` text): +// - code 0 -> Agent Guard ENABLED (caller may proceed) +// - code 2 -> reachable but the platform has the MCP registry DISABLED +// - code 1 -> DISABLED for any other reason: no credentials, timeout, +// network/DNS error (caller must silently abort) +// +// Set JF_AGENT_GUARD_DEBUG=true for verbose tracing on stderr. +// Library callers use runAgentGuardCheck(); CLI entry calls process.exit. + +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +import { isMainEntry } from "./entry.mjs"; + +export const SETTINGS_PATH = + "/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled"; +export const REQUEST_TIMEOUT_MS = 5000; + +export const EXIT_ENABLED = 0; +export const EXIT_DISABLED = 1; +export const EXIT_REGISTRY_DISABLED = 2; + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {string} newName + * @param {string} [oldName] + * @returns {string | undefined} + */ +function envLookup(env, newName, oldName) { + const raw = env[newName] ?? (oldName ? env[oldName] : undefined); + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed || undefined; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {(message: string) => void} [debug] + */ +function makeDebug(env, debug) { + if (typeof debug === "function") return debug; + const enabled = env.JF_AGENT_GUARD_DEBUG === "true"; + return (message) => { + if (enabled) console.error(`[jfrog-agent-guard] ${message}`); + }; +} + +/** + * Resolve credentials from Path A (environment variables) or Path B + * (JFrog CLI configuration). + * + * Intentionally distinct from `jf-identity.mjs`: + * - package-resolution identity is always `jf config` and may use Basic auth; + * - Agent Guard's settings probe needs a Bearer access token, and mirrors the + * AG CLI by preferring JFROG_URL/JF_URL + access token when set. + * - When `serverId` is set: that jf server first, then env, never the default + * CLI server. Without `serverId`: env first, then default `jf config export`. + * Do not reuse getPlatformIdentity() here without preserving that contract. + * + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} [opts] + * @returns {{ baseUrl: string, token: string, source: string } | null} + */ +export function resolveAgentGuardCredentials(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + const explicitServerId = opts.serverId?.trim() || undefined; + const execFn = opts.execFileSyncFn ?? execFileSync; + + if (explicitServerId) { + const fromCli = resolveFromCliConfig({ + serverId: explicitServerId, + execFileSyncFn: execFn, + debug, + }); + if (fromCli) return fromCli; + debug( + "Explicit server ID did not resolve via jf config; falling back to env credentials.", + ); + } + + const envUrl = envLookup(env, "JFROG_URL", "JF_URL"); + const envToken = envLookup(env, "JFROG_ACCESS_TOKEN", "JF_ACCESS_TOKEN"); + if (envUrl && envToken) { + debug("Using credentials from environment variables (Path A)."); + return { + baseUrl: envUrl, + token: envToken, + source: "environment variables", + }; + } + debug( + "Environment credentials incomplete; trying JFrog CLI config (Path B).", + ); + + if (explicitServerId) return null; + return resolveFromCliConfig({ + serverId: undefined, + execFileSyncFn: execFn, + debug, + }); +} + +/** + * @param {{ + * serverId?: string, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} opts + */ +function resolveFromCliConfig(opts) { + const debug = opts.debug ?? (() => {}); + const execFn = opts.execFileSyncFn ?? execFileSync; + const exportArgs = opts.serverId + ? ["config", "export", opts.serverId] + : ["config", "export"]; + let exported; + try { + exported = execFn("jf", exportArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }).trim(); + } catch (error) { + debug( + `'jf config export' failed (jf not on PATH or no server configured): ${error?.message}`, + ); + return null; + } + + let cfg; + try { + cfg = JSON.parse(Buffer.from(exported, "base64").toString("utf8")); + } catch (error) { + debug(`Could not decode the jf config export token: ${error?.message}`); + return null; + } + + const baseUrl = cfg?.url; + const token = cfg?.accessToken; + if (!baseUrl) { + debug("Exported JFrog CLI config has no platform URL."); + return null; + } + if (!token) { + debug( + "Exported JFrog CLI config has no access token (bearer auth needed).", + ); + return null; + } + + const id = cfg?.serverId ?? "default"; + return { + baseUrl, + token, + source: `JF CLI config (server '${id}')`, + }; +} + +/** + * @param {string} baseUrl + * @param {string} token + * @param {{ + * fetchFn?: typeof fetch, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + */ +export async function isGatewayPluginEnabled(baseUrl, token, opts = {}) { + const debug = opts.debug ?? (() => {}); + const fetchFn = opts.fetchFn ?? fetch; + const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS; + + const root = baseUrl.replace(/\/+$/, "").replace(/\/artifactory$/, ""); + const url = root + SETTINGS_PATH; + debug(`Fetching gateway plugin setting from ${url}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchFn(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + }); + if (!response.ok) { + debug(`Settings request returned HTTP ${response.status}.`); + return { + ok: false, + reason: `settings endpoint returned HTTP ${response.status}`, + }; + } + const data = await response.json(); + const unwrap = (v) => (v !== null && typeof v === "object" ? v?.value : v); + const container = data?.settings ?? data; + const named = + container?.mcpGatewayPluginEnabled ?? + container?.mcp_gateway_plugin_enabled; + const value = + typeof data === "boolean" + ? data + : named !== undefined + ? unwrap(named) + : unwrap(container); + debug(`Settings response indicates gateway plugin enabled=${value}.`); + if (value === true) return { ok: true }; + if (value === false) { + return { + ok: false, + registryOff: true, + reason: "mcp gateway plugin setting returned false", + }; + } + return { + ok: false, + reason: "settings endpoint returned an invalid gateway-plugin setting", + }; + } catch (error) { + const reason = + error?.name === "AbortError" + ? "timeout" + : (error?.message ?? "unknown error"); + debug(`Settings request failed: ${reason}`); + return { + ok: false, + reason: `settings endpoint unreachable (${reason})`, + }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Run the Agent Guard activation check without exiting the process. + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * fetchFn?: typeof fetch, + * execFileSyncFn?: typeof execFileSync, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + * @returns {Promise<{ code: number, reason: string }>} + */ +export async function runAgentGuardCheck(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + + try { + const forceDisabled = + envLookup(env, "_JF_AGENT_GUARD_FORCE_DISABLE") === "true"; + const forceEnabled = + envLookup(env, "JF_AGENT_GUARD_FORCE_ENABLE") === "true"; + if (forceDisabled) { + return { + code: EXIT_DISABLED, + reason: "Disabled: forced via _JF_AGENT_GUARD_FORCE_DISABLE", + }; + } + if (forceEnabled) { + return { + code: EXIT_ENABLED, + reason: "Enabled: forced via JF_AGENT_GUARD_FORCE_ENABLE", + }; + } + + const creds = resolveAgentGuardCredentials({ + serverId: opts.serverId, + env, + execFileSyncFn: opts.execFileSyncFn, + debug, + }); + if (!creds) { + return { + code: EXIT_DISABLED, + reason: + "Disabled: JFROG_URL/JF_URL + access token not set and no default JF CLI config found", + }; + } + + const result = await isGatewayPluginEnabled(creds.baseUrl, creds.token, { + fetchFn: opts.fetchFn, + timeoutMs: opts.timeoutMs, + debug, + }); + if (result.ok) { + return { + code: EXIT_ENABLED, + reason: `Enabled: via ${creds.source}`, + }; + } + if (result.registryOff) { + return { + code: EXIT_REGISTRY_DISABLED, + reason: `RegistryDisabled: ${result.reason}`, + }; + } + return { + code: EXIT_DISABLED, + reason: `Disabled: ${result.reason}`, + }; + } catch (error) { + debug(`Unexpected error: ${error?.stack ?? error?.message ?? error}`); + return { code: EXIT_DISABLED, reason: "Disabled: unexpected error" }; + } +} + +async function main() { + const result = await runAgentGuardCheck({ + serverId: process.argv[2], + }); + process.stdout.write(`${result.reason}\n`); + process.exit(result.code); +} + +if (isMainEntry(import.meta.url)) { + main().catch((error) => { + console.error(`[jfrog-agent-guard] Unexpected error: ${error?.message}`); + process.exit(EXIT_DISABLED); + }); +} diff --git a/plugins/jfrog/modules/core/entry.mjs b/plugins/jfrog/modules/core/entry.mjs new file mode 100644 index 0000000..476d681 --- /dev/null +++ b/plugins/jfrog/modules/core/entry.mjs @@ -0,0 +1,36 @@ +// Shared "was this module run as the CLI entrypoint?" check for the adapters. +// +// Claude invokes hooks as `${CLAUDE_PLUGIN_ROOT}/modules/.mjs`, and a +// plugin install directory is often a symlink. Node resolves the main entry to +// its real path before assigning import.meta.url, so comparing against a raw +// path.resolve(process.argv[1]) reports false under a symlinked layout and the +// hook silently becomes a no-op with exit code 0. Compare against both. + +import { realpathSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +/** + * @param {string} moduleUrl — the caller's import.meta.url + * @param {string} [entry] — defaults to process.argv[1] + */ +export function isMainEntry(moduleUrl, entry = process.argv[1]) { + if (!entry) return false; + + try { + const resolved = path.resolve(entry); + let real = resolved; + try { + real = realpathSync(resolved); + } catch { + // Entry may not exist on disk (e.g. a virtual entrypoint); use as-is. + } + return ( + moduleUrl === pathToFileURL(real).href || + moduleUrl === pathToFileURL(resolved).href + ); + } catch { + return false; + } +} diff --git a/plugins/jfrog/modules/core/rewrite-mcp-json.mjs b/plugins/jfrog/modules/core/rewrite-mcp-json.mjs new file mode 100644 index 0000000..9f22bca --- /dev/null +++ b/plugins/jfrog/modules/core/rewrite-mcp-json.mjs @@ -0,0 +1,732 @@ +// Shared Agent Guard `--rewrite-mcp-json` runner for harness adapters. +// +// Harness plugins own path discovery; this module owns discover → project/ +// server resolution → Step 0 gating → spawn/timeout, and soft-fail +// orchestration. Server id is resolved once for both the gate and AG --server. +// +// Usage (from a thin Cursor/Claude script next to synced modules/): +// import { runRewriteMcpJsonPipeline } from "./modules/core/rewrite-mcp-json.mjs"; +// await runRewriteMcpJsonPipeline({ +// discover: () => [...absoluteMcpJsonPaths], +// allowRoots: [...], +// }); +// +// Kill switch: JF_AGENT_REWRITE_MCP_JSON_DISABLE=1 → soft no-op (exit 0). +// Local binary: JFROG_AGENT_GUARD_BIN=/path/to/agent-guard (skips npx). +// Version pin: JFROG_AGENT_GUARD_VERSION (default DEFAULT_AGENT_GUARD_VERSION). + +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import process from "node:process"; + +import { EXIT_ENABLED, runAgentGuardCheck } from "./agent-guard-check.mjs"; +import { createLogger } from "./logger.mjs"; + +const log = createLogger("rewrite-mcp-json"); + +export const AGENT_GUARD_PACKAGE = "@jfrog/agent-guard"; +export const DISABLE_ENV = "JF_AGENT_REWRITE_MCP_JSON_DISABLE"; +export const AGENT_GUARD_BIN_ENV = "JFROG_AGENT_GUARD_BIN"; +/** + * Default npm registry for `npx @jfrog/agent-guard` during mcp.json rewrite. + * + * Exception to the usual "no runtime hard-dep on releases.jfrog.io" bundling + * rule: package-resolution hooks are fully vendored, but Agent Guard's MCP + * rewrite intentionally fetches `@jfrog/agent-guard` at session start via + * npx from the public `coding-agents-npm` channel (override with + * JFROG_AGENT_GUARD_REPO / JFROG_AGENT_GUARD_BIN). See .cursor/rules/bundling.mdc. + */ +export const DEFAULT_AGENT_GUARD_NPM_REGISTRY = + "https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/"; +/** + * Pinned so a session start cannot execute whatever the registry currently + * tags as latest. Bump deliberately; JFROG_AGENT_GUARD_VERSION overrides + * (including "latest"). + */ +export const DEFAULT_AGENT_GUARD_VERSION = "1.6.0"; +/** Shared budget for rewriting all discovered files in one hook invocation. */ +/** Must leave headroom under Cursor hooks.json timeout (45s) for gate + overhead. */ +export const DEFAULT_REWRITE_TIMEOUT_MS = 35_000; +/** SIGTERM → SIGKILL escalation window for a child that ignores the first signal. */ +export const DEFAULT_KILL_GRACE_MS = 2_000; + +export function isRewriteDisabled(env = process.env) { + return env[DISABLE_ENV] === "1"; +} + +/** + * True when JFROG_URL/JF_URL + access token are set — AG reads env directly, + * so callers must omit `--server`. + * @param {NodeJS.ProcessEnv} [env] + */ +export function hasJfrogUrlTokenEnv(env = process.env) { + const url = env.JFROG_URL?.trim() || env.JF_URL?.trim(); + const token = env.JFROG_ACCESS_TOKEN?.trim() || env.JF_ACCESS_TOKEN?.trim(); + return Boolean(url && token); +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Scan mcp.json files for an existing Agent Guard `_JF_ARGS` project= value. + * @param {string[]} mcpPaths + * @param {{ readFileSyncFn?: typeof readFileSync }} [opts] + * @returns {string} + */ +export function scanMcpJsonForProject(mcpPaths, opts = {}) { + const readFn = opts.readFileSyncFn ?? readFileSync; + for (const mcpPath of mcpPaths ?? []) { + const servers = readMcpServers(mcpPath, readFn); + if (!servers) continue; + for (const entry of Object.values(servers)) { + if (!isPlainObject(entry)) continue; + const envBlock = entry.env; + if (!isPlainObject(envBlock)) continue; + const jfArgs = envBlock._JF_ARGS; + if (typeof jfArgs !== "string") continue; + const match = /(?:^|&)project=([^&]*)/.exec(jfArgs); + const project = match?.[1]?.trim(); + if (project) return project; + } + } + return ""; +} + +/** + * Scan mcp.json files for an existing Agent Guard `--server ` in args. + * @param {string[]} mcpPaths + * @param {{ readFileSyncFn?: typeof readFileSync }} [opts] + * @returns {string} + */ +export function scanMcpJsonForServerId(mcpPaths, opts = {}) { + const readFn = opts.readFileSyncFn ?? readFileSync; + for (const mcpPath of mcpPaths ?? []) { + const servers = readMcpServers(mcpPath, readFn); + if (!servers) continue; + for (const entry of Object.values(servers)) { + if (!isPlainObject(entry)) continue; + const args = entry.args; + if (!Array.isArray(args)) continue; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--server" && typeof args[i + 1] === "string") { + const id = args[i + 1].trim(); + if (id) return id; + } + } + } + } + return ""; +} + +/** + * @param {string} mcpPath + * @param {typeof readFileSync} readFn + * @returns {Record | null} + */ +function readMcpServers(mcpPath, readFn) { + try { + const raw = readFn(mcpPath, "utf8"); + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed) || !isPlainObject(parsed.mcpServers)) { + return null; + } + return /** @type {Record} */ (parsed.mcpServers); + } catch { + return null; + } +} + +/** + * Resolve JFrog project key: env → existing AG `_JF_ARGS` → "". + * @param {NodeJS.ProcessEnv} [env] + * @param {{ + * mcpPaths?: string[], + * readFileSyncFn?: typeof readFileSync, + * }} [opts] + * @returns {string} + */ +export function resolveRewriteProject(env = process.env, opts = {}) { + const fromEnv = env.JF_PROJECT?.trim() || env.JFROG_PROJECT?.trim() || ""; + if (fromEnv) return fromEnv; + return scanMcpJsonForProject(opts.mcpPaths ?? [], { + readFileSyncFn: opts.readFileSyncFn, + }); +} + +/** + * Resolve server ID for gate + rewrite (same priority both places): + * omit when URL+token env → existing AG `--server` in mcp.json → + * `serverIdHint` → JF_SERVER / JFROG_SERVER_ID → "". + * @param {NodeJS.ProcessEnv} [env] + * @param {{ + * mcpPaths?: string[], + * readFileSyncFn?: typeof readFileSync, + * serverIdHint?: string, + * }} [opts] + * @returns {string} + */ +export function resolveRewriteServerId(env = process.env, opts = {}) { + if (hasJfrogUrlTokenEnv(env)) return ""; + const fromMcp = scanMcpJsonForServerId(opts.mcpPaths ?? [], { + readFileSyncFn: opts.readFileSyncFn, + }); + if (fromMcp) return fromMcp; + const hint = opts.serverIdHint?.trim(); + if (hint) return hint; + return env.JF_SERVER?.trim() || env.JFROG_SERVER_ID?.trim() || ""; +} + +/** + * @param {NodeJS.Platform} [platform] + */ +export function resolveNpxCommand(platform = process.platform) { + return platform === "win32" ? "npx.cmd" : "npx"; +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {NodeJS.Platform} [platform] + * @param {{ local?: boolean }} [opts] + */ +export function buildNpxSpawnOptions( + env, + platform = process.platform, + opts = {}, +) { + const isWin = platform === "win32"; + const useShell = isWin && !opts.local; + return { + stdio: /** @type {const} */ (["pipe", "pipe", "pipe"]), + env, + // Pin cmd.exe — shell: true would honor ComSpec (e.g. PowerShell). + shell: useShell ? "cmd.exe" : false, + detached: !isWin, + }; +} + +/** + * CRT-quote a single argv token for Node spawn under shell: "cmd.exe". + * @param {string} arg + * @returns {string} + * @throws {Error} when the arg contains CR/LF + */ +export function quoteWindowsArg(arg) { + const value = String(arg ?? ""); + if (/[\r\n]/.test(value)) { + throw new Error("Windows spawn arg must not contain CR/LF"); + } + return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`; +} + +/** + * @param {string[]} args + * @param {NodeJS.Platform} [platform] + * @returns {string[]} + */ +export function quoteSpawnArgs(args, platform = process.platform) { + return platform === "win32" ? args.map(quoteWindowsArg) : args; +} + +/** + * @param {{ pid?: number, kill?: (signal?: string) => boolean }} child + * @param {{ + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * spawnFn?: typeof spawn, + * graceMs?: number, + * isAlive?: () => boolean, + * waitForExit?: Promise, + * }} [opts] + * @returns {Promise} + */ +export async function killRewriteChildTree(child, opts = {}) { + const platform = opts.platform ?? process.platform; + const killFn = opts.killFn ?? process.kill; + const spawnFn = opts.spawnFn ?? spawn; + const graceMs = opts.graceMs ?? DEFAULT_KILL_GRACE_MS; + const isAlive = opts.isAlive ?? (() => true); + + const signalChild = (signal) => { + try { + child?.kill?.(signal); + } catch { + // Already gone. + } + }; + + const signalTree = (signal) => { + if (platform === "win32") { + if (child?.pid) { + try { + const killer = spawnFn( + "taskkill", + ["/pid", String(child.pid), "/T", "/F"], + { stdio: "ignore" }, + ); + killer?.on?.("error", () => {}); + return; + } catch { + // fall through + } + } + signalChild(signal); + return; + } + + if (child?.pid) { + try { + killFn(-child.pid, signal); + return; + } catch { + // Fall through to child.kill when the group is already gone. + } + } + signalChild(signal); + }; + + signalTree("SIGTERM"); + + if (graceMs <= 0 || !isAlive()) return; + await waitForExitOrTimeout(opts.waitForExit, graceMs); + if (!isAlive()) return; + + log.warn("rewrite child ignored SIGTERM; escalating to SIGKILL", { + graceMs, + }); + signalTree("SIGKILL"); +} + +/** + * @param {Promise | undefined} exited + * @param {number} graceMs + */ +function waitForExitOrTimeout(exited, graceMs) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, graceMs); + exited?.then( + () => { + clearTimeout(timer); + resolve(undefined); + }, + () => { + clearTimeout(timer); + resolve(undefined); + }, + ); + }); +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function resolveAgentGuardNpmRegistry(env = process.env) { + const fromEnv = env.JFROG_AGENT_GUARD_REPO?.trim(); + return fromEnv || DEFAULT_AGENT_GUARD_NPM_REGISTRY; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function resolveAgentGuardSpec(env = process.env) { + const version = + env.JFROG_AGENT_GUARD_VERSION?.trim() || DEFAULT_AGENT_GUARD_VERSION; + return `${AGENT_GUARD_PACKAGE}@${version}`; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {string | undefined} + */ +export function resolveAgentGuardBin(env = process.env) { + return env[AGENT_GUARD_BIN_ENV]?.trim() || undefined; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * }} opts + * @returns {string[]} + * @throws {Error} when project is missing or paths are empty + */ +export function buildAgentGuardRewriteArgs(opts) { + const env = opts.env ?? process.env; + const paths = opts.paths ?? []; + if (paths.length === 0) { + throw new Error("rewrite-mcp-json requires at least one mcp.json path"); + } + const project = + opts.project?.trim() || resolveRewriteProject(env, { mcpPaths: paths }); + if (!project) { + throw new Error("rewrite-mcp-json requires --project (or JF_PROJECT)"); + } + + const args = ["--rewrite-mcp-json", ...paths, "--project", project]; + + const server = + opts.serverId !== undefined + ? opts.serverId.trim() + : resolveRewriteServerId(env, { mcpPaths: paths }); + if (server) { + args.push("--server", server); + } + + const agentGuardRegistry = env.JFROG_AGENT_GUARD_REPO?.trim(); + if (agentGuardRegistry) { + args.push("--registry", agentGuardRegistry); + } + + for (const root of opts.allowRoots ?? []) { + if (root) args.push("--allow-root", root); + } + + args.push("--format", "json"); + return args; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * }} opts + * @returns {string[]} + */ +export function buildNpxArgs(opts) { + const env = opts.env ?? process.env; + return [ + "--yes", + "--registry", + resolveAgentGuardNpmRegistry(env), + resolveAgentGuardSpec(env), + ...buildAgentGuardRewriteArgs(opts), + ]; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * platform?: NodeJS.Platform, + * }} opts + * @returns {{ command: string, args: string[], local: boolean }} + */ +export function resolveAgentGuardCommand(opts) { + const env = opts.env ?? process.env; + const platform = opts.platform ?? process.platform; + const bin = resolveAgentGuardBin(env); + if (bin) { + return { + command: bin, + args: buildAgentGuardRewriteArgs(opts), + local: true, + }; + } + return { + command: resolveNpxCommand(platform), + args: buildNpxArgs(opts), + local: false, + }; +} + +/** + * Spawn Agent Guard `--rewrite-mcp-json`. AG writes files; stdout is JSON + * summary when `--format json` is passed. + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * spawnFn?: typeof spawn, + * env?: NodeJS.ProcessEnv, + * timeoutMs?: number, + * graceMs?: number, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * }} opts + * @returns {Promise<{ code: number, stdout: string, stderr: string }>} + */ +export function runAgentGuardRewriteMcpJson(opts) { + const spawnFn = opts.spawnFn ?? spawn; + const env = opts.env ?? process.env; + const timeoutMs = + opts.timeoutMs === undefined ? DEFAULT_REWRITE_TIMEOUT_MS : opts.timeoutMs; + const platform = opts.platform ?? process.platform; + + let command; + let args; + let spawnOpts; + try { + const resolved = resolveAgentGuardCommand({ + paths: opts.paths, + project: opts.project, + serverId: opts.serverId, + allowRoots: opts.allowRoots, + env, + platform, + }); + command = resolved.command; + spawnOpts = buildNpxSpawnOptions(env, platform, { local: resolved.local }); + args = spawnOpts.shell + ? quoteSpawnArgs(resolved.args, platform) + : resolved.args; + } catch (err) { + return Promise.resolve({ + code: 1, + stdout: "", + stderr: err?.message ?? String(err), + }); + } + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let exited = false; + let timedOut = false; + let markExited = () => {}; + const exitedPromise = new Promise((r) => { + markExited = r; + }); + /** @type {ReturnType | undefined} */ + let timer; + const finish = (result) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(result); + }; + + let child; + try { + child = spawnFn(command, args, spawnOpts); + } catch (err) { + finish({ + code: 1, + stdout: "", + stderr: err?.message ?? String(err), + }); + return; + } + + child.stdout?.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (err) => { + exited = true; + markExited(); + finish({ + code: 1, + stdout, + stderr: err?.message ?? String(err), + }); + }); + child.on("close", (code) => { + exited = true; + markExited(); + if (timedOut) return; + finish({ code: code ?? 1, stdout, stderr }); + }); + + child.stdin?.on?.("error", () => {}); + try { + child.stdin?.end(); + } catch { + // Child may already have exited. + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + timedOut = true; + const finishTimedOut = () => { + finish({ + code: 1, + stdout, + stderr: `${stderr ? `${stderr.trim()}\n` : ""}rewrite timed out after ${timeoutMs}ms`, + }); + }; + killRewriteChildTree(child, { + platform, + killFn: opts.killFn, + spawnFn, + graceMs: opts.graceMs, + isAlive: () => !exited, + waitForExit: exitedPromise, + }).then(finishTimedOut, finishTimedOut); + }, timeoutMs); + } + }); +} + +/** + * @param {string} raw + * @returns {{ scanned?: number, rewritten?: number, files?: string[], errors?: string[], dryRun?: boolean } | null} + */ +export function parseRewriteMcpJsonResult(raw) { + if (typeof raw !== "string" || !raw.trim()) return null; + try { + const parsed = JSON.parse(raw.trim()); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Strip userinfo from URLs before logging. + * @param {string} text + * @returns {string} + */ +export function redactUrlCredentials(text) { + return String(text ?? "").replace( + /([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, + "$1***@", + ); +} + +/** + * Orchestration: kill switch → discover → project/server → Step 0 gate → + * rewrite. Server id is resolved once and reused for both the gate and AG + * `--server`. Always returns 0 (soft-fail). Harness adapters supply discovery + * + allow-roots. + * + * @param {{ + * discover: () => string[] | Promise, + * allowRoots?: string[] | ((paths: string[]) => string[]), + * env?: NodeJS.ProcessEnv, + * spawnFn?: typeof spawn, + * timeoutMs?: number, + * graceMs?: number, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * runAgentGuardCheckFn?: typeof runAgentGuardCheck, + * readFileSyncFn?: typeof readFileSync, + * serverIdHint?: string, + * }} opts + * @returns {Promise} always 0 + */ +export async function runRewriteMcpJsonPipeline(opts) { + const env = opts.env ?? process.env; + const checkFn = opts.runAgentGuardCheckFn ?? runAgentGuardCheck; + + if (isRewriteDisabled(env)) { + log.info("rewrite disabled via env", { env: DISABLE_ENV }); + return 0; + } + + let paths; + try { + paths = await opts.discover(); + } catch (err) { + log.error("discover failed; soft no-op", { + error: err?.message ?? String(err), + }); + return 0; + } + + if (!Array.isArray(paths) || paths.length === 0) { + log.info("no mcp.json files found; skip rewrite"); + return 0; + } + + const project = resolveRewriteProject(env, { + mcpPaths: paths, + readFileSyncFn: opts.readFileSyncFn, + }); + if (!project) { + log.info("rewrite skipped; missing JF_PROJECT", {}); + return 0; + } + + const serverId = resolveRewriteServerId(env, { + mcpPaths: paths, + readFileSyncFn: opts.readFileSyncFn, + serverIdHint: opts.serverIdHint, + }); + + const gate = await checkFn({ + serverId: serverId || undefined, + env, + }); + if (gate.code !== EXIT_ENABLED) { + log.info("agent-guard check blocked rewrite; soft no-op", { + code: gate.code, + reason: gate.reason, + }); + return 0; + } + + const allowRoots = + typeof opts.allowRoots === "function" + ? opts.allowRoots(paths) + : (opts.allowRoots ?? []); + + log.info("rewrite-mcp-json targets", { + count: paths.length, + allowRoots: allowRoots.length, + }); + + const budgetMs = + opts.timeoutMs === undefined ? DEFAULT_REWRITE_TIMEOUT_MS : opts.timeoutMs; + const startedAtMs = Date.now(); + const result = await runAgentGuardRewriteMcpJson({ + paths, + project, + serverId, + allowRoots, + env, + spawnFn: opts.spawnFn, + timeoutMs: budgetMs, + graceMs: opts.graceMs, + platform: opts.platform, + killFn: opts.killFn, + }); + const durMs = Date.now() - startedAtMs; + + if (result.code !== 0) { + log.error("rewrite-mcp-json failed", { + code: result.code, + stderr: redactUrlCredentials((result.stderr || "").trim()).slice(0, 500), + durMs, + }); + return 0; + } + + const summary = parseRewriteMcpJsonResult(result.stdout); + if (summary) { + log.info("rewrite-mcp-json ok", { + scanned: summary.scanned, + rewritten: summary.rewritten, + errors: summary.errors?.length ?? 0, + durMs, + }); + } else { + log.info("rewrite-mcp-json ok; no JSON summary", { durMs }); + } + + return 0; +} diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs new file mode 100644 index 0000000..73ae039 --- /dev/null +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Cursor sessionStart adapter: invoke the shared Agent Guard rewrite pipeline +// with Cursor plugin mcp.json discovery. +// +// Usage: +// node cursor-align-mcp-json.mjs session-start +// node cursor-align-mcp-json.mjs file-changed # same work; reserved for a +// # future Cursor FileChanged hook +// +// Path discovery: cursor-mcp-json-discover.mjs +// Orchestration / Step 0 / spawn: modules/core/rewrite-mcp-json.mjs +// +// Kill switch: JF_AGENT_REWRITE_MCP_JSON_DISABLE=1 → no-op (exit 0). +// Never exits non-zero — a failed rewrite must not break the Cursor session. + +import { existsSync, readdirSync, statSync } from "node:fs"; +import process from "node:process"; + +import { isMainEntry } from "../modules/core/entry.mjs"; +import { detectHarness, parseSessionId, readStdin } from "../modules/core/io.mjs"; +import { createLogger, setLogContext } from "../modules/core/logger.mjs"; +import { runRewriteMcpJsonPipeline } from "../modules/core/rewrite-mcp-json.mjs"; +import { + discoverPluginMcpJsonPaths, + resolveRewriteAllowRoots, +} from "./cursor-mcp-json-discover.mjs"; + +const HARNESS_ID = "cursor"; +const log = createLogger("align-mcp-json"); + +/** Recommended Cursor hooks.json timeout (seconds) for the align entry. */ +export const RECOMMENDED_HOOK_TIMEOUT_SEC = 45; + +/** @type {ReadonlySet} */ +export const MODES = Object.freeze(new Set(["session-start", "file-changed"])); + +/** + * @param {string | undefined} modeArg + * @returns {boolean} + */ +export function isKnownMode(modeArg) { + return typeof modeArg === "string" && MODES.has(modeArg); +} + +/** + * Thin harness entry: detect Cursor, discover paths, run shared pipeline. + * @param {string | undefined} modeArg + * @param {{ + * env?: NodeJS.ProcessEnv, + * home?: string, + * readStdinFn?: typeof readStdin, + * runRewriteMcpJsonPipelineFn?: typeof runRewriteMcpJsonPipeline, + * readdirSyncFn?: typeof readdirSync, + * existsSyncFn?: typeof existsSync, + * statSyncFn?: typeof statSync, + * mcpJsonPath?: string, + * timeoutMs?: number, + * graceMs?: number, + * spawnFn?: unknown, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * runAgentGuardCheckFn?: unknown, + * readFileSyncFn?: unknown, + * }} [deps] + * @returns {Promise} always 0 + */ +export async function runCursorAlignMcpJson(modeArg, deps = {}) { + const env = deps.env ?? process.env; + const readStdinFn = deps.readStdinFn ?? readStdin; + const pipelineFn = + deps.runRewriteMcpJsonPipelineFn ?? runRewriteMcpJsonPipeline; + + const stdinRaw = await readStdinFn(); + setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); + + const harness = detectHarness(stdinRaw); + if (harness && harness !== HARNESS_ID) { + log.info("invoked by another harness; no-op", { harness }); + return 0; + } + + if (!isKnownMode(modeArg)) { + log.warn("unknown mode; no-op", { mode: modeArg ?? "" }); + return 0; + } + + const existsFn = deps.existsSyncFn ?? existsSync; + + return pipelineFn({ + env, + discover: () => { + if (deps.mcpJsonPath) { + return existsFn(deps.mcpJsonPath) ? [deps.mcpJsonPath] : []; + } + return discoverPluginMcpJsonPaths({ + home: deps.home, + env, + moduleUrl: import.meta.url, + readdirSyncFn: deps.readdirSyncFn, + existsSyncFn: existsFn, + statSyncFn: deps.statSyncFn, + }); + }, + allowRoots: (paths) => + resolveRewriteAllowRoots({ + home: deps.home, + env, + moduleUrl: import.meta.url, + targets: paths, + }), + spawnFn: deps.spawnFn, + timeoutMs: deps.timeoutMs, + graceMs: deps.graceMs, + platform: deps.platform, + killFn: deps.killFn, + runAgentGuardCheckFn: deps.runAgentGuardCheckFn, + readFileSyncFn: deps.readFileSyncFn, + }); +} + +async function main() { + await runCursorAlignMcpJson(process.argv[2]); + process.exit(0); +} + +if (isMainEntry(import.meta.url)) { + main().catch((err) => { + log.error("unexpected failure", { error: err?.message ?? String(err) }); + process.exit(0); + }); +} diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs new file mode 100644 index 0000000..0623c44 --- /dev/null +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs @@ -0,0 +1,119 @@ +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + CURSOR_CONFIG_DIR_ENV, + ROOTS_ENV, +} from "./cursor-mcp-json-discover.mjs"; +import { + isKnownMode, + runCursorAlignMcpJson, +} from "./cursor-align-mcp-json.mjs"; + +/** + * @param {string[]} segments + * @returns {string} + */ +function tempDir(...segments) { + const root = mkdtempSync(path.join(tmpdir(), "cursor-align-")); + const full = path.join(root, ...segments); + mkdirSync(full, { recursive: true }); + return full; +} + +test("isKnownMode accepts session-start and file-changed", () => { + assert.equal(isKnownMode("session-start"), true); + assert.equal(isKnownMode("file-changed"), true); + assert.equal(isKnownMode("other"), false); + assert.equal(isKnownMode(undefined), false); +}); + +test("runCursorAlignMcpJson no-ops on unknown mode", async () => { + let called = false; + const code = await runCursorAlignMcpJson("nope", { + readStdinFn: async () => "", + runRewriteMcpJsonPipelineFn: async () => { + called = true; + return 0; + }, + }); + assert.equal(code, 0); + assert.equal(called, false); +}); + +test("runCursorAlignMcpJson no-ops when harness is not cursor", async () => { + let called = false; + const code = await runCursorAlignMcpJson("session-start", { + readStdinFn: async () => + JSON.stringify({ + session_id: "s1", + hook_event_name: "SessionStart", + source: "startup", + }), + runRewriteMcpJsonPipelineFn: async () => { + called = true; + return 0; + }, + }); + assert.equal(code, 0); + assert.equal(called, false); +}); + +test("runCursorAlignMcpJson passes discovered paths to shared pipeline", async () => { + const cursorDir = tempDir("cursor-pipeline"); + const pluginA = path.join(cursorDir, "plugins", "local", "a"); + mkdirSync(pluginA, { recursive: true }); + const mcpPath = path.join(pluginA, "mcp.json"); + writeFileSync(mcpPath, "{}"); + + /** @type {{ paths?: string[], allowRoots?: string[] }} */ + const captured = {}; + const code = await runCursorAlignMcpJson("session-start", { + env: { + [CURSOR_CONFIG_DIR_ENV]: cursorDir, + // Avoid scanning the real hosting plugin tree in this unit test. + [ROOTS_ENV]: pluginA, + }, + readStdinFn: async () => + JSON.stringify({ session_id: "s1", cursor_version: "1.0.0" }), + runRewriteMcpJsonPipelineFn: async (opts) => { + const paths = await opts.discover(); + captured.paths = paths; + captured.allowRoots = + typeof opts.allowRoots === "function" + ? opts.allowRoots(paths) + : opts.allowRoots; + return 0; + }, + }); + + assert.equal(code, 0); + assert.deepEqual(captured.paths, [mcpPath]); + assert.ok(captured.allowRoots?.includes(cursorDir)); + assert.ok(captured.allowRoots?.includes(pluginA)); +}); + +test("runCursorAlignMcpJson respects mcpJsonPath override", async () => { + const file = path.join(tempDir("single"), "mcp.json"); + writeFileSync(file, "{}"); + + /** @type {string[] | undefined} */ + let paths; + const code = await runCursorAlignMcpJson("session-start", { + mcpJsonPath: file, + readStdinFn: async () => "", + runRewriteMcpJsonPipelineFn: async (opts) => { + paths = await opts.discover(); + return 0; + }, + }); + assert.equal(code, 0); + assert.deepEqual(paths, [file]); +}); diff --git a/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs new file mode 100644 index 0000000..f6ad369 --- /dev/null +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs @@ -0,0 +1,282 @@ +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Cursor-specific discovery of plugin mcp.json / .mcp.json paths and +// Agent Guard --allow-root directories. Harness entry lives in +// cursor-align-mcp-json.mjs; shared rewrite orchestration lives in +// modules/core/rewrite-mcp-json.mjs. +// +// Override roots: JF_ALIGN_MCP_JSON_ROOTS=/path/a:/path/b +// (POSIX: colon/comma; Windows: semicolon/comma — avoids splitting C:\…) +// Cursor config root: CURSOR_CONFIG_DIR (Cursor CLI; default ~/.cursor) +// Include marketplace cache: JF_ALIGN_MCP_JSON_INCLUDE_CACHE=1 + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +/** + * OS-delimiter- or comma-separated absolute plugin roots; skips default + * discovery. POSIX uses `:` / `,`; Windows uses `;` / `,` (not `:` — that + * would split drive letters like `C:\…`). + */ +export const ROOTS_ENV = "JF_ALIGN_MCP_JSON_ROOTS"; +/** + * Cursor CLI config root override. Same as Cursor: + * `${CURSOR_CONFIG_DIR:-$HOME/.cursor}` — plugins live under + * `$CURSOR_CONFIG_DIR/plugins/local` (and optionally `…/cache`). + */ +export const CURSOR_CONFIG_DIR_ENV = "CURSOR_CONFIG_DIR"; +/** When "1", also scan $CURSOR_CONFIG_DIR/plugins/cache (marketplace installs). */ +export const INCLUDE_CACHE_ENV = "JF_ALIGN_MCP_JSON_INCLUDE_CACHE"; + +/** + * Plugin root is the parent of `scripts/` (where this file lives). + * @param {string} [moduleUrl] — import.meta.url of a scripts/*.mjs module + */ +export function resolvePluginRoot(moduleUrl = import.meta.url) { + const scriptsDir = path.dirname(fileURLToPath(moduleUrl)); + return path.dirname(scriptsDir); +} + +/** + * @param {string} [moduleUrl] + */ +export function resolvePluginMcpJsonPath(moduleUrl = import.meta.url) { + return path.join(resolvePluginRoot(moduleUrl), "mcp.json"); +} + +/** + * @param {string} raw + * @param {NodeJS.Platform} [platform] + * @returns {string[]} + */ +export function parseRootsEnv(raw, platform = process.platform) { + if (typeof raw !== "string" || !raw.trim()) return []; + // Use the *requested* platform delimiter — not path.delimiter from the host + // OS — so unit tests and cross-compiled callers get correct splitting. + // Windows: `;` / `,` (never bare `:` — that splits drive letters like `C:\…`). + // POSIX: `:` / `,`. + const sep = platform === "win32" ? /[;,]/ : /[:,]/; + return raw + .split(sep) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Cursor config root: `${CURSOR_CONFIG_DIR:-$HOME/.cursor}`. + * @param {{ + * home?: string, + * env?: NodeJS.ProcessEnv, + * }} [opts] + * @returns {string} + */ +export function resolveCursorConfigDir(opts = {}) { + const env = opts.env ?? process.env; + const home = opts.home ?? homedir(); + const fromEnv = + typeof env[CURSOR_CONFIG_DIR_ENV] === "string" + ? env[CURSOR_CONFIG_DIR_ENV].trim() + : ""; + return fromEnv || path.join(home, ".cursor"); +} + +/** + * @param {{ + * home?: string, + * env?: NodeJS.ProcessEnv, + * readdirSyncFn?: typeof readdirSync, + * existsSyncFn?: typeof existsSync, + * statSyncFn?: typeof statSync, + * }} [opts] + * @returns {string[]} + */ +export function discoverCursorPluginRoots(opts = {}) { + const env = opts.env ?? process.env; + const home = opts.home ?? homedir(); + const readdirFn = opts.readdirSyncFn ?? readdirSync; + const existsFn = opts.existsSyncFn ?? existsSync; + const statFn = opts.statSyncFn ?? statSync; + + const fromEnv = parseRootsEnv(env[ROOTS_ENV] ?? ""); + if (fromEnv.length > 0) { + return fromEnv.filter((root) => { + try { + return existsFn(root) && statFn(root).isDirectory(); + } catch { + return false; + } + }); + } + + const cursorDir = resolveCursorConfigDir({ home, env }); + /** @type {string[]} */ + const roots = []; + const localDir = path.join(cursorDir, "plugins", "local"); + roots.push( + ...listImmediateSubdirs(localDir, { readdirFn, existsFn, statFn }), + ); + + if (env[INCLUDE_CACHE_ENV] === "1") { + const cacheRoot = path.join(cursorDir, "plugins", "cache"); + for (const marketplace of listImmediateSubdirs(cacheRoot, { + readdirFn, + existsFn, + statFn, + })) { + for (const pluginName of listImmediateSubdirs(marketplace, { + readdirFn, + existsFn, + statFn, + })) { + roots.push( + ...listImmediateSubdirs(pluginName, { readdirFn, existsFn, statFn }), + ); + } + } + } + + return roots; +} + +/** + * @param {string} dir + * @param {{ + * readdirFn: typeof readdirSync, + * existsFn: typeof existsSync, + * statFn: typeof statSync, + * }} fs + * @returns {string[]} + */ +function listImmediateSubdirs(dir, fs) { + if (!fs.existsFn(dir)) return []; + let names; + try { + names = fs.readdirFn(dir); + } catch { + return []; + } + /** @type {string[]} */ + const out = []; + for (const name of names) { + const full = path.join(dir, name); + try { + if (fs.statFn(full).isDirectory()) out.push(full); + } catch { + // skip + } + } + return out; +} + +/** + * Resolve the MCP config path for a plugin root. + * Order: mcp.json, then .mcp.json. + * @param {string} pluginRoot + * @param {{ + * existsSyncFn?: typeof existsSync, + * }} [deps] + * @returns {string | undefined} + */ +export function resolveMcpJsonForPluginRoot(pluginRoot, deps = {}) { + const existsFn = deps.existsSyncFn ?? existsSync; + + const mcpJson = path.join(pluginRoot, "mcp.json"); + if (existsFn(mcpJson)) return mcpJson; + + const dotMcpJson = path.join(pluginRoot, ".mcp.json"); + if (existsFn(dotMcpJson)) return dotMcpJson; + + return undefined; +} + +/** + * @param {{ + * home?: string, + * env?: NodeJS.ProcessEnv, + * moduleUrl?: string, + * includeSelf?: boolean, + * readdirSyncFn?: typeof readdirSync, + * existsSyncFn?: typeof existsSync, + * statSyncFn?: typeof statSync, + * }} [opts] + * @returns {string[]} + */ +export function discoverPluginMcpJsonPaths(opts = {}) { + const env = opts.env ?? process.env; + const existsFn = opts.existsSyncFn ?? existsSync; + const roots = discoverCursorPluginRoots({ + home: opts.home, + env, + readdirSyncFn: opts.readdirSyncFn, + existsSyncFn: existsFn, + statSyncFn: opts.statSyncFn, + }); + + /** @type {string[]} */ + const paths = []; + const seen = new Set(); + + const add = (p) => { + if (!p || seen.has(p)) return; + seen.add(p); + paths.push(p); + }; + + for (const root of roots) { + add( + resolveMcpJsonForPluginRoot(root, { + existsSyncFn: existsFn, + }), + ); + } + + const rootsOverridden = parseRootsEnv(env[ROOTS_ENV] ?? "").length > 0; + if (opts.includeSelf !== false && !rootsOverridden) { + add( + resolveMcpJsonForPluginRoot(resolvePluginRoot(opts.moduleUrl), { + existsSyncFn: existsFn, + }), + ); + } + + return paths; +} + +/** + * Allow-roots for Agent Guard: Cursor config dir, override roots, plugin root, + * and parent dirs of discovered targets. + * @param {{ + * home?: string, + * env?: NodeJS.ProcessEnv, + * moduleUrl?: string, + * targets?: string[], + * }} [opts] + * @returns {string[]} + */ +export function resolveRewriteAllowRoots(opts = {}) { + const env = opts.env ?? process.env; + const home = opts.home ?? homedir(); + /** @type {string[]} */ + const roots = []; + const seen = new Set(); + const add = (p) => { + if (!p || seen.has(p)) return; + seen.add(p); + roots.push(p); + }; + + add(resolveCursorConfigDir({ home, env })); + for (const root of parseRootsEnv(env[ROOTS_ENV] ?? "")) { + add(root); + } + add(resolvePluginRoot(opts.moduleUrl)); + for (const target of opts.targets ?? []) { + add(path.dirname(target)); + } + return roots; +} diff --git a/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs new file mode 100644 index 0000000..412fa1a --- /dev/null +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs @@ -0,0 +1,205 @@ +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; + +import { + CURSOR_CONFIG_DIR_ENV, + INCLUDE_CACHE_ENV, + ROOTS_ENV, + discoverCursorPluginRoots, + discoverPluginMcpJsonPaths, + parseRootsEnv, + resolveCursorConfigDir, + resolveMcpJsonForPluginRoot, + resolvePluginRoot, + resolveRewriteAllowRoots, +} from "./cursor-mcp-json-discover.mjs"; + +/** + * @param {string[]} segments + * @returns {string} + */ +function tempDir(...segments) { + const root = mkdtempSync(path.join(tmpdir(), "cursor-discover-")); + const full = path.join(root, ...segments); + mkdirSync(full, { recursive: true }); + return full; +} + +test("parseRootsEnv splits POSIX and Windows delimiters", () => { + assert.deepEqual(parseRootsEnv("/a:/b,/c", "linux"), ["/a", "/b", "/c"]); + assert.deepEqual(parseRootsEnv("C:\\a;D:\\b,E:\\c", "win32"), [ + "C:\\a", + "D:\\b", + "E:\\c", + ]); + // Bare colon must not split a Windows drive letter. + assert.deepEqual(parseRootsEnv("C:\\plugins\\local", "win32"), [ + "C:\\plugins\\local", + ]); + assert.deepEqual(parseRootsEnv(" ", "linux"), []); +}); + +test("resolveCursorConfigDir prefers CURSOR_CONFIG_DIR", () => { + const home = "/home/user"; + assert.equal( + resolveCursorConfigDir({ home, env: {} }), + path.join(home, ".cursor"), + ); + assert.equal( + resolveCursorConfigDir({ + home, + env: { [CURSOR_CONFIG_DIR_ENV]: "/custom/cursor" }, + }), + "/custom/cursor", + ); +}); + +test("resolvePluginRoot is parent of scripts/", () => { + const scriptsDir = path.join("/tmp/plugin", "scripts"); + const moduleUrl = pathToFileURL( + path.join(scriptsDir, "cursor-mcp-json-discover.mjs"), + ).href; + assert.equal(resolvePluginRoot(moduleUrl), path.join("/tmp/plugin")); +}); + +test("resolveMcpJsonForPluginRoot prefers mcp.json over .mcp.json", () => { + const root = tempDir("plugin-a"); + writeFileSync(path.join(root, ".mcp.json"), "{}"); + assert.equal( + resolveMcpJsonForPluginRoot(root), + path.join(root, ".mcp.json"), + ); + writeFileSync(path.join(root, "mcp.json"), "{}"); + assert.equal(resolveMcpJsonForPluginRoot(root), path.join(root, "mcp.json")); +}); + +test("discoverCursorPluginRoots scans plugins/local", () => { + const cursorDir = tempDir("cursor-home"); + const localA = path.join(cursorDir, "plugins", "local", "alpha"); + const localB = path.join(cursorDir, "plugins", "local", "beta"); + mkdirSync(localA, { recursive: true }); + mkdirSync(localB, { recursive: true }); + + const roots = discoverCursorPluginRoots({ + home: "/unused", + env: { [CURSOR_CONFIG_DIR_ENV]: cursorDir }, + }); + assert.deepEqual(roots.sort(), [localA, localB].sort()); +}); + +test("discoverCursorPluginRoots honors JF_ALIGN_MCP_JSON_ROOTS override", () => { + const override = tempDir("override-root"); + const roots = discoverCursorPluginRoots({ + home: "/unused", + env: { [ROOTS_ENV]: override }, + }); + assert.deepEqual(roots, [override]); +}); + +test("discoverCursorPluginRoots optionally includes cache tree", () => { + const cursorDir = tempDir("cursor-cache"); + const versionRoot = path.join( + cursorDir, + "plugins", + "cache", + "marketplace", + "plugin-name", + "1.0.0", + ); + mkdirSync(versionRoot, { recursive: true }); + + const without = discoverCursorPluginRoots({ + home: "/unused", + env: { [CURSOR_CONFIG_DIR_ENV]: cursorDir }, + }); + assert.deepEqual(without, []); + + const withCache = discoverCursorPluginRoots({ + home: "/unused", + env: { + [CURSOR_CONFIG_DIR_ENV]: cursorDir, + [INCLUDE_CACHE_ENV]: "1", + }, + }); + assert.deepEqual(withCache, [versionRoot]); +}); + +test("discoverPluginMcpJsonPaths finds mcp.json and .mcp.json and includes self", () => { + const cursorDir = tempDir("cursor-discover"); + const pluginA = path.join(cursorDir, "plugins", "local", "a"); + const pluginB = path.join(cursorDir, "plugins", "local", "b"); + mkdirSync(pluginA, { recursive: true }); + mkdirSync(pluginB, { recursive: true }); + writeFileSync(path.join(pluginA, "mcp.json"), "{}"); + writeFileSync(path.join(pluginB, ".mcp.json"), "{}"); + + const selfRoot = tempDir("self-plugin"); + writeFileSync(path.join(selfRoot, "mcp.json"), "{}"); + const moduleUrl = pathToFileURL( + path.join(selfRoot, "scripts", "cursor-mcp-json-discover.mjs"), + ).href; + + const paths = discoverPluginMcpJsonPaths({ + home: "/unused", + env: { [CURSOR_CONFIG_DIR_ENV]: cursorDir }, + moduleUrl, + }); + + assert.deepEqual( + paths.sort(), + [ + path.join(pluginA, "mcp.json"), + path.join(pluginB, ".mcp.json"), + path.join(selfRoot, "mcp.json"), + ].sort(), + ); +}); + +test("discoverPluginMcpJsonPaths skips self when roots env overrides", () => { + const override = tempDir("override-only"); + writeFileSync(path.join(override, "mcp.json"), "{}"); + const selfRoot = tempDir("self-skipped"); + writeFileSync(path.join(selfRoot, "mcp.json"), "{}"); + const moduleUrl = pathToFileURL( + path.join(selfRoot, "scripts", "cursor-mcp-json-discover.mjs"), + ).href; + + const paths = discoverPluginMcpJsonPaths({ + env: { [ROOTS_ENV]: override }, + moduleUrl, + }); + assert.deepEqual(paths, [path.join(override, "mcp.json")]); +}); + +test("resolveRewriteAllowRoots includes cursor dir, overrides, plugin, targets", () => { + const cursorDir = "/tmp/cursor-cfg"; + const override = "/tmp/override"; + const selfRoot = "/tmp/self-plugin"; + const moduleUrl = pathToFileURL( + path.join(selfRoot, "scripts", "cursor-mcp-json-discover.mjs"), + ).href; + const target = "/tmp/other-plugin/mcp.json"; + + const roots = resolveRewriteAllowRoots({ + env: { + [CURSOR_CONFIG_DIR_ENV]: cursorDir, + [ROOTS_ENV]: override, + }, + moduleUrl, + targets: [target], + }); + assert.deepEqual(roots, [ + cursorDir, + override, + selfRoot, + "/tmp/other-plugin", + ]); +}); From 3dd16aeef567a5487d045e645512e45d1dfb7756 Mon Sep 17 00:00:00 2001 From: arielam Date: Tue, 18 Aug 2026 11:20:06 +0300 Subject: [PATCH 2/4] MLD-1347 - Fix Yoni and Shay's comments --- .github/scripts/sync-modules-vendor.json | 5 ++ .github/scripts/sync-modules.mjs | 55 +++++++++++++++- .github/scripts/sync-modules.test.mjs | 32 +++++++++ VENDOR.md | 4 +- plugins/jfrog/README.md | 12 ++-- plugins/jfrog/hooks/hooks.json | 2 +- .../jfrog/modules/core/rewrite-mcp-json.mjs | 2 +- .../jfrog/scripts/cursor-align-mcp-json.mjs | 2 +- .../scripts/cursor-align-mcp-json.test.mjs | 31 ++++++++- .../scripts/cursor-mcp-json-discover.mjs | 65 ++++++++++++++----- .../scripts/cursor-mcp-json-discover.test.mjs | 44 +++++++++---- 11 files changed, 211 insertions(+), 43 deletions(-) create mode 100644 .github/scripts/sync-modules.test.mjs diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 4bb93a2..b683033 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -4,5 +4,10 @@ "dest_prefix": "plugins/jfrog", "paths": [ "modules" + ], + "keep": [ + "modules/core/rewrite-mcp-json.mjs", + "modules/core/agent-guard-check.mjs", + "modules/core/entry.mjs" ] } diff --git a/.github/scripts/sync-modules.mjs b/.github/scripts/sync-modules.mjs index 99a5d29..e98520a 100644 --- a/.github/scripts/sync-modules.mjs +++ b/.github/scripts/sync-modules.mjs @@ -6,10 +6,15 @@ // // Defaults JFROG_AGENT_HOOKS_PATH to ../jfrog-agent-hooks (sibling clone). // Reads paths from sync-modules-vendor.json. +// +// Optional vendor.keep: dest-relative file paths restored after sync so a +// temporary overlay (e.g. MLD-1386 core files) is not wiped until upstream +// ships them and keep is removed. import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "..", ".."); @@ -24,6 +29,37 @@ async function fileExists(p) { } } +/** + * @param {string} destRoot + * @param {string[]} keepRels — paths relative to destRoot + * @returns {Promise>} + */ +export async function stashKeepFiles(destRoot, keepRels) { + /** @type {Map} */ + const stash = new Map(); + for (const rel of keepRels) { + if (typeof rel !== "string" || !rel.trim()) continue; + const normalized = rel.replace(/^\/+/, ""); + const full = path.join(destRoot, normalized); + if (!(await fileExists(full))) continue; + stash.set(normalized, await fs.readFile(full)); + } + return stash; +} + +/** + * @param {string} destRoot + * @param {Map} stash + */ +export async function restoreKeepFiles(destRoot, stash) { + for (const [rel, buf] of stash) { + const full = path.join(destRoot, rel); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, buf); + console.log(` keep restored: ${rel}`); + } +} + async function copyPath(fromDir, toDir, relativePath) { const from = path.join(fromDir, relativePath); const to = path.join(toDir, relativePath); @@ -42,6 +78,7 @@ async function main() { if (!Array.isArray(paths) || paths.length === 0) { throw new Error(`${vendorPath} must define a non-empty paths array`); } + const keep = Array.isArray(vendor.keep) ? vendor.keep : []; const hooksRoot = process.env.JFROG_AGENT_HOOKS_PATH?.trim() || @@ -57,10 +94,24 @@ async function main() { const destRoot = destPrefix ? path.join(repoRoot, destPrefix) : repoRoot; console.log(`--- sync from ${hooksRoot} (pin: ${vendor.pin ?? "local"}) ---`); + const stash = await stashKeepFiles(destRoot, keep); for (const rel of paths) { await copyPath(hooksRoot, destRoot, rel); } + await restoreKeepFiles(destRoot, stash); console.log("done."); } -await main(); +function isMainModule() { + const entry = process.argv[1]; + if (!entry) return false; + try { + return pathToFileURL(path.resolve(entry)).href === import.meta.url; + } catch { + return false; + } +} + +if (isMainModule()) { + await main(); +} diff --git a/.github/scripts/sync-modules.test.mjs b/.github/scripts/sync-modules.test.mjs new file mode 100644 index 0000000..d620092 --- /dev/null +++ b/.github/scripts/sync-modules.test.mjs @@ -0,0 +1,32 @@ +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + restoreKeepFiles, + stashKeepFiles, +} from "./sync-modules.mjs"; + +test("stashKeepFiles + restoreKeepFiles round-trip overlay files", async () => { + const destRoot = mkdtempSync(path.join(tmpdir(), "sync-keep-")); + const rel = path.join("modules", "core", "rewrite-mcp-json.mjs"); + const full = path.join(destRoot, rel); + mkdirSync(path.dirname(full), { recursive: true }); + writeFileSync(full, "overlay-v1\n"); + + const stash = await stashKeepFiles(destRoot, [rel, "modules/core/missing.mjs"]); + assert.equal(stash.size, 1); + assert.equal(stash.get(rel)?.toString("utf8"), "overlay-v1\n"); + + // Simulate sync wiping the tree. + writeFileSync(full, "upstream-empty\n"); + + await restoreKeepFiles(destRoot, stash); + assert.equal(readFileSync(full, "utf8"), "overlay-v1\n"); +}); diff --git a/VENDOR.md b/VENDOR.md index 37e9c24..5a2bf7e 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -40,7 +40,7 @@ The bundle contains harness runners (`core/`, `cursor-session-start.mjs`), the ` Harness-specific scripts (for example `plugins/jfrog/scripts/cursor-align-mcp-json.mjs` and `cursor-mcp-json-discover.mjs`) live **outside** `modules/` so sync does not wipe them. They call shared orchestration in synced `modules/core/` (for example `rewrite-mcp-json.mjs`). -Until the next `jfrog-agent-hooks` release that includes MLD-1386, `modules/core/{rewrite-mcp-json,agent-guard-check,entry}.mjs` are vendored from that branch on top of the pinned `v0.8.1` tree (see the vendor pin suffix). +Until the next `jfrog-agent-hooks` release that includes MLD-1386, `modules/core/{rewrite-mcp-json,agent-guard-check,entry}.mjs` are vendored from that branch on top of the pinned `v0.8.1` tree (see the vendor pin suffix). Those paths are listed in `keep` in [`sync-modules-vendor.json`](.github/scripts/sync-modules-vendor.json) so a sync run restores them after replacing `modules/`. Remove the `keep` entries once upstream includes the files and the pin no longer needs the overlay. ## Refreshing modules @@ -48,4 +48,4 @@ Until the next `jfrog-agent-hooks` release that includes MLD-1386, `modules/core JFROG_AGENT_HOOKS_PATH=/path/to/jfrog-agent-hooks node .github/scripts/sync-modules.mjs ``` -The script reads `paths` and `dest_prefix` from `sync-modules-vendor.json` (today: `paths: ["modules"]`, `dest_prefix: "plugins/jfrog"`) and replaces the whole `plugins/jfrog/modules/` tree. +The script reads `paths`, `dest_prefix`, and optional `keep` from `sync-modules-vendor.json` (today: `paths: ["modules"]`, `dest_prefix: "plugins/jfrog"`) and replaces the whole `plugins/jfrog/modules/` tree, then restores any `keep` files that existed before the sync. diff --git a/plugins/jfrog/README.md b/plugins/jfrog/README.md index 5000e4b..05cfb0a 100644 --- a/plugins/jfrog/README.md +++ b/plugins/jfrog/README.md @@ -20,7 +20,7 @@ CLI authentication options: run `jf login` for browser-based setup, or set the ` |---|---|---| | **MCP** | `mcp.json` | Remote JFrog MCP server (OAuth, no API keys) | | **Hook + Skill** | `hooks/hooks.json`, `skills/jfrog-setup-package-managers/` | Agent Package Resolution (Preview) — route agent package installs through Artifactory | -| **Hook** | `hooks/hooks.json`, `scripts/cursor-align-mcp-json.mjs` (+ `cursor-mcp-json-discover.mjs`) | On session start, rewrite discovered plugin `mcp.json` / `.mcp.json` files through Agent Guard (`--rewrite-mcp-json`) so stdio MCP entries launch via `@jfrog/agent-guard` | +| **Hook** | `hooks/hooks.json`, `scripts/cursor-align-mcp-json.mjs` (+ `cursor-mcp-json-discover.mjs`) | On session start, rewrite discovered plugin `mcp.json` files through Agent Guard (`--rewrite-mcp-json`) so stdio MCP entries launch via `@jfrog/agent-guard` | ### Skills @@ -49,7 +49,9 @@ Agent Package Resolution is in preview and opt-in. To get started: ## Plugin MCP rewrite (Agent Guard) -On every Cursor agent `sessionStart`, the plugin discovers MCP configs under `$CURSOR_CONFIG_DIR/plugins/local/*` (default `~/.cursor`; prefers `mcp.json`, else `.mcp.json`), plus this plugin's own `mcp.json`, and runs `npx @jfrog/agent-guard --rewrite-mcp-json` against those paths. Stdio MCP entries are rewritten to launch through Agent Guard; remote `url` / `http` / `sse` / `ws` entries are left unchanged. Workspace and user-level `.cursor/mcp.json` files are **not** rewritten. If a file is rewritten, use **Developer: Reload Window** so Cursor reloads MCP config. +On every Cursor agent `sessionStart`, the plugin discovers plugin `mcp.json` files under `$CURSOR_CONFIG_DIR/plugins/local/*` (default `~/.cursor` when unset), plus this plugin's own `mcp.json`, and runs `npx @jfrog/agent-guard --rewrite-mcp-json` against those paths. Cursor plugins use `mcp.json` only (`.mcp.json` is not discovered). Stdio MCP entries are rewritten to launch through Agent Guard; remote `url` / `http` / `sse` / `ws` entries are left unchanged. Workspace and user-level `.cursor/mcp.json` files are **not** rewritten. If a file is rewritten, use **Developer: Reload Window** so Cursor reloads MCP config. + +Marketplace installs under `$CURSOR_CONFIG_DIR/plugins/cache` are **not** rewritten by default (opt-in via env below). Auto-discovered roots must resolve under `$CURSOR_CONFIG_DIR` (symlink escapes are skipped); `JF_ALIGN_MCP_JSON_ROOTS` overrides are trusted as-is and skip this plugin's own `mcp.json` unless you list that root yourself. The hook soft-fails (never breaks the session): missing project key, Agent Guard gate failure, or rewrite errors log and exit 0. @@ -61,9 +63,9 @@ The hook soft-fails (never breaks the session): missing project key, Agent Guard | `JFROG_AGENT_GUARD_VERSION` | Override pinned `@jfrog/agent-guard` version | | `JFROG_AGENT_GUARD_REPO` | Private npm registry for `@jfrog/agent-guard` | | `JFROG_AGENT_GUARD_BIN` | Local Agent Guard binary (skips npx) | -| `JF_ALIGN_MCP_JSON_ROOTS` | Override discovery roots (POSIX `:`/`,`; Windows `;`/`,`) | -| `CURSOR_CONFIG_DIR` | Cursor config root (default `~/.cursor`) | -| `JF_ALIGN_MCP_JSON_INCLUDE_CACHE=1` | Also scan `$CURSOR_CONFIG_DIR/plugins/cache` | +| `JF_ALIGN_MCP_JSON_ROOTS` | Replace discovery roots entirely (POSIX `:`/`,`; Windows `;`/`,`). Does not auto-include this plugin's own `mcp.json` | +| `CURSOR_CONFIG_DIR` | Root used for default plugin discovery (default `~/.cursor`). Matches Cursor's usual plugin layout when unset; primarily documented by Cursor for CLI config | +| `JF_ALIGN_MCP_JSON_INCLUDE_CACHE=1` | Also scan `$CURSOR_CONFIG_DIR/plugins/cache` (marketplace installs; off by default) | ## MCP Capabilities diff --git a/plugins/jfrog/hooks/hooks.json b/plugins/jfrog/hooks/hooks.json index a6657f5..0cd3ccd 100644 --- a/plugins/jfrog/hooks/hooks.json +++ b/plugins/jfrog/hooks/hooks.json @@ -8,7 +8,7 @@ }, { "command": "node \"./scripts/cursor-align-mcp-json.mjs\" session-start", - "timeout": 45 + "timeout": 60 } ] } diff --git a/plugins/jfrog/modules/core/rewrite-mcp-json.mjs b/plugins/jfrog/modules/core/rewrite-mcp-json.mjs index 9f22bca..9bd236a 100644 --- a/plugins/jfrog/modules/core/rewrite-mcp-json.mjs +++ b/plugins/jfrog/modules/core/rewrite-mcp-json.mjs @@ -45,7 +45,7 @@ export const DEFAULT_AGENT_GUARD_NPM_REGISTRY = */ export const DEFAULT_AGENT_GUARD_VERSION = "1.6.0"; /** Shared budget for rewriting all discovered files in one hook invocation. */ -/** Must leave headroom under Cursor hooks.json timeout (45s) for gate + overhead. */ +/** Must leave headroom under Cursor hooks.json timeout (60s) for gate + overhead. */ export const DEFAULT_REWRITE_TIMEOUT_MS = 35_000; /** SIGTERM → SIGKILL escalation window for a child that ignores the first signal. */ export const DEFAULT_KILL_GRACE_MS = 2_000; diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs index 73ae039..5bd32a6 100644 --- a/plugins/jfrog/scripts/cursor-align-mcp-json.mjs +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs @@ -33,7 +33,7 @@ const HARNESS_ID = "cursor"; const log = createLogger("align-mcp-json"); /** Recommended Cursor hooks.json timeout (seconds) for the align entry. */ -export const RECOMMENDED_HOOK_TIMEOUT_SEC = 45; +export const RECOMMENDED_HOOK_TIMEOUT_SEC = 60; /** @type {ReadonlySet} */ export const MODES = Object.freeze(new Set(["session-start", "file-changed"])); diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs index 0623c44..9db880e 100644 --- a/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs @@ -3,10 +3,11 @@ // https://www.apache.org/licenses/LICENSE-2.0 import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { CURSOR_CONFIG_DIR_ENV, @@ -14,8 +15,13 @@ import { } from "./cursor-mcp-json-discover.mjs"; import { isKnownMode, + RECOMMENDED_HOOK_TIMEOUT_SEC, runCursorAlignMcpJson, } from "./cursor-align-mcp-json.mjs"; +import { + DEFAULT_KILL_GRACE_MS, + DEFAULT_REWRITE_TIMEOUT_MS, +} from "../modules/core/rewrite-mcp-json.mjs"; /** * @param {string[]} segments @@ -28,6 +34,29 @@ function tempDir(...segments) { return full; } +test("hooks.json align timeout matches RECOMMENDED_HOOK_TIMEOUT_SEC with rewrite headroom", () => { + const hooksPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "hooks", + "hooks.json", + ); + const hooks = JSON.parse(readFileSync(hooksPath, "utf8")); + const alignHook = hooks.hooks?.sessionStart?.find((h) => + String(h.command ?? "").includes("cursor-align-mcp-json"), + ); + assert.ok(alignHook, "sessionStart align hook missing from hooks.json"); + assert.equal(alignHook.timeout, RECOMMENDED_HOOK_TIMEOUT_SEC); + + // Gate (~7s) + rewrite spawn + SIGKILL grace must fit under the hook timeout. + const reservedOverheadMs = 7_000; + assert.ok( + DEFAULT_REWRITE_TIMEOUT_MS + DEFAULT_KILL_GRACE_MS + reservedOverheadMs < + RECOMMENDED_HOOK_TIMEOUT_SEC * 1000, + "rewrite budget + grace + gate overhead must leave margin under hooks.json timeout", + ); +}); + test("isKnownMode accepts session-start and file-changed", () => { assert.equal(isKnownMode("session-start"), true); assert.equal(isKnownMode("file-changed"), true); diff --git a/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs index f6ad369..7d864e9 100644 --- a/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs @@ -2,17 +2,17 @@ // Licensed under the Apache License, Version 2.0 // https://www.apache.org/licenses/LICENSE-2.0 // -// Cursor-specific discovery of plugin mcp.json / .mcp.json paths and -// Agent Guard --allow-root directories. Harness entry lives in -// cursor-align-mcp-json.mjs; shared rewrite orchestration lives in -// modules/core/rewrite-mcp-json.mjs. +// Cursor-specific discovery of plugin mcp.json paths and Agent Guard +// --allow-root directories. Harness entry lives in cursor-align-mcp-json.mjs; +// shared rewrite orchestration lives in modules/core/rewrite-mcp-json.mjs. // // Override roots: JF_ALIGN_MCP_JSON_ROOTS=/path/a:/path/b // (POSIX: colon/comma; Windows: semicolon/comma — avoids splitting C:\…) -// Cursor config root: CURSOR_CONFIG_DIR (Cursor CLI; default ~/.cursor) +// Cursor config root: CURSOR_CONFIG_DIR (default ~/.cursor; used as our +// discovery root — Cursor docs primarily document this for CLI config) // Include marketplace cache: JF_ALIGN_MCP_JSON_INCLUDE_CACHE=1 -import { existsSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import process from "node:process"; @@ -25,8 +25,8 @@ import { fileURLToPath } from "node:url"; */ export const ROOTS_ENV = "JF_ALIGN_MCP_JSON_ROOTS"; /** - * Cursor CLI config root override. Same as Cursor: - * `${CURSOR_CONFIG_DIR:-$HOME/.cursor}` — plugins live under + * Cursor config root used for default discovery. Same default as Cursor's + * usual layout: `${CURSOR_CONFIG_DIR:-$HOME/.cursor}` — plugins under * `$CURSOR_CONFIG_DIR/plugins/local` (and optionally `…/cache`). */ export const CURSOR_CONFIG_DIR_ENV = "CURSOR_CONFIG_DIR"; @@ -85,6 +85,20 @@ export function resolveCursorConfigDir(opts = {}) { return fromEnv || path.join(home, ".cursor"); } +/** + * True when resolvedPath is cursorDir or a descendant (after realpath). + * @param {string} resolvedPath + * @param {string} cursorDirResolved + */ +export function isPathInsideResolvedRoot(resolvedPath, cursorDirResolved) { + const root = cursorDirResolved.endsWith(path.sep) + ? cursorDirResolved + : cursorDirResolved + path.sep; + return ( + resolvedPath === cursorDirResolved || resolvedPath.startsWith(root) + ); +} + /** * @param {{ * home?: string, @@ -92,6 +106,7 @@ export function resolveCursorConfigDir(opts = {}) { * readdirSyncFn?: typeof readdirSync, * existsSyncFn?: typeof existsSync, * statSyncFn?: typeof statSync, + * realpathSyncFn?: typeof realpathSync, * }} [opts] * @returns {string[]} */ @@ -101,9 +116,11 @@ export function discoverCursorPluginRoots(opts = {}) { const readdirFn = opts.readdirSyncFn ?? readdirSync; const existsFn = opts.existsSyncFn ?? existsSync; const statFn = opts.statSyncFn ?? statSync; + const realpathFn = opts.realpathSyncFn ?? realpathSync; const fromEnv = parseRootsEnv(env[ROOTS_ENV] ?? ""); if (fromEnv.length > 0) { + // Override roots are trusted and not confined to CURSOR_CONFIG_DIR. return fromEnv.filter((root) => { try { return existsFn(root) && statFn(root).isDirectory(); @@ -114,6 +131,13 @@ export function discoverCursorPluginRoots(opts = {}) { } const cursorDir = resolveCursorConfigDir({ home, env }); + let cursorDirResolved; + try { + cursorDirResolved = realpathFn(cursorDir); + } catch { + return []; + } + /** @type {string[]} */ const roots = []; const localDir = path.join(cursorDir, "plugins", "local"); @@ -140,7 +164,17 @@ export function discoverCursorPluginRoots(opts = {}) { } } - return roots; + return roots.filter((root) => { + try { + const resolved = realpathFn(root); + return ( + statFn(resolved).isDirectory() && + isPathInsideResolvedRoot(resolved, cursorDirResolved) + ); + } catch { + return false; + } + }); } /** @@ -174,8 +208,7 @@ function listImmediateSubdirs(dir, fs) { } /** - * Resolve the MCP config path for a plugin root. - * Order: mcp.json, then .mcp.json. + * Resolve the MCP config path for a plugin root (Cursor plugins use mcp.json). * @param {string} pluginRoot * @param {{ * existsSyncFn?: typeof existsSync, @@ -184,14 +217,8 @@ function listImmediateSubdirs(dir, fs) { */ export function resolveMcpJsonForPluginRoot(pluginRoot, deps = {}) { const existsFn = deps.existsSyncFn ?? existsSync; - const mcpJson = path.join(pluginRoot, "mcp.json"); - if (existsFn(mcpJson)) return mcpJson; - - const dotMcpJson = path.join(pluginRoot, ".mcp.json"); - if (existsFn(dotMcpJson)) return dotMcpJson; - - return undefined; + return existsFn(mcpJson) ? mcpJson : undefined; } /** @@ -203,6 +230,7 @@ export function resolveMcpJsonForPluginRoot(pluginRoot, deps = {}) { * readdirSyncFn?: typeof readdirSync, * existsSyncFn?: typeof existsSync, * statSyncFn?: typeof statSync, + * realpathSyncFn?: typeof realpathSync, * }} [opts] * @returns {string[]} */ @@ -215,6 +243,7 @@ export function discoverPluginMcpJsonPaths(opts = {}) { readdirSyncFn: opts.readdirSyncFn, existsSyncFn: existsFn, statSyncFn: opts.statSyncFn, + realpathSyncFn: opts.realpathSyncFn, }); /** @type {string[]} */ diff --git a/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs index 412fa1a..2e56f76 100644 --- a/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs @@ -3,7 +3,7 @@ // https://www.apache.org/licenses/LICENSE-2.0 import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; @@ -70,13 +70,10 @@ test("resolvePluginRoot is parent of scripts/", () => { assert.equal(resolvePluginRoot(moduleUrl), path.join("/tmp/plugin")); }); -test("resolveMcpJsonForPluginRoot prefers mcp.json over .mcp.json", () => { +test("resolveMcpJsonForPluginRoot finds mcp.json only", () => { const root = tempDir("plugin-a"); writeFileSync(path.join(root, ".mcp.json"), "{}"); - assert.equal( - resolveMcpJsonForPluginRoot(root), - path.join(root, ".mcp.json"), - ); + assert.equal(resolveMcpJsonForPluginRoot(root), undefined); writeFileSync(path.join(root, "mcp.json"), "{}"); assert.equal(resolveMcpJsonForPluginRoot(root), path.join(root, "mcp.json")); }); @@ -132,7 +129,7 @@ test("discoverCursorPluginRoots optionally includes cache tree", () => { assert.deepEqual(withCache, [versionRoot]); }); -test("discoverPluginMcpJsonPaths finds mcp.json and .mcp.json and includes self", () => { +test("discoverPluginMcpJsonPaths finds mcp.json only and includes self", () => { const cursorDir = tempDir("cursor-discover"); const pluginA = path.join(cursorDir, "plugins", "local", "a"); const pluginB = path.join(cursorDir, "plugins", "local", "b"); @@ -155,11 +152,7 @@ test("discoverPluginMcpJsonPaths finds mcp.json and .mcp.json and includes self" assert.deepEqual( paths.sort(), - [ - path.join(pluginA, "mcp.json"), - path.join(pluginB, ".mcp.json"), - path.join(selfRoot, "mcp.json"), - ].sort(), + [path.join(pluginA, "mcp.json"), path.join(selfRoot, "mcp.json")].sort(), ); }); @@ -179,6 +172,33 @@ test("discoverPluginMcpJsonPaths skips self when roots env overrides", () => { assert.deepEqual(paths, [path.join(override, "mcp.json")]); }); + +test("discoverCursorPluginRoots drops symlinks that escape CURSOR_CONFIG_DIR", () => { + const cursorDir = tempDir("cursor-symlink"); + const outside = tempDir("outside-plugin"); + const localDir = path.join(cursorDir, "plugins", "local"); + mkdirSync(localDir, { recursive: true }); + const safe = path.join(localDir, "safe"); + mkdirSync(safe, { recursive: true }); + const evil = path.join(localDir, "evil-link"); + symlinkSync(outside, evil); + + const roots = discoverCursorPluginRoots({ + home: "/unused", + env: { [CURSOR_CONFIG_DIR_ENV]: cursorDir }, + }); + assert.deepEqual(roots, [safe]); +}); + +test("discoverCursorPluginRoots override roots are not confined to CURSOR_CONFIG_DIR", () => { + const override = tempDir("override-outside"); + const roots = discoverCursorPluginRoots({ + home: "/unused", + env: { [ROOTS_ENV]: override }, + }); + assert.deepEqual(roots, [override]); +}); + test("resolveRewriteAllowRoots includes cursor dir, overrides, plugin, targets", () => { const cursorDir = "/tmp/cursor-cfg"; const override = "/tmp/override"; From 2f9a63ebcae52b2635343722bbf8f85ebcd0f51f Mon Sep 17 00:00:00 2001 From: arielam Date: Tue, 18 Aug 2026 17:28:54 +0300 Subject: [PATCH 3/4] MLD-1347 - Discover .mcp.json, govern marketplace installs, cjamge cursor directory --- plugins/jfrog/README.md | 9 +- .../scripts/cursor-align-mcp-json.test.mjs | 10 +- .../scripts/cursor-mcp-json-discover.mjs | 76 +++++------ .../scripts/cursor-mcp-json-discover.test.mjs | 124 ++++++++++++------ 4 files changed, 128 insertions(+), 91 deletions(-) diff --git a/plugins/jfrog/README.md b/plugins/jfrog/README.md index 05cfb0a..282fdf4 100644 --- a/plugins/jfrog/README.md +++ b/plugins/jfrog/README.md @@ -20,7 +20,7 @@ CLI authentication options: run `jf login` for browser-based setup, or set the ` |---|---|---| | **MCP** | `mcp.json` | Remote JFrog MCP server (OAuth, no API keys) | | **Hook + Skill** | `hooks/hooks.json`, `skills/jfrog-setup-package-managers/` | Agent Package Resolution (Preview) — route agent package installs through Artifactory | -| **Hook** | `hooks/hooks.json`, `scripts/cursor-align-mcp-json.mjs` (+ `cursor-mcp-json-discover.mjs`) | On session start, rewrite discovered plugin `mcp.json` files through Agent Guard (`--rewrite-mcp-json`) so stdio MCP entries launch via `@jfrog/agent-guard` | +| **Hook** | `hooks/hooks.json`, `scripts/cursor-align-mcp-json.mjs` (+ `cursor-mcp-json-discover.mjs`) | On session start, rewrite discovered plugin `mcp.json` / `.mcp.json` files through Agent Guard (`--rewrite-mcp-json`) so stdio MCP entries launch via `@jfrog/agent-guard` | ### Skills @@ -49,9 +49,9 @@ Agent Package Resolution is in preview and opt-in. To get started: ## Plugin MCP rewrite (Agent Guard) -On every Cursor agent `sessionStart`, the plugin discovers plugin `mcp.json` files under `$CURSOR_CONFIG_DIR/plugins/local/*` (default `~/.cursor` when unset), plus this plugin's own `mcp.json`, and runs `npx @jfrog/agent-guard --rewrite-mcp-json` against those paths. Cursor plugins use `mcp.json` only (`.mcp.json` is not discovered). Stdio MCP entries are rewritten to launch through Agent Guard; remote `url` / `http` / `sse` / `ws` entries are left unchanged. Workspace and user-level `.cursor/mcp.json` files are **not** rewritten. If a file is rewritten, use **Developer: Reload Window** so Cursor reloads MCP config. +On every Cursor agent `sessionStart`, the plugin discovers plugin `mcp.json` and `.mcp.json` files under `~/.cursor/plugins/local/*` and `~/.cursor/plugins/cache/*` (marketplace installs), plus this plugin's own configs, and runs `npx @jfrog/agent-guard --rewrite-mcp-json` against those paths. Cursor can load servers from both files when both exist. Stdio MCP entries are rewritten to launch through Agent Guard; remote `url` / `http` / `sse` / `ws` entries are left unchanged. Workspace and user-level `.cursor/mcp.json` files are **not** rewritten. If a file is rewritten, use **Developer: Reload Window** so Cursor reloads MCP config. -Marketplace installs under `$CURSOR_CONFIG_DIR/plugins/cache` are **not** rewritten by default (opt-in via env below). Auto-discovered roots must resolve under `$CURSOR_CONFIG_DIR` (symlink escapes are skipped); `JF_ALIGN_MCP_JSON_ROOTS` overrides are trusted as-is and skip this plugin's own `mcp.json` unless you list that root yourself. +Marketplace installs under `~/.cursor/plugins/cache` are rewritten by default (opt out via env below). Auto-discovered roots must resolve under `~/.cursor` (symlink escapes are skipped); `JF_ALIGN_MCP_JSON_ROOTS` overrides are trusted as-is and skip this plugin's own `mcp.json` unless you list that root yourself. `CURSOR_CONFIG_DIR` (CLI config) is **not** used for plugin discovery — Cursor loads plugins from `~/.cursor` regardless. The hook soft-fails (never breaks the session): missing project key, Agent Guard gate failure, or rewrite errors log and exit 0. @@ -64,8 +64,7 @@ The hook soft-fails (never breaks the session): missing project key, Agent Guard | `JFROG_AGENT_GUARD_REPO` | Private npm registry for `@jfrog/agent-guard` | | `JFROG_AGENT_GUARD_BIN` | Local Agent Guard binary (skips npx) | | `JF_ALIGN_MCP_JSON_ROOTS` | Replace discovery roots entirely (POSIX `:`/`,`; Windows `;`/`,`). Does not auto-include this plugin's own `mcp.json` | -| `CURSOR_CONFIG_DIR` | Root used for default plugin discovery (default `~/.cursor`). Matches Cursor's usual plugin layout when unset; primarily documented by Cursor for CLI config | -| `JF_ALIGN_MCP_JSON_INCLUDE_CACHE=1` | Also scan `$CURSOR_CONFIG_DIR/plugins/cache` (marketplace installs; off by default) | +| `JF_ALIGN_MCP_JSON_SKIP_CACHE=1` | Skip `~/.cursor/plugins/cache` (marketplace installs; scanned by default) | ## MCP Capabilities diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs index 9db880e..e38101a 100644 --- a/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs @@ -9,10 +9,7 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { - CURSOR_CONFIG_DIR_ENV, - ROOTS_ENV, -} from "./cursor-mcp-json-discover.mjs"; +import { ROOTS_ENV } from "./cursor-mcp-json-discover.mjs"; import { isKnownMode, RECOMMENDED_HOOK_TIMEOUT_SEC, @@ -96,7 +93,8 @@ test("runCursorAlignMcpJson no-ops when harness is not cursor", async () => { }); test("runCursorAlignMcpJson passes discovered paths to shared pipeline", async () => { - const cursorDir = tempDir("cursor-pipeline"); + const home = tempDir("home-pipeline"); + const cursorDir = path.join(home, ".cursor"); const pluginA = path.join(cursorDir, "plugins", "local", "a"); mkdirSync(pluginA, { recursive: true }); const mcpPath = path.join(pluginA, "mcp.json"); @@ -105,8 +103,8 @@ test("runCursorAlignMcpJson passes discovered paths to shared pipeline", async ( /** @type {{ paths?: string[], allowRoots?: string[] }} */ const captured = {}; const code = await runCursorAlignMcpJson("session-start", { + home, env: { - [CURSOR_CONFIG_DIR_ENV]: cursorDir, // Avoid scanning the real hosting plugin tree in this unit test. [ROOTS_ENV]: pluginA, }, diff --git a/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs index 7d864e9..3590a14 100644 --- a/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs @@ -2,15 +2,16 @@ // Licensed under the Apache License, Version 2.0 // https://www.apache.org/licenses/LICENSE-2.0 // -// Cursor-specific discovery of plugin mcp.json paths and Agent Guard +// Cursor-specific discovery of plugin mcp.json / .mcp.json paths and Agent Guard // --allow-root directories. Harness entry lives in cursor-align-mcp-json.mjs; // shared rewrite orchestration lives in modules/core/rewrite-mcp-json.mjs. // // Override roots: JF_ALIGN_MCP_JSON_ROOTS=/path/a:/path/b // (POSIX: colon/comma; Windows: semicolon/comma — avoids splitting C:\…) -// Cursor config root: CURSOR_CONFIG_DIR (default ~/.cursor; used as our -// discovery root — Cursor docs primarily document this for CLI config) -// Include marketplace cache: JF_ALIGN_MCP_JSON_INCLUDE_CACHE=1 +// Default discovery root: $HOME/.cursor (Cursor loads plugins from here; +// CURSOR_CONFIG_DIR is ignored — Cursor does not relocate plugins via that var) +// Marketplace cache (~/.cursor/plugins/cache) is scanned by default; +// skip with JF_ALIGN_MCP_JSON_SKIP_CACHE=1 import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; @@ -24,14 +25,8 @@ import { fileURLToPath } from "node:url"; * would split drive letters like `C:\…`). */ export const ROOTS_ENV = "JF_ALIGN_MCP_JSON_ROOTS"; -/** - * Cursor config root used for default discovery. Same default as Cursor's - * usual layout: `${CURSOR_CONFIG_DIR:-$HOME/.cursor}` — plugins under - * `$CURSOR_CONFIG_DIR/plugins/local` (and optionally `…/cache`). - */ -export const CURSOR_CONFIG_DIR_ENV = "CURSOR_CONFIG_DIR"; -/** When "1", also scan $CURSOR_CONFIG_DIR/plugins/cache (marketplace installs). */ -export const INCLUDE_CACHE_ENV = "JF_ALIGN_MCP_JSON_INCLUDE_CACHE"; +/** When "1", skip ~/.cursor/plugins/cache (marketplace installs; on by default). */ +export const SKIP_CACHE_ENV = "JF_ALIGN_MCP_JSON_SKIP_CACHE"; /** * Plugin root is the parent of `scripts/` (where this file lives). @@ -68,21 +63,17 @@ export function parseRootsEnv(raw, platform = process.platform) { } /** - * Cursor config root: `${CURSOR_CONFIG_DIR:-$HOME/.cursor}`. + * Cursor plugin config root: `$HOME/.cursor`. + * Cursor loads plugins from this path; `CURSOR_CONFIG_DIR` (CLI config) is not + * used for plugin discovery. * @param {{ * home?: string, - * env?: NodeJS.ProcessEnv, * }} [opts] * @returns {string} */ export function resolveCursorConfigDir(opts = {}) { - const env = opts.env ?? process.env; const home = opts.home ?? homedir(); - const fromEnv = - typeof env[CURSOR_CONFIG_DIR_ENV] === "string" - ? env[CURSOR_CONFIG_DIR_ENV].trim() - : ""; - return fromEnv || path.join(home, ".cursor"); + return path.join(home, ".cursor"); } /** @@ -120,7 +111,7 @@ export function discoverCursorPluginRoots(opts = {}) { const fromEnv = parseRootsEnv(env[ROOTS_ENV] ?? ""); if (fromEnv.length > 0) { - // Override roots are trusted and not confined to CURSOR_CONFIG_DIR. + // Override roots are trusted and not confined to ~/.cursor. return fromEnv.filter((root) => { try { return existsFn(root) && statFn(root).isDirectory(); @@ -130,7 +121,7 @@ export function discoverCursorPluginRoots(opts = {}) { }); } - const cursorDir = resolveCursorConfigDir({ home, env }); + const cursorDir = resolveCursorConfigDir({ home }); let cursorDirResolved; try { cursorDirResolved = realpathFn(cursorDir); @@ -145,7 +136,7 @@ export function discoverCursorPluginRoots(opts = {}) { ...listImmediateSubdirs(localDir, { readdirFn, existsFn, statFn }), ); - if (env[INCLUDE_CACHE_ENV] === "1") { + if (env[SKIP_CACHE_ENV] !== "1") { const cacheRoot = path.join(cursorDir, "plugins", "cache"); for (const marketplace of listImmediateSubdirs(cacheRoot, { readdirFn, @@ -208,17 +199,23 @@ function listImmediateSubdirs(dir, fs) { } /** - * Resolve the MCP config path for a plugin root (Cursor plugins use mcp.json). + * Resolve MCP config paths for a plugin root. Cursor loads servers from both + * `mcp.json` and `.mcp.json` when present, so both are returned (in that order). * @param {string} pluginRoot * @param {{ * existsSyncFn?: typeof existsSync, * }} [deps] - * @returns {string | undefined} + * @returns {string[]} */ export function resolveMcpJsonForPluginRoot(pluginRoot, deps = {}) { const existsFn = deps.existsSyncFn ?? existsSync; - const mcpJson = path.join(pluginRoot, "mcp.json"); - return existsFn(mcpJson) ? mcpJson : undefined; + /** @type {string[]} */ + const paths = []; + for (const name of ["mcp.json", ".mcp.json"]) { + const candidate = path.join(pluginRoot, name); + if (existsFn(candidate)) paths.push(candidate); + } + return paths; } /** @@ -257,27 +254,30 @@ export function discoverPluginMcpJsonPaths(opts = {}) { }; for (const root of roots) { - add( - resolveMcpJsonForPluginRoot(root, { - existsSyncFn: existsFn, - }), - ); + for (const p of resolveMcpJsonForPluginRoot(root, { + existsSyncFn: existsFn, + })) { + add(p); + } } const rootsOverridden = parseRootsEnv(env[ROOTS_ENV] ?? "").length > 0; if (opts.includeSelf !== false && !rootsOverridden) { - add( - resolveMcpJsonForPluginRoot(resolvePluginRoot(opts.moduleUrl), { + for (const p of resolveMcpJsonForPluginRoot( + resolvePluginRoot(opts.moduleUrl), + { existsSyncFn: existsFn, - }), - ); + }, + )) { + add(p); + } } return paths; } /** - * Allow-roots for Agent Guard: Cursor config dir, override roots, plugin root, + * Allow-roots for Agent Guard: ~/.cursor, override roots, plugin root, * and parent dirs of discovered targets. * @param {{ * home?: string, @@ -299,7 +299,7 @@ export function resolveRewriteAllowRoots(opts = {}) { roots.push(p); }; - add(resolveCursorConfigDir({ home, env })); + add(resolveCursorConfigDir({ home })); for (const root of parseRootsEnv(env[ROOTS_ENV] ?? "")) { add(root); } diff --git a/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs index 2e56f76..37d8714 100644 --- a/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs @@ -10,9 +10,8 @@ import test from "node:test"; import { pathToFileURL } from "node:url"; import { - CURSOR_CONFIG_DIR_ENV, - INCLUDE_CACHE_ENV, ROOTS_ENV, + SKIP_CACHE_ENV, discoverCursorPluginRoots, discoverPluginMcpJsonPaths, parseRootsEnv, @@ -33,6 +32,19 @@ function tempDir(...segments) { return full; } +/** + * Fake $HOME with a `.cursor` tree for discovery tests. + * @param {string[]} underCursor — path segments under `.cursor` + * @returns {{ home: string, cursorDir: string, path: string }} + */ +function tempHomeCursor(...underCursor) { + const home = mkdtempSync(path.join(tmpdir(), "cursor-home-")); + const cursorDir = path.join(home, ".cursor"); + const full = path.join(cursorDir, ...underCursor); + mkdirSync(full, { recursive: true }); + return { home, cursorDir, path: full }; +} + test("parseRootsEnv splits POSIX and Windows delimiters", () => { assert.deepEqual(parseRootsEnv("/a:/b,/c", "linux"), ["/a", "/b", "/c"]); assert.deepEqual(parseRootsEnv("C:\\a;D:\\b,E:\\c", "win32"), [ @@ -47,19 +59,31 @@ test("parseRootsEnv splits POSIX and Windows delimiters", () => { assert.deepEqual(parseRootsEnv(" ", "linux"), []); }); -test("resolveCursorConfigDir prefers CURSOR_CONFIG_DIR", () => { +test("resolveCursorConfigDir is always $HOME/.cursor", () => { const home = "/home/user"; assert.equal( - resolveCursorConfigDir({ home, env: {} }), + resolveCursorConfigDir({ home }), path.join(home, ".cursor"), ); - assert.equal( - resolveCursorConfigDir({ - home, - env: { [CURSOR_CONFIG_DIR_ENV]: "/custom/cursor" }, - }), - "/custom/cursor", +}); + +test("discoverCursorPluginRoots ignores CURSOR_CONFIG_DIR", () => { + const { home, path: localPlugin } = tempHomeCursor( + "plugins", + "local", + "from-home", + ); + const customCursor = tempDir("custom-cursor"); + mkdirSync( + path.join(customCursor, "plugins", "local", "from-env"), + { recursive: true }, ); + + const roots = discoverCursorPluginRoots({ + home, + env: { CURSOR_CONFIG_DIR: customCursor }, + }); + assert.deepEqual(roots, [localPlugin]); }); test("resolvePluginRoot is parent of scripts/", () => { @@ -70,24 +94,29 @@ test("resolvePluginRoot is parent of scripts/", () => { assert.equal(resolvePluginRoot(moduleUrl), path.join("/tmp/plugin")); }); -test("resolveMcpJsonForPluginRoot finds mcp.json only", () => { +test("resolveMcpJsonForPluginRoot finds mcp.json and .mcp.json", () => { const root = tempDir("plugin-a"); writeFileSync(path.join(root, ".mcp.json"), "{}"); - assert.equal(resolveMcpJsonForPluginRoot(root), undefined); + assert.deepEqual(resolveMcpJsonForPluginRoot(root), [ + path.join(root, ".mcp.json"), + ]); writeFileSync(path.join(root, "mcp.json"), "{}"); - assert.equal(resolveMcpJsonForPluginRoot(root), path.join(root, "mcp.json")); + assert.deepEqual(resolveMcpJsonForPluginRoot(root), [ + path.join(root, "mcp.json"), + path.join(root, ".mcp.json"), + ]); }); test("discoverCursorPluginRoots scans plugins/local", () => { - const cursorDir = tempDir("cursor-home"); + const { home, cursorDir } = tempHomeCursor("plugins", "local"); const localA = path.join(cursorDir, "plugins", "local", "alpha"); const localB = path.join(cursorDir, "plugins", "local", "beta"); mkdirSync(localA, { recursive: true }); mkdirSync(localB, { recursive: true }); const roots = discoverCursorPluginRoots({ - home: "/unused", - env: { [CURSOR_CONFIG_DIR_ENV]: cursorDir }, + home, + env: {}, }); assert.deepEqual(roots.sort(), [localA, localB].sort()); }); @@ -101,8 +130,8 @@ test("discoverCursorPluginRoots honors JF_ALIGN_MCP_JSON_ROOTS override", () => assert.deepEqual(roots, [override]); }); -test("discoverCursorPluginRoots optionally includes cache tree", () => { - const cursorDir = tempDir("cursor-cache"); +test("discoverCursorPluginRoots includes cache tree by default", () => { + const { home, cursorDir } = tempHomeCursor("plugins", "cache"); const versionRoot = path.join( cursorDir, "plugins", @@ -113,46 +142,57 @@ test("discoverCursorPluginRoots optionally includes cache tree", () => { ); mkdirSync(versionRoot, { recursive: true }); - const without = discoverCursorPluginRoots({ - home: "/unused", - env: { [CURSOR_CONFIG_DIR_ENV]: cursorDir }, + const withCache = discoverCursorPluginRoots({ + home, + env: {}, }); - assert.deepEqual(without, []); + assert.deepEqual(withCache, [versionRoot]); - const withCache = discoverCursorPluginRoots({ - home: "/unused", + const skipped = discoverCursorPluginRoots({ + home, env: { - [CURSOR_CONFIG_DIR_ENV]: cursorDir, - [INCLUDE_CACHE_ENV]: "1", + [SKIP_CACHE_ENV]: "1", }, }); - assert.deepEqual(withCache, [versionRoot]); + assert.deepEqual(skipped, []); }); -test("discoverPluginMcpJsonPaths finds mcp.json only and includes self", () => { - const cursorDir = tempDir("cursor-discover"); +test("discoverPluginMcpJsonPaths finds mcp.json and .mcp.json and includes self", () => { + const { home, cursorDir } = tempHomeCursor("plugins", "local"); const pluginA = path.join(cursorDir, "plugins", "local", "a"); const pluginB = path.join(cursorDir, "plugins", "local", "b"); + const pluginC = path.join(cursorDir, "plugins", "local", "c"); mkdirSync(pluginA, { recursive: true }); mkdirSync(pluginB, { recursive: true }); + mkdirSync(pluginC, { recursive: true }); writeFileSync(path.join(pluginA, "mcp.json"), "{}"); writeFileSync(path.join(pluginB, ".mcp.json"), "{}"); + writeFileSync(path.join(pluginC, "mcp.json"), "{}"); + writeFileSync(path.join(pluginC, ".mcp.json"), "{}"); const selfRoot = tempDir("self-plugin"); writeFileSync(path.join(selfRoot, "mcp.json"), "{}"); + writeFileSync(path.join(selfRoot, ".mcp.json"), "{}"); const moduleUrl = pathToFileURL( path.join(selfRoot, "scripts", "cursor-mcp-json-discover.mjs"), ).href; const paths = discoverPluginMcpJsonPaths({ - home: "/unused", - env: { [CURSOR_CONFIG_DIR_ENV]: cursorDir }, + home, + env: {}, moduleUrl, }); assert.deepEqual( paths.sort(), - [path.join(pluginA, "mcp.json"), path.join(selfRoot, "mcp.json")].sort(), + [ + path.join(pluginA, "mcp.json"), + path.join(pluginB, ".mcp.json"), + path.join(pluginC, "mcp.json"), + path.join(pluginC, ".mcp.json"), + path.join(selfRoot, "mcp.json"), + path.join(selfRoot, ".mcp.json"), + ].sort(), ); }); @@ -172,25 +212,23 @@ test("discoverPluginMcpJsonPaths skips self when roots env overrides", () => { assert.deepEqual(paths, [path.join(override, "mcp.json")]); }); - -test("discoverCursorPluginRoots drops symlinks that escape CURSOR_CONFIG_DIR", () => { - const cursorDir = tempDir("cursor-symlink"); +test("discoverCursorPluginRoots drops symlinks that escape ~/.cursor", () => { + const { home, cursorDir } = tempHomeCursor("plugins", "local"); const outside = tempDir("outside-plugin"); const localDir = path.join(cursorDir, "plugins", "local"); - mkdirSync(localDir, { recursive: true }); const safe = path.join(localDir, "safe"); mkdirSync(safe, { recursive: true }); const evil = path.join(localDir, "evil-link"); symlinkSync(outside, evil); const roots = discoverCursorPluginRoots({ - home: "/unused", - env: { [CURSOR_CONFIG_DIR_ENV]: cursorDir }, + home, + env: {}, }); assert.deepEqual(roots, [safe]); }); -test("discoverCursorPluginRoots override roots are not confined to CURSOR_CONFIG_DIR", () => { +test("discoverCursorPluginRoots override roots are not confined to ~/.cursor", () => { const override = tempDir("override-outside"); const roots = discoverCursorPluginRoots({ home: "/unused", @@ -199,8 +237,9 @@ test("discoverCursorPluginRoots override roots are not confined to CURSOR_CONFIG assert.deepEqual(roots, [override]); }); -test("resolveRewriteAllowRoots includes cursor dir, overrides, plugin, targets", () => { - const cursorDir = "/tmp/cursor-cfg"; +test("resolveRewriteAllowRoots includes ~/.cursor, overrides, plugin, targets", () => { + const home = "/tmp/fake-home"; + const cursorDir = path.join(home, ".cursor"); const override = "/tmp/override"; const selfRoot = "/tmp/self-plugin"; const moduleUrl = pathToFileURL( @@ -209,8 +248,9 @@ test("resolveRewriteAllowRoots includes cursor dir, overrides, plugin, targets", const target = "/tmp/other-plugin/mcp.json"; const roots = resolveRewriteAllowRoots({ + home, env: { - [CURSOR_CONFIG_DIR_ENV]: cursorDir, + CURSOR_CONFIG_DIR: "/tmp/ignored-cursor-cfg", [ROOTS_ENV]: override, }, moduleUrl, From 01672b9a9eb740091b28e1810aa312aa7a3d332d Mon Sep 17 00:00:00 2001 From: arielam Date: Wed, 19 Aug 2026 10:38:56 +0300 Subject: [PATCH 4/4] MLD-1347 - Add message to user after proccess completed --- plugins/jfrog/README.md | 2 +- .../jfrog/modules/core/rewrite-mcp-json.mjs | 32 ++++++--- .../jfrog/scripts/cursor-align-mcp-json.mjs | 22 +++++- .../scripts/cursor-align-mcp-json.test.mjs | 72 +++++++++++++++++-- 4 files changed, 112 insertions(+), 16 deletions(-) diff --git a/plugins/jfrog/README.md b/plugins/jfrog/README.md index 282fdf4..d053e59 100644 --- a/plugins/jfrog/README.md +++ b/plugins/jfrog/README.md @@ -49,7 +49,7 @@ Agent Package Resolution is in preview and opt-in. To get started: ## Plugin MCP rewrite (Agent Guard) -On every Cursor agent `sessionStart`, the plugin discovers plugin `mcp.json` and `.mcp.json` files under `~/.cursor/plugins/local/*` and `~/.cursor/plugins/cache/*` (marketplace installs), plus this plugin's own configs, and runs `npx @jfrog/agent-guard --rewrite-mcp-json` against those paths. Cursor can load servers from both files when both exist. Stdio MCP entries are rewritten to launch through Agent Guard; remote `url` / `http` / `sse` / `ws` entries are left unchanged. Workspace and user-level `.cursor/mcp.json` files are **not** rewritten. If a file is rewritten, use **Developer: Reload Window** so Cursor reloads MCP config. +On every Cursor agent `sessionStart`, the plugin discovers plugin `mcp.json` and `.mcp.json` files under `~/.cursor/plugins/local/*` and `~/.cursor/plugins/cache/*` (marketplace installs), plus this plugin's own configs, and runs `npx @jfrog/agent-guard --rewrite-mcp-json` against those paths. Cursor can load servers from both files when both exist. Stdio MCP entries are rewritten to launch through Agent Guard; remote `url` / `http` / `sse` / `ws` entries are left unchanged. Workspace and user-level `.cursor/mcp.json` files are **not** rewritten. If a file is rewritten, the sessionStart hook asks you to **open a new session** so Cursor reconnects those MCPs. Marketplace installs under `~/.cursor/plugins/cache` are rewritten by default (opt out via env below). Auto-discovered roots must resolve under `~/.cursor` (symlink escapes are skipped); `JF_ALIGN_MCP_JSON_ROOTS` overrides are trusted as-is and skip this plugin's own `mcp.json` unless you list that root yourself. `CURSOR_CONFIG_DIR` (CLI config) is **not** used for plugin discovery — Cursor loads plugins from `~/.cursor` regardless. diff --git a/plugins/jfrog/modules/core/rewrite-mcp-json.mjs b/plugins/jfrog/modules/core/rewrite-mcp-json.mjs index 9bd236a..5170c8e 100644 --- a/plugins/jfrog/modules/core/rewrite-mcp-json.mjs +++ b/plugins/jfrog/modules/core/rewrite-mcp-json.mjs @@ -6,10 +6,11 @@ // // Usage (from a thin Cursor/Claude script next to synced modules/): // import { runRewriteMcpJsonPipeline } from "./modules/core/rewrite-mcp-json.mjs"; -// await runRewriteMcpJsonPipeline({ +// const { code, rewritten } = await runRewriteMcpJsonPipeline({ // discover: () => [...absoluteMcpJsonPaths], // allowRoots: [...], // }); +// // code is always 0 (soft-fail); rewritten > 0 when Agent Guard updated files. // // Kill switch: JF_AGENT_REWRITE_MCP_JSON_DISABLE=1 → soft no-op (exit 0). // Local binary: JFROG_AGENT_GUARD_BIN=/path/to/agent-guard (skips npx). @@ -608,10 +609,21 @@ export function redactUrlCredentials(text) { ); } +/** + * Soft-fail pipeline result. `code` is always 0 so harness hooks never break + * the session; `rewritten` is the Agent Guard summary count (0 on skips). + * @typedef {{ code: 0, rewritten: number }} RewritePipelineResult + */ + +/** @returns {RewritePipelineResult} */ +function pipelineOk(rewritten = 0) { + return { code: 0, rewritten: Number(rewritten) > 0 ? Number(rewritten) : 0 }; +} + /** * Orchestration: kill switch → discover → project/server → Step 0 gate → * rewrite. Server id is resolved once and reused for both the gate and AG - * `--server`. Always returns 0 (soft-fail). Harness adapters supply discovery + * `--server`. Always soft-fails (`code: 0`). Harness adapters supply discovery * + allow-roots. * * @param {{ @@ -627,7 +639,7 @@ export function redactUrlCredentials(text) { * readFileSyncFn?: typeof readFileSync, * serverIdHint?: string, * }} opts - * @returns {Promise} always 0 + * @returns {Promise} */ export async function runRewriteMcpJsonPipeline(opts) { const env = opts.env ?? process.env; @@ -635,7 +647,7 @@ export async function runRewriteMcpJsonPipeline(opts) { if (isRewriteDisabled(env)) { log.info("rewrite disabled via env", { env: DISABLE_ENV }); - return 0; + return pipelineOk(); } let paths; @@ -645,12 +657,12 @@ export async function runRewriteMcpJsonPipeline(opts) { log.error("discover failed; soft no-op", { error: err?.message ?? String(err), }); - return 0; + return pipelineOk(); } if (!Array.isArray(paths) || paths.length === 0) { log.info("no mcp.json files found; skip rewrite"); - return 0; + return pipelineOk(); } const project = resolveRewriteProject(env, { @@ -659,7 +671,7 @@ export async function runRewriteMcpJsonPipeline(opts) { }); if (!project) { log.info("rewrite skipped; missing JF_PROJECT", {}); - return 0; + return pipelineOk(); } const serverId = resolveRewriteServerId(env, { @@ -677,7 +689,7 @@ export async function runRewriteMcpJsonPipeline(opts) { code: gate.code, reason: gate.reason, }); - return 0; + return pipelineOk(); } const allowRoots = @@ -713,7 +725,7 @@ export async function runRewriteMcpJsonPipeline(opts) { stderr: redactUrlCredentials((result.stderr || "").trim()).slice(0, 500), durMs, }); - return 0; + return pipelineOk(); } const summary = parseRewriteMcpJsonResult(result.stdout); @@ -728,5 +740,5 @@ export async function runRewriteMcpJsonPipeline(opts) { log.info("rewrite-mcp-json ok; no JSON summary", { durMs }); } - return 0; + return pipelineOk(summary?.rewritten); } diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs index 5bd32a6..bb0091d 100644 --- a/plugins/jfrog/scripts/cursor-align-mcp-json.mjs +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs @@ -16,6 +16,8 @@ // // Kill switch: JF_AGENT_REWRITE_MCP_JSON_DISABLE=1 → no-op (exit 0). // Never exits non-zero — a failed rewrite must not break the Cursor session. +// When rewrite updates files, this hook emits additional_context asking the +// user to open a new session so Cursor reconnects those MCPs. import { existsSync, readdirSync, statSync } from "node:fs"; import process from "node:process"; @@ -38,6 +40,16 @@ export const RECOMMENDED_HOOK_TIMEOUT_SEC = 60; /** @type {ReadonlySet} */ export const MODES = Object.freeze(new Set(["session-start", "file-changed"])); +export const RECONNECT_HINT = + "JFrog Agent Guard secured your plugins' MCP servers. Open a new session to reconnect."; + +/** + * @returns {string} Cursor sessionStart stdout JSON payload + */ +export function buildReconnectPayload() { + return JSON.stringify({ additional_context: RECONNECT_HINT }); +} + /** * @param {string | undefined} modeArg * @returns {boolean} @@ -54,6 +66,7 @@ export function isKnownMode(modeArg) { * home?: string, * readStdinFn?: typeof readStdin, * runRewriteMcpJsonPipelineFn?: typeof runRewriteMcpJsonPipeline, + * writeStdout?: (s: string) => void, * readdirSyncFn?: typeof readdirSync, * existsSyncFn?: typeof existsSync, * statSyncFn?: typeof statSync, @@ -73,6 +86,7 @@ export async function runCursorAlignMcpJson(modeArg, deps = {}) { const readStdinFn = deps.readStdinFn ?? readStdin; const pipelineFn = deps.runRewriteMcpJsonPipelineFn ?? runRewriteMcpJsonPipeline; + const writeStdout = deps.writeStdout ?? ((s) => process.stdout.write(s)); const stdinRaw = await readStdinFn(); setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); @@ -90,7 +104,7 @@ export async function runCursorAlignMcpJson(modeArg, deps = {}) { const existsFn = deps.existsSyncFn ?? existsSync; - return pipelineFn({ + const result = await pipelineFn({ env, discover: () => { if (deps.mcpJsonPath) { @@ -120,6 +134,12 @@ export async function runCursorAlignMcpJson(modeArg, deps = {}) { runAgentGuardCheckFn: deps.runAgentGuardCheckFn, readFileSyncFn: deps.readFileSyncFn, }); + + if ((result?.rewritten ?? 0) > 0) { + writeStdout(buildReconnectPayload()); + } + + return 0; } async function main() { diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs index e38101a..e059029 100644 --- a/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs @@ -11,6 +11,8 @@ import { fileURLToPath } from "node:url"; import { ROOTS_ENV } from "./cursor-mcp-json-discover.mjs"; import { + RECONNECT_HINT, + buildReconnectPayload, isKnownMode, RECOMMENDED_HOOK_TIMEOUT_SEC, runCursorAlignMcpJson, @@ -61,21 +63,38 @@ test("isKnownMode accepts session-start and file-changed", () => { assert.equal(isKnownMode(undefined), false); }); +test("buildReconnectPayload uses additional_context with approved wording", () => { + const payload = JSON.parse(buildReconnectPayload()); + assert.equal(payload.additional_context, RECONNECT_HINT); + assert.match( + payload.additional_context, + /JFrog Agent Guard secured your plugins' MCP servers/, + ); + assert.match(payload.additional_context, /Open a new session to reconnect/); + assert.equal(payload.hookSpecificOutput, undefined); +}); + test("runCursorAlignMcpJson no-ops on unknown mode", async () => { let called = false; + let stdout = ""; const code = await runCursorAlignMcpJson("nope", { readStdinFn: async () => "", runRewriteMcpJsonPipelineFn: async () => { called = true; - return 0; + return { code: 0, rewritten: 0 }; + }, + writeStdout: (s) => { + stdout += s; }, }); assert.equal(code, 0); assert.equal(called, false); + assert.equal(stdout, ""); }); test("runCursorAlignMcpJson no-ops when harness is not cursor", async () => { let called = false; + let stdout = ""; const code = await runCursorAlignMcpJson("session-start", { readStdinFn: async () => JSON.stringify({ @@ -85,11 +104,15 @@ test("runCursorAlignMcpJson no-ops when harness is not cursor", async () => { }), runRewriteMcpJsonPipelineFn: async () => { called = true; - return 0; + return { code: 0, rewritten: 0 }; + }, + writeStdout: (s) => { + stdout += s; }, }); assert.equal(code, 0); assert.equal(called, false); + assert.equal(stdout, ""); }); test("runCursorAlignMcpJson passes discovered paths to shared pipeline", async () => { @@ -102,6 +125,7 @@ test("runCursorAlignMcpJson passes discovered paths to shared pipeline", async ( /** @type {{ paths?: string[], allowRoots?: string[] }} */ const captured = {}; + let stdout = ""; const code = await runCursorAlignMcpJson("session-start", { home, env: { @@ -117,7 +141,10 @@ test("runCursorAlignMcpJson passes discovered paths to shared pipeline", async ( typeof opts.allowRoots === "function" ? opts.allowRoots(paths) : opts.allowRoots; - return 0; + return { code: 0, rewritten: 0 }; + }, + writeStdout: (s) => { + stdout += s; }, }); @@ -125,6 +152,7 @@ test("runCursorAlignMcpJson passes discovered paths to shared pipeline", async ( assert.deepEqual(captured.paths, [mcpPath]); assert.ok(captured.allowRoots?.includes(cursorDir)); assert.ok(captured.allowRoots?.includes(pluginA)); + assert.equal(stdout, ""); }); test("runCursorAlignMcpJson respects mcpJsonPath override", async () => { @@ -138,9 +166,45 @@ test("runCursorAlignMcpJson respects mcpJsonPath override", async () => { readStdinFn: async () => "", runRewriteMcpJsonPipelineFn: async (opts) => { paths = await opts.discover(); - return 0; + return { code: 0, rewritten: 0 }; }, + writeStdout: () => {}, }); assert.equal(code, 0); assert.deepEqual(paths, [file]); }); + +test("runCursorAlignMcpJson emits reconnect hint when rewritten > 0", async () => { + let stdout = ""; + const code = await runCursorAlignMcpJson("session-start", { + readStdinFn: async () => + JSON.stringify({ session_id: "s1", cursor_version: "1.0.0" }), + runRewriteMcpJsonPipelineFn: async () => ({ code: 0, rewritten: 1 }), + writeStdout: (s) => { + stdout += s; + }, + }); + assert.equal(code, 0); + const payload = JSON.parse(stdout); + assert.equal(payload.additional_context, RECONNECT_HINT); + assert.match( + payload.additional_context, + /JFrog Agent Guard secured your plugins' MCP servers/, + ); + assert.match(payload.additional_context, /Open a new session to reconnect/); + assert.doesNotMatch(payload.additional_context, /\/reload-plugins/); +}); + +test("runCursorAlignMcpJson does not emit when rewritten is 0", async () => { + let stdout = ""; + const code = await runCursorAlignMcpJson("session-start", { + readStdinFn: async () => + JSON.stringify({ session_id: "s1", cursor_version: "1.0.0" }), + runRewriteMcpJsonPipelineFn: async () => ({ code: 0, rewritten: 0 }), + writeStdout: (s) => { + stdout += s; + }, + }); + assert.equal(code, 0); + assert.equal(stdout, ""); +});