Skip to content

Commit 30787c3

Browse files
authored
AX-2073 - Change jfrog mcp (#29)
1 parent 7ec9222 commit 30787c3

3 files changed

Lines changed: 52 additions & 143 deletions

File tree

README.md

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The JFrog plugin provides the following capabilities, grouped by component:
1111

1212
| Component | Feature | Description |
1313
| --- | --- | --- |
14-
| **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`. |
14+
| **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`. |
1515
| **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. |
1616
| **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. |
1717
| **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. |
@@ -26,7 +26,7 @@ plugin version.
2626

2727
Before installing, make sure you have:
2828

29-
- **JFrog host URL and access token** — A [JFrog Platform](https://jfrog.com) instance you can authenticate against.
29+
- **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).
3030
- **OpenCode** — Installed (verified against OpenCode **1.17.7** and newer, which honors `config.skills.paths` in object form).
3131
- **Node.js** (≥ 18) — with `npx` on your `PATH` (used by the Agent Guard).
3232
- **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).
@@ -108,25 +108,30 @@ this machine:
108108

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

111-
The JFrog Platform MCP server authenticates separately from an access token — see
112-
below.
111+
The JFrog Platform MCP server authenticates separately, via OAuth — see below.
113112

114113
---
115114

116115
## JFrog Platform MCP server
117116

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

122-
**Prerequisitesboth must be set:**
121+
**Prerequisiteset the JFrog host:**
123122

124-
- `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.
125-
- `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.
123+
- `JFROG_PLATFORM_URL` — your JFrog platform host, e.g. `mycompany.jfrog.io` (bare host, no scheme — `https://` is added automatically).
126124

127-
The MCP is authenticated with the token directly (`Authorization: Bearer …`, `oauth: false`),
128-
so it works headlessly with no interactive browser sign-in. Registration is a pure
129-
config mutation — there is no network call on plugin load.
125+
**Authentication is OAuth only.** The plugin registers the entry with OAuth
126+
auto-detection enabled. OpenCode discovers the OAuth authorization server advertised
127+
by the `/mcp` endpoint, and you sign in once through the browser:
128+
129+
```bash
130+
opencode mcp auth jfrog # runs the OAuth flow and stores the tokens
131+
opencode mcp list # jfrog should now show as connected
132+
```
133+
134+
Related commands: `opencode mcp logout jfrog` and `opencode mcp debug jfrog`.
130135

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

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

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

205204
- **"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`.
206-
- **`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.
205+
- **`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.
207206

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

src/index.test.ts

Lines changed: 20 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,7 @@ describe('JfrogOpencodePlugin config hook', () => {
108108

109109
// Just-in-time setup hints surfaced from the tool hook on the first `jf` command.
110110
describe('JFrog setup hints (tool.execute.before)', () => {
111-
const ENV_KEYS = [
112-
'PATH',
113-
'JFROG_URL',
114-
'JF_URL',
115-
'JFROG_PLATFORM_URL',
116-
'JFROG_ACCESS_TOKEN',
117-
'JF_ACCESS_TOKEN',
118-
'JFROG_MCP_DISABLE',
119-
];
111+
const ENV_KEYS = ['PATH', 'JFROG_PLATFORM_URL', 'JFROG_MCP_DISABLE'];
120112
let saved: Record<string, string | undefined>;
121113
let bin: string | undefined;
122114

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

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

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

256-
// JFrog Platform remote MCP injection via the config hook (token auth, headless).
244+
// JFrog Platform remote MCP injection via the config hook (OAuth only — no token, no headers).
257245
describe('JfrogOpencodePlugin JFrog remote MCP injection', () => {
258-
const ENV_KEYS = [
259-
'JFROG_URL',
260-
'JF_URL',
261-
'JFROG_PLATFORM_URL',
262-
'JFROG_ACCESS_TOKEN',
263-
'JF_ACCESS_TOKEN',
264-
'JFROG_MCP_DISABLE',
265-
];
246+
const ENV_KEYS = ['JFROG_PLATFORM_URL', 'JFROG_MCP_DISABLE'];
266247
let savedEnv: Record<string, string | undefined>;
267248

268249
beforeEach(() => {
@@ -291,91 +272,42 @@ describe('JfrogOpencodePlugin JFrog remote MCP injection', () => {
291272
return config;
292273
}
293274

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

305-
it('injects the resolved token into the Authorization header', async () => {
306-
// OpenCode does not expand {env:} in plugin-injected config, so the token value is materialized.
307-
process.env.JFROG_URL = 'https://example.jfrog.io';
308-
process.env.JFROG_ACCESS_TOKEN = 'eyJresolvedtokenvalue';
309-
const jfrog = mcpOf(await runConfig())?.jfrog;
310-
expect(jfrog?.headers?.Authorization).toBe('Bearer eyJresolvedtokenvalue');
311-
});
312-
313-
it('normalizes scheme and trailing slash in the host', async () => {
314-
process.env.JFROG_URL = 'https://x.jfrog.io/';
315-
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
316-
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://x.jfrog.io/mcp');
317-
});
318-
319-
it('preserves an explicit http:// scheme (no silent https upgrade)', async () => {
320-
process.env.JFROG_URL = 'http://internal.corp/';
321-
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
322-
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('http://internal.corp/mcp');
323-
});
324-
325-
it('defaults to https:// when the host omits a scheme', async () => {
326-
process.env.JFROG_URL = 'bare.jfrog.io';
327-
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
289+
it('defaults to https:// for a bare host (the expected JFROG_PLATFORM_URL form)', async () => {
290+
process.env.JFROG_PLATFORM_URL = 'bare.jfrog.io';
328291
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://bare.jfrog.io/mcp');
329292
});
330293

331-
it('accepts the legacy JF_URL host name', async () => {
332-
process.env.JF_URL = 'legacy.jfrog.io';
333-
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
334-
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://legacy.jfrog.io/mcp');
335-
});
336-
337-
it('accepts the cursor-compat JFROG_PLATFORM_URL host name', async () => {
338-
process.env.JFROG_PLATFORM_URL = 'cursor.jfrog.io';
339-
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
340-
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://cursor.jfrog.io/mcp');
341-
});
342-
343-
it('uses the legacy JF_ACCESS_TOKEN value when only it is set', async () => {
344-
process.env.JFROG_URL = 'https://example.jfrog.io';
345-
process.env.JF_ACCESS_TOKEN = 'eyJlegacytokenvalue';
346-
const auth = mcpOf(await runConfig())?.jfrog?.headers?.Authorization;
347-
expect(auth).toBe('Bearer eyJlegacytokenvalue');
294+
it('tolerates an explicit scheme and a trailing slash', async () => {
295+
process.env.JFROG_PLATFORM_URL = 'https://x.jfrog.io/';
296+
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://x.jfrog.io/mcp');
348297
});
349298

350299
it('skips injection when the host is missing', async () => {
351-
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
352-
expect(mcpOf(await runConfig())).toBeUndefined();
353-
});
354-
355-
it('skips injection when the token is missing', async () => {
356-
process.env.JFROG_URL = 'https://example.jfrog.io';
357300
expect(mcpOf(await runConfig())).toBeUndefined();
358301
});
359302

360-
it('registers the MCP even when the token is not a JWT (shape only warns, never gates)', async () => {
361-
process.env.JFROG_URL = 'https://example.jfrog.io';
362-
process.env.JFROG_ACCESS_TOKEN = 'reference-token-not-a-jwt';
363-
const jfrog = mcpOf(await runConfig())?.jfrog;
364-
expect(jfrog).toBeDefined();
365-
expect(jfrog?.url).toBe('https://example.jfrog.io/mcp');
366-
expect(jfrog?.headers?.Authorization).toBe('Bearer reference-token-not-a-jwt');
367-
});
368-
369303
it('skips injection when JFROG_MCP_DISABLE=true', async () => {
370-
process.env.JFROG_URL = 'https://example.jfrog.io';
371-
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
304+
process.env.JFROG_PLATFORM_URL = 'example.jfrog.io';
372305
process.env.JFROG_MCP_DISABLE = 'true';
373306
expect(mcpOf(await runConfig())).toBeUndefined();
374307
});
375308

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

386318
it('is idempotent across repeated config calls', async () => {
387-
process.env.JFROG_URL = 'https://example.jfrog.io';
388-
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
319+
process.env.JFROG_PLATFORM_URL = 'example.jfrog.io';
389320
const hooks = await server(pluginInput());
390321
const config = {} as Config;
391322
await hooks.config?.(config);

src/index.ts

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@ const LOG_FILE = join(process.cwd(), '.opencode', 'event-log.txt');
1919
// Works for both src/index.ts (dev) and dist/index.js (installed): `..` lands on skills/ in both.
2020
const BUNDLED_SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
2121

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

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

4140
// ── Pure helpers ────────────────────────────────────────────────────────────────
@@ -68,9 +67,6 @@ const commandExists = (cmd: string): boolean => {
6867
);
6968
};
7069

71-
const firstDefinedEnv = (names: readonly string[]): string | undefined =>
72-
names.map((name) => process.env[name]).find((value) => !!value);
73-
7470
// Preserve an explicit http/https scheme (default https when none); strip trailing slashes.
7571
const toBaseUrl = (raw: string): string => {
7672
const trimmed = raw.replace(/\/+$/, '');
@@ -79,34 +75,25 @@ const toBaseUrl = (raw: string): string => {
7975

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

82-
/** JFrog JWT access tokens are base64url JWTs that begin with `eyJ`; reference tokens do not. */
83-
const looksLikeJwt = (token: string): boolean => token.startsWith('eyJ');
84-
8578
/**
86-
* Resolve the JFrog MCP host + token env var name from the environment.
87-
* Returns undefined when MCP is disabled or either value is absent.
79+
* Resolve the JFrog MCP host from `JFROG_PLATFORM_URL`.
80+
* Returns undefined when MCP is disabled or the host is absent.
8881
*/
8982
const resolveMcpCredentials = (): McpCredentials | undefined => {
9083
if (process.env.JFROG_MCP_DISABLE === 'true') {
9184
return undefined;
9285
}
93-
const host = firstDefinedEnv(HOST_ENV_VARS);
94-
const tokenVar = TOKEN_ENV_VARS.find((name) => process.env[name]);
95-
return host && tokenVar ? { baseUrl: toBaseUrl(host), tokenVar } : undefined;
86+
const host = process.env[HOST_ENV_VAR];
87+
return host ? { baseUrl: toBaseUrl(host) } : undefined;
9688
};
9789

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

@@ -144,25 +131,17 @@ const registerMcp = (cfg: ConfigWithJfrog, log: Logger): void => {
144131
const credentials = resolveMcpCredentials();
145132
if (!credentials) {
146133
log(
147-
'mcp: jfrog remote MCP not registered (need JFROG_URL + JFROG_ACCESS_TOKEN; or JFROG_MCP_DISABLE=true)'
134+
'mcp: jfrog remote MCP not registered (need JFROG_PLATFORM_URL; or JFROG_MCP_DISABLE=true)'
148135
);
149136
return;
150137
}
151138

152-
const token = process.env[credentials.tokenVar] ?? '';
153-
if (!looksLikeJwt(token)) {
154-
log(
155-
`mcp: WARNING ${credentials.tokenVar} does not look like a JWT access token; the MCP will likely ` +
156-
'fail with HTTP 401. Create one with `jf atc` (a reference token will not work).'
157-
);
158-
}
159-
160139
cfg.mcp = cfg.mcp ?? {};
161140
if (cfg.mcp.jfrog) {
162141
return;
163142
}
164-
cfg.mcp.jfrog = mcpServerEntry(credentials, token);
165-
log(`mcp: registered jfrog remote MCP at ${credentials.baseUrl}/mcp`);
143+
cfg.mcp.jfrog = mcpServerEntry(credentials);
144+
log(`mcp: registered jfrog remote MCP (OAuth) at ${credentials.baseUrl}/mcp`);
166145
};
167146

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

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

0 commit comments

Comments
 (0)