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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,41 @@ OpenCode then discovers the skills the same way it discovers any skill — they
tool and `/skills`, and the agent invokes them when relevant. There is no runtime download, unzip, or
network call on load.

## JFrog Platform MCP

When the environment is configured, the plugin also 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.

**Prerequisites — both must be set:**

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

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.

**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 `mcp.jfrog` server in your config,
the plugin leaves it untouched.

**Context cost:** the JFrog MCP exposes ~56 tools whose schemas are loaded into the model context on
every request (OpenCode has no lazy tool loading), measured at roughly **+32K tokens per request** in
OpenCode (~44K in Cursor). The MCP is enabled by default for parity with the JFrog Cursor/Claude plugins;
if that overhead matters for your workflow, disable it with `JFROG_MCP_DISABLE=true` or narrow the
surface with `tools` globbing. The bundled **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=…`).

## Updating the bundled skills

The skills are vendored at a pinned version. Updating them is a build-time step and **requires a new
Expand All @@ -83,6 +118,12 @@ Logs are written to `<project-root>/.opencode/event-log.txt`.
If you see a **"bundled skills not found"** error (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`.

If the JFrog MCP shows **`401` / an SSE error** 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`). MCP
connection status is surfaced by OpenCode itself — this plugin only registers the server. With
`JFROG_DEBUG_LOGS=true`, a non-JWT token also produces a `WARNING` line in the event log.

## Upgrading from < 0.0.3

This release changes behavior in ways that are **not** backward compatible:
Expand Down
304 changes: 290 additions & 14 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
// (c) JFrog Ltd. (2026)
import { describe, it, expect, mock } from 'bun:test';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Config, PluginInput } from '@opencode-ai/plugin';
Expand Down Expand Up @@ -30,17 +40,43 @@ function skillsOf(config: Config): { paths?: string[] } | undefined {
return (config as { skills?: { paths?: string[] } }).skills;
}

type McpEntry = {
type?: string;
url?: string;
oauth?: boolean;
enabled?: boolean;
headers?: Record<string, string>;
};

function mcpOf(config: Config): Record<string, McpEntry> | undefined {
return (config as { mcp?: Record<string, McpEntry> }).mcp;
}

function toastCount(client: PluginInput['client'], substr: string): number {
const showToast = client.tui.showToast as unknown as ReturnType<typeof mock>;
return showToast.mock.calls.filter((args) =>
String((args[0] as { body?: { message?: string } })?.body?.message ?? '').includes(substr)
).length;
}

async function runBash(hooks: Awaited<ReturnType<typeof server>>, command: string): Promise<void> {
await hooks['tool.execute.before']?.(
{ tool: 'bash', sessionID: 's', callID: 'c' } as never,
{ args: { command } } as never
);
}

describe('jfrog opencode plugin exports', () => {
it('exposes the same plugin as server and JfrogOpencodePlugin', () => {
expect(server).toBe(JfrogOpencodePlugin);
});
});

describe('JfrogOpencodePlugin config hook', () => {
it('returns only a config hook (no event hook)', async () => {
it('returns config and tool.execute.before hooks', async () => {
const hooks = await server(pluginInput());
expect(hooks.config).toBeDefined();
expect((hooks as { event?: unknown }).event).toBeUndefined();
expect(hooks['tool.execute.before']).toBeDefined();
});

it('adds the bundled skills dir to config.skills.paths (object form)', async () => {
Expand All @@ -62,18 +98,116 @@ describe('JfrogOpencodePlugin config hook', () => {
expect(bundled.length).toBe(1);
});

it('shows the `jf setup` nudge only once across multiple config calls', async () => {
it('does not toast a setup hint from the config hook', async () => {
const client = createClient();
const hooks = await server(pluginInput(client));
const config = {} as Config;
await hooks.config?.(config);
await hooks.config?.(config);
const showToast = client.tui.showToast as unknown as ReturnType<typeof mock>;
const nudges = showToast.mock.calls.filter((args) => {
const message = (args[0] as { body?: { message?: string } })?.body?.message ?? '';
return message.includes('jf setup');
});
expect(nudges.length).toBe(1);
await hooks.config?.({} as Config);
expect(toastCount(client, 'JFrog:')).toBe(0);
});
});

// 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',
];
let saved: Record<string, string | undefined>;
let bin: string | undefined;

beforeEach(() => {
saved = {};
for (const key of ENV_KEYS) {
saved[key] = process.env[key];
}
// Default scenario: jf absent + MCP env absent.
process.env.PATH = '';
for (const key of ENV_KEYS.slice(1)) {
delete process.env[key];
}
});

afterEach(() => {
for (const key of ENV_KEYS) {
const value = saved[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
if (bin) {
rmSync(bin, { recursive: true, force: true });
bin = undefined;
}
});

function installJf(): void {
bin = mkdtempSync(join(tmpdir(), 'jfbin-'));
const jfPath = join(bin, 'jf');
writeFileSync(jfPath, '#!/bin/sh\n');
chmodSync(jfPath, 0o755);
process.env.PATH = bin;
}

it('hints to install the CLI when `jf` is missing (on a jf command)', async () => {
const client = createClient();
const hooks = await server(pluginInput(client));
await runBash(hooks, 'jf rt ping');
expect(toastCount(client, 'was not found on your PATH')).toBe(1);
});

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';
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 () => {
// 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');
expect(toastCount(client, 'was not found on your PATH')).toBe(1);
expect(toastCount(client, 'JFrog:')).toBe(1);
});

it('shows at most one hint per session', async () => {
const client = createClient();
const hooks = await server(pluginInput(client));
await runBash(hooks, 'jf rt ping');
await runBash(hooks, 'jf c show');
expect(toastCount(client, 'JFrog:')).toBe(1);
});

it('does not hint for non-jf bash commands', async () => {
const client = createClient();
const hooks = await server(pluginInput(client));
await runBash(hooks, 'npm install lodash');
await runBash(hooks, 'echo jfrog'); // `jf` is not a standalone command here
expect(toastCount(client, 'JFrog:')).toBe(0);
});

it('does not hint for non-bash tools', async () => {
const client = createClient();
const hooks = await server(pluginInput(client));
await hooks['tool.execute.before']?.(
{ tool: 'read', sessionID: 's', callID: 'c' } as never,
{ args: { command: 'jf rt ping' } } as never
);
expect(toastCount(client, 'JFrog:')).toBe(0);
});
});

Expand Down Expand Up @@ -111,3 +245,145 @@ describe('vendored skills content sanity (V9)', () => {
});
}
});

// JFrog Platform remote MCP injection via the config hook (token auth, headless).
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',
];
let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
savedEnv = {};
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});

afterEach(() => {
for (const key of ENV_KEYS) {
const value = savedEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});

async function runConfig(): Promise<Config> {
const hooks = await server(pluginInput());
const config = {} as Config;
await hooks.config?.(config);
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';
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);
});

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';
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('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_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';
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;
await hooks.config?.(config);
expect(mcpOf(config)?.jfrog).toEqual(existing);
});

it('is idempotent across repeated config calls', async () => {
process.env.JFROG_URL = 'https://example.jfrog.io';
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
const hooks = await server(pluginInput());
const config = {} as Config;
await hooks.config?.(config);
const first = mcpOf(config)?.jfrog;
await hooks.config?.(config);
expect(mcpOf(config)?.jfrog).toEqual(first);
});
});
Loading
Loading