Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"metadata": {
"description": "JFrog Platform plugins for Cursor",
"version": "0.5.17",
"version": "0.5.18",
"pluginRoot": "plugins"
},
"plugins": [
Expand Down
55 changes: 53 additions & 2 deletions .github/scripts/sync-modules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..", "..");
Expand All @@ -24,6 +29,37 @@ async function fileExists(p) {
}
}

/**
* @param {string} destRoot
* @param {string[]} keepRels — paths relative to destRoot
* @returns {Promise<Map<string, Buffer>>}
*/
export async function stashKeepFiles(destRoot, keepRels) {
/** @type {Map<string, Buffer>} */
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<string, Buffer>} 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);
Expand All @@ -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() ||
Expand All @@ -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();
}
31 changes: 31 additions & 0 deletions .github/scripts/sync-modules.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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");
});
4 changes: 3 additions & 1 deletion VENDOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ 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`).

## 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` from `sync-modules-vendor.json` (today: `paths: ["modules"]`) and optional `dest_prefix` / `keep`. It copies those paths into the destination (default: repo root) and restores any `keep` files that existed before the sync.
2 changes: 1 addition & 1 deletion plugins/jfrog/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jfrog",
"displayName": "JFrog Platform",
"version": "0.5.17",
"version": "0.5.18",
"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",
Expand Down
20 changes: 20 additions & 0 deletions plugins/jfrog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -46,6 +47,25 @@ Agent Package Resolution is in preview. The shipped template enables it with emp
- **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 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.

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` | Replace discovery roots entirely (POSIX `:`/`,`; Windows `;`/`,`). Does not auto-include this plugin's own `mcp.json` |
| `JF_ALIGN_MCP_JSON_SKIP_CACHE=1` | Skip `~/.cursor/plugins/cache` (marketplace installs; scanned by default) |

## MCP Capabilities

The JFrog MCP Server provides:
Expand Down
4 changes: 4 additions & 0 deletions plugins/jfrog/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": 60
}
]
}
Expand Down
155 changes: 155 additions & 0 deletions plugins/jfrog/scripts/cursor-align-mcp-json.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/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.
// 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";

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 = 60;

/** @type {ReadonlySet<string>} */
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}
*/
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,
* writeStdout?: (s: string) => void,
* 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<number>} 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 writeStdout = deps.writeStdout ?? ((s) => process.stdout.write(s));

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;

const result = await 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,
});

if (result?.outcome === "rewritten") {
writeStdout(buildReconnectPayload());
}

return 0;
}

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);
});
}
Loading
Loading