Skip to content
Open
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
8 changes: 7 additions & 1 deletion .github/scripts/sync-modules-vendor.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
{
"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"
],
"keep": [
"modules/core/rewrite-mcp-json.mjs",
"modules/core/agent-guard-check.mjs",
"modules/core/entry.mjs"
]
}
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();
}
32 changes: 32 additions & 0 deletions .github/scripts/sync-modules.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
6 changes: 5 additions & 1 deletion VENDOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). 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

```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`, `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.
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 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 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
Loading
Loading