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
39 changes: 19 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The JFrog plugin provides the following capabilities, grouped by component:

| Component | Feature | Description |
| --- | --- | --- |
| **MCP** | JFrog Platform MCP server | Registers the remote JFrog Platform MCP (`https://<JFROG_URL>/mcp`, token auth) into OpenCode's `config.mcp.jfrog`. Opt out with `JFROG_MCP_DISABLE=true`. |
| **MCP** | JFrog Platform MCP server | Registers the remote JFrog Platform MCP (`https://${JFROG_PLATFORM_URL}/mcp`, OAuth) into OpenCode's `config.mcp.jfrog`. Authenticate once with `opencode mcp auth jfrog`. Opt out with `JFROG_MCP_DISABLE=true`. |
| **Skill** | JFrog Platform | Interact with Artifactory repositories, builds, permissions, users, access tokens, projects, release bundles, and platform administration via the JFrog CLI and REST/GraphQL APIs. Also covers security audits, CVE lookups, and Advanced Security exposure queries. |
| **Skill** | Package safety & download | Check whether npm, Maven, PyPI, Go, and other packages are safe, curated, or allowed, then download them through Artifactory remote caches or curation-aware package managers. |
| **Skill** | Agent Guard | OpenCode manages MCPs through the JFrog Agent Guard. Discover, install, configure, update, and remove MCP servers from the JFrog AI Catalog approved for your project, and authenticate to remote HTTP MCPs via OAuth, API key, or bearer token. |
Expand All @@ -26,7 +26,7 @@ plugin version.

Before installing, make sure you have:

- **JFrog host URL and access token** — A [JFrog Platform](https://jfrog.com) instance you can authenticate against.
- **JFrog host** — A [JFrog Platform](https://jfrog.com) instance you can authenticate against, exposed to the plugin as `JFROG_PLATFORM_URL` (e.g. `mycompany.jfrog.io`). The JFrog Platform MCP server authenticates via OAuth (browser sign-in).
- **OpenCode** — Installed (verified against OpenCode **1.17.7** and newer, which honors `config.skills.paths` in object form).
- **Node.js** (≥ 18) — with `npx` on your `PATH` (used by the Agent Guard).
- **Skill runtime requirements** — `jf` CLI, `jq`, and `curl` on `PATH`, plus a configured JFrog CLI server. For the minimum versions, see the upstream skills [`Requirements`](https://github.com/jfrog/jfrog-skills/blob/v0.22.0/README.md#requirements). Configure the CLI with `jf login` / `jf config add` — see [Authentication](#authentication).
Expand Down Expand Up @@ -108,25 +108,30 @@ this machine:

3. Follow the interactive prompts to enter your JFrog platform URL and access token.

The JFrog Platform MCP server authenticates separately from an access token — see
below.
The JFrog Platform MCP server authenticates separately, via OAuth — see below.

---

## JFrog Platform MCP server

When the environment is configured, the plugin registers the **JFrog Platform remote
MCP server** (`https://<JFROG_URL>/mcp`) into `config.mcp.jfrog`, so the JFrog platform
tools appear in OpenCode alongside the skills.
When `JFROG_PLATFORM_URL` is set, the plugin registers the **JFrog Platform remote MCP
server** (`https://${JFROG_PLATFORM_URL}/mcp`) into `config.mcp.jfrog`, so the JFrog
platform tools appear in OpenCode alongside the skills.

**Prerequisitesboth must be set:**
**Prerequisiteset the JFrog host:**

- `JFROG_URL` — your JFrog platform URL (e.g. `https://mycompany.jfrog.io`). The legacy `JF_URL` and the `JFROG_PLATFORM_URL` (Cursor-compat) names are also accepted.
- `JFROG_ACCESS_TOKEN` — a **JWT access token** created with `jf access-token-create` (or the legacy `JF_ACCESS_TOKEN`). This **must be a JWT access token, not a 64-character reference token** — reference tokens are rejected by the `/mcp` endpoint.
- `JFROG_PLATFORM_URL` — your JFrog platform host, e.g. `mycompany.jfrog.io` (bare host, no scheme — `https://` is added automatically).

The MCP is authenticated with the token directly (`Authorization: Bearer …`, `oauth: false`),
so it works headlessly with no interactive browser sign-in. Registration is a pure
config mutation — there is no network call on plugin load.
**Authentication is OAuth only.** The plugin registers the entry with OAuth
auto-detection enabled. OpenCode discovers the OAuth authorization server advertised
by the `/mcp` endpoint, and you sign in once through the browser:

```bash
opencode mcp auth jfrog # runs the OAuth flow and stores the tokens
opencode mcp list # jfrog should now show as connected
```

Related commands: `opencode mcp logout jfrog` and `opencode mcp debug jfrog`.

**Opt-out:** set `JFROG_MCP_DISABLE=true` to skip MCP registration entirely. You can
also scope the exposed tools via OpenCode's `tools` globbing. If you define your own
Expand All @@ -139,12 +144,6 @@ model context on every request (OpenCode has no lazy tool loading), measured at
**skills** do not carry this cost — only their short descriptions stay in context, and a
skill's body loads only when it is invoked.

**Token handling:** OpenCode does not expand `{env:…}` placeholders in config that a
plugin injects at runtime, so the plugin reads `JFROG_ACCESS_TOKEN` from the environment
and sets the resolved `Authorization: Bearer <token>` header directly. The token
therefore lives in the in-memory session config (sourced from your environment); the
plugin itself never writes it to disk. Prefer a short-lived token (`jf atc --expiry=…`).

---

## Usage
Expand Down Expand Up @@ -203,7 +202,7 @@ export JFROG_DEBUG_LOGS=true
Logs are written to `<project-root>/.opencode/event-log.txt`.

- **"bundled skills not found"** (a toast in the TUI and/or an `ERROR` line in the log) — the installed package is incomplete or corrupted; reinstall `@jfrog/opencode-jfrog-plugin`.
- **`401` / SSE error** for the JFrog MCP in `opencode mcp list` (or the TUI) — the `/mcp` endpoint rejected the token. Make sure `JFROG_ACCESS_TOKEN` is a **JWT** access token (`jf atc`), not a 64-char reference token, and that it was issued for the same platform as `JFROG_URL` (check `jf c show`). With `JFROG_DEBUG_LOGS=true`, a non-JWT token also produces a `WARNING` line in the event log.
- **`401` / SSE error** for the JFrog MCP in `opencode mcp list` (or the TUI) — you have not completed the OAuth sign-in, or the stored OAuth session expired. Run `opencode mcp auth jfrog` to (re)authenticate, and confirm `JFROG_PLATFORM_URL` points at the platform you signed in to. Use `opencode mcp debug jfrog` to inspect the OAuth connection, or `opencode mcp logout jfrog` to clear a stale session and re-auth.

For MCP-registry issues, see the [JFrog MCP Registry troubleshooting guide](https://docs.jfrog.com/ai-ml/docs/mcp-registry-troubleshooting).

Expand Down
109 changes: 20 additions & 89 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,7 @@ describe('JfrogOpencodePlugin config hook', () => {

// Just-in-time setup hints surfaced from the tool hook on the first `jf` command.
describe('JFrog setup hints (tool.execute.before)', () => {
const ENV_KEYS = [
'PATH',
'JFROG_URL',
'JF_URL',
'JFROG_PLATFORM_URL',
'JFROG_ACCESS_TOKEN',
'JF_ACCESS_TOKEN',
'JFROG_MCP_DISABLE',
];
const ENV_KEYS = ['PATH', 'JFROG_PLATFORM_URL', 'JFROG_MCP_DISABLE'];
let saved: Record<string, string | undefined>;
let bin: string | undefined;

Expand Down Expand Up @@ -164,19 +156,15 @@ describe('JFrog setup hints (tool.execute.before)', () => {

it('shows NO hint when `jf` is present (MCP setup is surfaced by OpenCode + README, not toasts)', async () => {
installJf();
// Even a non-JWT token / missing env produces no toast — those are not the plugin's concern now.
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'cmVmdGtuOnJlZmVyZW5jZQ';
// With `jf` present the plugin surfaces no setup toast — MCP setup is OpenCode's concern, not ours.
const client = createClient();
const hooks = await server(pluginInput(client));
await runBash(hooks, 'jf rt ping');
expect(toastCount(client, 'JFrog:')).toBe(0);
});

it('shows only the install hint when `jf` is absent, regardless of MCP env', async () => {
it('shows the install hint when `jf` is absent', async () => {
// PATH='' (jf absent) is the describe default.
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'eyJhbGciOiJSUzI1NiJ9.payload.sig';
const client = createClient();
const hooks = await server(pluginInput(client));
await runBash(hooks, 'jf rt ping');
Expand Down Expand Up @@ -253,16 +241,9 @@ describe('vendored skills content sanity (V9)', () => {
}
});

// JFrog Platform remote MCP injection via the config hook (token auth, headless).
// JFrog Platform remote MCP injection via the config hook (OAuth only — no token, no headers).
describe('JfrogOpencodePlugin JFrog remote MCP injection', () => {
const ENV_KEYS = [
'JFROG_URL',
'JF_URL',
'JFROG_PLATFORM_URL',
'JFROG_ACCESS_TOKEN',
'JF_ACCESS_TOKEN',
'JFROG_MCP_DISABLE',
];
const ENV_KEYS = ['JFROG_PLATFORM_URL', 'JFROG_MCP_DISABLE'];
let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
Expand Down Expand Up @@ -291,91 +272,42 @@ describe('JfrogOpencodePlugin JFrog remote MCP injection', () => {
return config;
}

it('injects a remote jfrog MCP when JFROG_URL + JFROG_ACCESS_TOKEN are set', async () => {
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
it('injects an OAuth remote jfrog MCP when JFROG_PLATFORM_URL is set', async () => {
process.env.JFROG_PLATFORM_URL = 'example.jfrog.io';
// A token in the env must be ignored — the plugin no longer does Bearer auth.
process.env.JFROG_ACCESS_TOKEN = 'eyJshouldbeignored';
const jfrog = mcpOf(await runConfig())?.jfrog;
expect(jfrog).toBeDefined();
expect(jfrog?.type).toBe('remote');
expect(jfrog?.url).toBe('https://example.jfrog.io/mcp');
expect(jfrog?.oauth).toBe(false);
expect(jfrog?.enabled).toBe(true);
// OAuth: no Authorization header, and oauth auto-detection is left enabled (never set to false).
expect(jfrog?.headers).toBeUndefined();
expect(jfrog?.oauth).toBeUndefined();
});

it('injects the resolved token into the Authorization header', async () => {
// OpenCode does not expand {env:} in plugin-injected config, so the token value is materialized.
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'eyJresolvedtokenvalue';
const jfrog = mcpOf(await runConfig())?.jfrog;
expect(jfrog?.headers?.Authorization).toBe('Bearer eyJresolvedtokenvalue');
});

it('normalizes scheme and trailing slash in the host', async () => {
process.env.JFROG_URL = 'https://x.jfrog.io/';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://x.jfrog.io/mcp');
});

it('preserves an explicit http:// scheme (no silent https upgrade)', async () => {
process.env.JFROG_URL = 'http://internal.corp/';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('http://internal.corp/mcp');
});

it('defaults to https:// when the host omits a scheme', async () => {
process.env.JFROG_URL = 'bare.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
it('defaults to https:// for a bare host (the expected JFROG_PLATFORM_URL form)', async () => {
process.env.JFROG_PLATFORM_URL = 'bare.jfrog.io';
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://bare.jfrog.io/mcp');
});

it('accepts the legacy JF_URL host name', async () => {
process.env.JF_URL = 'legacy.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://legacy.jfrog.io/mcp');
});

it('accepts the cursor-compat JFROG_PLATFORM_URL host name', async () => {
process.env.JFROG_PLATFORM_URL = 'cursor.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://cursor.jfrog.io/mcp');
});

it('uses the legacy JF_ACCESS_TOKEN value when only it is set', async () => {
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JF_ACCESS_TOKEN = 'eyJlegacytokenvalue';
const auth = mcpOf(await runConfig())?.jfrog?.headers?.Authorization;
expect(auth).toBe('Bearer eyJlegacytokenvalue');
it('tolerates an explicit scheme and a trailing slash', async () => {
process.env.JFROG_PLATFORM_URL = 'https://x.jfrog.io/';
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://x.jfrog.io/mcp');
});

it('skips injection when the host is missing', async () => {
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
expect(mcpOf(await runConfig())).toBeUndefined();
});

it('skips injection when the token is missing', async () => {
process.env.JFROG_URL = 'https://example.jfrog.io';
expect(mcpOf(await runConfig())).toBeUndefined();
});

it('registers the MCP even when the token is not a JWT (shape only warns, never gates)', async () => {
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'reference-token-not-a-jwt';
const jfrog = mcpOf(await runConfig())?.jfrog;
expect(jfrog).toBeDefined();
expect(jfrog?.url).toBe('https://example.jfrog.io/mcp');
expect(jfrog?.headers?.Authorization).toBe('Bearer reference-token-not-a-jwt');
});

it('skips injection when JFROG_MCP_DISABLE=true', async () => {
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
process.env.JFROG_PLATFORM_URL = 'example.jfrog.io';
process.env.JFROG_MCP_DISABLE = 'true';
expect(mcpOf(await runConfig())).toBeUndefined();
});

it('does not overwrite a user-defined jfrog MCP entry', async () => {
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
process.env.JFROG_PLATFORM_URL = 'example.jfrog.io';
const existing: McpEntry = { type: 'remote', url: 'https://user.example/mcp', enabled: false };
const hooks = await server(pluginInput());
const config = { mcp: { jfrog: existing } } as unknown as Config;
Expand All @@ -384,8 +316,7 @@ describe('JfrogOpencodePlugin JFrog remote MCP injection', () => {
});

it('is idempotent across repeated config calls', async () => {
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
process.env.JFROG_PLATFORM_URL = 'example.jfrog.io';
const hooks = await server(pluginInput());
const config = {} as Config;
await hooks.config?.(config);
Expand Down
47 changes: 13 additions & 34 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ const LOG_FILE = join(process.cwd(), '.opencode', 'event-log.txt');
// Works for both src/index.ts (dev) and dist/index.js (installed): `..` lands on skills/ in both.
const BUNDLED_SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');

// Env var names, in precedence order (new JFROG_* first, then legacy JF_* / Cursor's JFROG_PLATFORM_URL).
const HOST_ENV_VARS = ['JFROG_URL', 'JF_URL', 'JFROG_PLATFORM_URL'] as const;
const TOKEN_ENV_VARS = ['JFROG_ACCESS_TOKEN', 'JF_ACCESS_TOKEN'] as const;
// Env var holding the JFrog platform host for the MCP URL, e.g. `mycompany.jfrog.io`
const HOST_ENV_VAR = 'JFROG_PLATFORM_URL';

const JF_CLI_INSTALL_HINT =
'JFrog: the `jf` CLI was not found on your PATH. Install it ' +
Expand All @@ -35,7 +34,7 @@ type ConfigWithJfrog = Config & {
skills?: { paths?: string[] };
mcp?: Record<string, unknown>;
};
type McpCredentials = { baseUrl: string; tokenVar: string };
type McpCredentials = { baseUrl: string };
type McpServer = NonNullable<Config['mcp']>[string];

// ── Pure helpers ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -68,9 +67,6 @@ const commandExists = (cmd: string): boolean => {
);
};

const firstDefinedEnv = (names: readonly string[]): string | undefined =>
names.map((name) => process.env[name]).find((value) => !!value);

// Preserve an explicit http/https scheme (default https when none); strip trailing slashes.
const toBaseUrl = (raw: string): string => {
const trimmed = raw.replace(/\/+$/, '');
Expand All @@ -79,34 +75,25 @@ const toBaseUrl = (raw: string): string => {

const isJfCommand = (command: string): boolean => /(?:^|[\s;&|(])jf(?:\s|$)/.test(command);

/** JFrog JWT access tokens are base64url JWTs that begin with `eyJ`; reference tokens do not. */
const looksLikeJwt = (token: string): boolean => token.startsWith('eyJ');

/**
* Resolve the JFrog MCP host + token env var name from the environment.
* Returns undefined when MCP is disabled or either value is absent.
* Resolve the JFrog MCP host from `JFROG_PLATFORM_URL`.
* Returns undefined when MCP is disabled or the host is absent.
*/
const resolveMcpCredentials = (): McpCredentials | undefined => {
if (process.env.JFROG_MCP_DISABLE === 'true') {
return undefined;
}
const host = firstDefinedEnv(HOST_ENV_VARS);
const tokenVar = TOKEN_ENV_VARS.find((name) => process.env[name]);
return host && tokenVar ? { baseUrl: toBaseUrl(host), tokenVar } : undefined;
const host = process.env[HOST_ENV_VAR];
return host ? { baseUrl: toBaseUrl(host) } : undefined;
};

/**
* Build the OpenCode remote-MCP entry with the resolved Bearer token.
* Build the OpenCode remote-MCP entry for the JFrog Platform MCP (OAuth only).
*
* Note: OpenCode does NOT expand `{env:...}` in config injected by a plugin at runtime (it only
* templates values loaded from opencode.json), so the token value must be materialized here. It comes
* from the user's own environment and is used in-memory for the connection.
*/
const mcpServerEntry = ({ baseUrl }: McpCredentials, token: string): McpServer => ({
const mcpServerEntry = ({ baseUrl }: McpCredentials): McpServer => ({
type: 'remote',
url: `${baseUrl}/mcp`,
oauth: false,
headers: { Authorization: `Bearer ${token}` },
enabled: true,
});

Expand Down Expand Up @@ -144,25 +131,17 @@ const registerMcp = (cfg: ConfigWithJfrog, log: Logger): void => {
const credentials = resolveMcpCredentials();
if (!credentials) {
log(
'mcp: jfrog remote MCP not registered (need JFROG_URL + JFROG_ACCESS_TOKEN; or JFROG_MCP_DISABLE=true)'
'mcp: jfrog remote MCP not registered (need JFROG_PLATFORM_URL; or JFROG_MCP_DISABLE=true)'
);
return;
}

const token = process.env[credentials.tokenVar] ?? '';
if (!looksLikeJwt(token)) {
log(
`mcp: WARNING ${credentials.tokenVar} does not look like a JWT access token; the MCP will likely ` +
'fail with HTTP 401. Create one with `jf atc` (a reference token will not work).'
);
}

cfg.mcp = cfg.mcp ?? {};
if (cfg.mcp.jfrog) {
return;
}
cfg.mcp.jfrog = mcpServerEntry(credentials, token);
log(`mcp: registered jfrog remote MCP at ${credentials.baseUrl}/mcp`);
cfg.mcp.jfrog = mcpServerEntry(credentials);
log(`mcp: registered jfrog remote MCP (OAuth) at ${credentials.baseUrl}/mcp`);
};

// ── Plugin ────────────────────────────────────────────────────────────────────
Expand All @@ -189,7 +168,7 @@ const jfrogOpencodePlugin: Plugin = async ({ client }) => {
log('JfrogOpencodePlugin starting...');

// Detect the JFrog CLI ONCE at load (cached for the session) so the per-tool hook stays a cheap
// boolean check. MCP setup issues (missing env, bad/non-JWT token, 401) are surfaced by OpenCode's
// boolean check. MCP setup issues (missing env, OAuth not completed, 401) are surfaced by OpenCode's
// own `mcp list`/TUI and documented in the README — the plugin does not nag for those.
const hasJfCli = commandExists('jf');
log('jf CLI on PATH: ' + hasJfCli);
Expand Down
Loading