Skip to content

Commit 5948dae

Browse files
feat(mcp): JFrog Platform remote MCP (token auth) + install hint (#19)
* feat(mcp): register JFrog Platform remote MCP (token auth) In the same config hook that registers the vendored skills, inject config.mcp.jfrog as an OpenCode remote MCP at https://<host>/mcp when both a host (JFROG_URL / JF_URL / JFROG_PLATFORM_URL) and a token env (JFROG_ACCESS_TOKEN / JF_ACCESS_TOKEN) are present and JFROG_MCP_DISABLE is not set. - Token auth, headless: oauth:false + Authorization: "Bearer {env:<TOKEN_VAR>}". The {env:} reference keeps the raw token out of the config (logs/state/rotation). - Idempotent and non-destructive: never clobbers a user-defined mcp.jfrog. - URL normalization (strip scheme + trailing slash); pure sync mutation, no network on load. Skips cleanly (log only) when unconfigured. - Tests cover the full matrix; README documents setup, the JWT-token requirement, and the JFROG_MCP_DISABLE opt-out. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(mcp): document the JFrog MCP per-request context cost Default-on for parity with the Cursor/Claude plugins; note the ~32K (OpenCode) / ~44K (Cursor) per-request tool-schema cost and the JFROG_MCP_DISABLE / tools scoping escape hatches. Skills do not carry this cost. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(mcp): resolved-token auth, install-only hint, functional refactor + tests - Inject the resolved Bearer token directly (OpenCode does not expand {env:} in plugin-injected config), so the JFrog MCP actually authenticates headlessly. - Just-in-time install-jf hint via tool.execute.before; MCP setup issues (missing env, non-JWT token, 401) are surfaced by OpenCode's mcp list/TUI and the README, with a debug WARNING for non-JWT tokens. - Functional refactor (pure helpers) + unit tests for registration, gating, idempotency, host/token precedence + normalization, and the install hint. - README: JFrog MCP prerequisites, token handling, context cost, 401 troubleshooting. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): address review — cross-platform PATH, scheme preservation, toast dedup, skills/MCP decoupling - commandExists: use path.delimiter and probe Windows .exe/.cmd/.bat - MCP base URL: preserve explicit http://, default https://, strip trailing slash - skills-not-found error toast/log deduped via module-level guard (once per session) - config hook registers skills and MCP unconditionally (independent features) Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0e511c0 commit 5948dae

3 files changed

Lines changed: 517 additions & 54 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,41 @@ OpenCode then discovers the skills the same way it discovers any skill — they
6464
tool and `/skills`, and the agent invokes them when relevant. There is no runtime download, unzip, or
6565
network call on load.
6666

67+
## JFrog Platform MCP
68+
69+
When the environment is configured, the plugin also registers the **JFrog Platform remote MCP server**
70+
(`https://<JFROG_URL>/mcp`) into `config.mcp.jfrog`, so the JFrog platform tools appear in OpenCode
71+
alongside the skills.
72+
73+
**Prerequisites — both must be set:**
74+
75+
- `JFROG_URL` — your JFrog platform URL (e.g. `https://mycompany.jfrog.io`). The legacy `JF_URL` and the
76+
`JFROG_PLATFORM_URL` (Cursor-compat) names are also accepted.
77+
- `JFROG_ACCESS_TOKEN` — a **JWT access token** created with `jf access-token-create` (or the legacy
78+
`JF_ACCESS_TOKEN`). This **must be a JWT access token, not a 64-character reference token** — reference
79+
tokens are rejected by the `/mcp` endpoint.
80+
81+
The MCP is authenticated with the token directly (`Authorization: Bearer …`, `oauth: false`), so it works
82+
headlessly with no interactive browser sign-in. Registration is a pure config mutation — there is no
83+
network call on plugin load.
84+
85+
**Opt-out:** set `JFROG_MCP_DISABLE=true` to skip MCP registration entirely. You can also scope the
86+
exposed tools via OpenCode's `tools` globbing. If you define your own `mcp.jfrog` server in your config,
87+
the plugin leaves it untouched.
88+
89+
**Context cost:** the JFrog MCP exposes ~56 tools whose schemas are loaded into the model context on
90+
every request (OpenCode has no lazy tool loading), measured at roughly **+32K tokens per request** in
91+
OpenCode (~44K in Cursor). The MCP is enabled by default for parity with the JFrog Cursor/Claude plugins;
92+
if that overhead matters for your workflow, disable it with `JFROG_MCP_DISABLE=true` or narrow the
93+
surface with `tools` globbing. The bundled **skills** do not carry this cost — only their short
94+
descriptions stay in context, and a skill's body loads only when it is invoked.
95+
96+
**Token handling:** OpenCode does not expand `{env:…}` placeholders in config that a plugin injects at
97+
runtime, so the plugin reads `JFROG_ACCESS_TOKEN` from the environment and sets the resolved
98+
`Authorization: Bearer <token>` header directly. The token therefore lives in the in-memory session
99+
config (sourced from your environment); the plugin itself never writes it to disk. Prefer a short-lived
100+
token (`jf atc --expiry=…`).
101+
67102
## Updating the bundled skills
68103

69104
The skills are vendored at a pinned version. Updating them is a build-time step and **requires a new
@@ -83,6 +118,12 @@ Logs are written to `<project-root>/.opencode/event-log.txt`.
83118
If you see a **"bundled skills not found"** error (a toast in the TUI and/or an `ERROR` line in the log),
84119
the installed package is incomplete or corrupted — reinstall `@jfrog/opencode-jfrog-plugin`.
85120

121+
If the JFrog MCP shows **`401` / an SSE error** in `opencode mcp list` (or the TUI), the `/mcp` endpoint
122+
rejected the token. Make sure `JFROG_ACCESS_TOKEN` is a **JWT** access token (`jf atc`), not a 64-char
123+
reference token, and that it was issued for the same platform as `JFROG_URL` (check `jf c show`). MCP
124+
connection status is surfaced by OpenCode itself — this plugin only registers the server. With
125+
`JFROG_DEBUG_LOGS=true`, a non-JWT token also produces a `WARNING` line in the event log.
126+
86127
## Upgrading from < 0.0.3
87128

88129
This release changes behavior in ways that are **not** backward compatible:

src/index.test.ts

Lines changed: 290 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
// (c) JFrog Ltd. (2026)
2-
import { describe, it, expect, mock } from 'bun:test';
3-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
3+
import {
4+
chmodSync,
5+
existsSync,
6+
mkdtempSync,
7+
readFileSync,
8+
readdirSync,
9+
rmSync,
10+
statSync,
11+
writeFileSync,
12+
} from 'node:fs';
13+
import { tmpdir } from 'node:os';
414
import { dirname, join } from 'node:path';
515
import { fileURLToPath } from 'node:url';
616
import type { Config, PluginInput } from '@opencode-ai/plugin';
@@ -30,17 +40,43 @@ function skillsOf(config: Config): { paths?: string[] } | undefined {
3040
return (config as { skills?: { paths?: string[] } }).skills;
3141
}
3242

43+
type McpEntry = {
44+
type?: string;
45+
url?: string;
46+
oauth?: boolean;
47+
enabled?: boolean;
48+
headers?: Record<string, string>;
49+
};
50+
51+
function mcpOf(config: Config): Record<string, McpEntry> | undefined {
52+
return (config as { mcp?: Record<string, McpEntry> }).mcp;
53+
}
54+
55+
function toastCount(client: PluginInput['client'], substr: string): number {
56+
const showToast = client.tui.showToast as unknown as ReturnType<typeof mock>;
57+
return showToast.mock.calls.filter((args) =>
58+
String((args[0] as { body?: { message?: string } })?.body?.message ?? '').includes(substr)
59+
).length;
60+
}
61+
62+
async function runBash(hooks: Awaited<ReturnType<typeof server>>, command: string): Promise<void> {
63+
await hooks['tool.execute.before']?.(
64+
{ tool: 'bash', sessionID: 's', callID: 'c' } as never,
65+
{ args: { command } } as never
66+
);
67+
}
68+
3369
describe('jfrog opencode plugin exports', () => {
3470
it('exposes the same plugin as server and JfrogOpencodePlugin', () => {
3571
expect(server).toBe(JfrogOpencodePlugin);
3672
});
3773
});
3874

3975
describe('JfrogOpencodePlugin config hook', () => {
40-
it('returns only a config hook (no event hook)', async () => {
76+
it('returns config and tool.execute.before hooks', async () => {
4177
const hooks = await server(pluginInput());
4278
expect(hooks.config).toBeDefined();
43-
expect((hooks as { event?: unknown }).event).toBeUndefined();
79+
expect(hooks['tool.execute.before']).toBeDefined();
4480
});
4581

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

65-
it('shows the `jf setup` nudge only once across multiple config calls', async () => {
101+
it('does not toast a setup hint from the config hook', async () => {
66102
const client = createClient();
67103
const hooks = await server(pluginInput(client));
68-
const config = {} as Config;
69-
await hooks.config?.(config);
70-
await hooks.config?.(config);
71-
const showToast = client.tui.showToast as unknown as ReturnType<typeof mock>;
72-
const nudges = showToast.mock.calls.filter((args) => {
73-
const message = (args[0] as { body?: { message?: string } })?.body?.message ?? '';
74-
return message.includes('jf setup');
75-
});
76-
expect(nudges.length).toBe(1);
104+
await hooks.config?.({} as Config);
105+
expect(toastCount(client, 'JFrog:')).toBe(0);
106+
});
107+
});
108+
109+
// Just-in-time setup hints surfaced from the tool hook on the first `jf` command.
110+
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+
];
120+
let saved: Record<string, string | undefined>;
121+
let bin: string | undefined;
122+
123+
beforeEach(() => {
124+
saved = {};
125+
for (const key of ENV_KEYS) {
126+
saved[key] = process.env[key];
127+
}
128+
// Default scenario: jf absent + MCP env absent.
129+
process.env.PATH = '';
130+
for (const key of ENV_KEYS.slice(1)) {
131+
delete process.env[key];
132+
}
133+
});
134+
135+
afterEach(() => {
136+
for (const key of ENV_KEYS) {
137+
const value = saved[key];
138+
if (value === undefined) {
139+
delete process.env[key];
140+
} else {
141+
process.env[key] = value;
142+
}
143+
}
144+
if (bin) {
145+
rmSync(bin, { recursive: true, force: true });
146+
bin = undefined;
147+
}
148+
});
149+
150+
function installJf(): void {
151+
bin = mkdtempSync(join(tmpdir(), 'jfbin-'));
152+
const jfPath = join(bin, 'jf');
153+
writeFileSync(jfPath, '#!/bin/sh\n');
154+
chmodSync(jfPath, 0o755);
155+
process.env.PATH = bin;
156+
}
157+
158+
it('hints to install the CLI when `jf` is missing (on a jf command)', async () => {
159+
const client = createClient();
160+
const hooks = await server(pluginInput(client));
161+
await runBash(hooks, 'jf rt ping');
162+
expect(toastCount(client, 'was not found on your PATH')).toBe(1);
163+
});
164+
165+
it('shows NO hint when `jf` is present (MCP setup is surfaced by OpenCode + README, not toasts)', async () => {
166+
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';
170+
const client = createClient();
171+
const hooks = await server(pluginInput(client));
172+
await runBash(hooks, 'jf rt ping');
173+
expect(toastCount(client, 'JFrog:')).toBe(0);
174+
});
175+
176+
it('shows only the install hint when `jf` is absent, regardless of MCP env', async () => {
177+
// 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';
180+
const client = createClient();
181+
const hooks = await server(pluginInput(client));
182+
await runBash(hooks, 'jf rt ping');
183+
expect(toastCount(client, 'was not found on your PATH')).toBe(1);
184+
expect(toastCount(client, 'JFrog:')).toBe(1);
185+
});
186+
187+
it('shows at most one hint per session', async () => {
188+
const client = createClient();
189+
const hooks = await server(pluginInput(client));
190+
await runBash(hooks, 'jf rt ping');
191+
await runBash(hooks, 'jf c show');
192+
expect(toastCount(client, 'JFrog:')).toBe(1);
193+
});
194+
195+
it('does not hint for non-jf bash commands', async () => {
196+
const client = createClient();
197+
const hooks = await server(pluginInput(client));
198+
await runBash(hooks, 'npm install lodash');
199+
await runBash(hooks, 'echo jfrog'); // `jf` is not a standalone command here
200+
expect(toastCount(client, 'JFrog:')).toBe(0);
201+
});
202+
203+
it('does not hint for non-bash tools', async () => {
204+
const client = createClient();
205+
const hooks = await server(pluginInput(client));
206+
await hooks['tool.execute.before']?.(
207+
{ tool: 'read', sessionID: 's', callID: 'c' } as never,
208+
{ args: { command: 'jf rt ping' } } as never
209+
);
210+
expect(toastCount(client, 'JFrog:')).toBe(0);
77211
});
78212
});
79213

@@ -111,3 +245,145 @@ describe('vendored skills content sanity (V9)', () => {
111245
});
112246
}
113247
});
248+
249+
// JFrog Platform remote MCP injection via the config hook (token auth, headless).
250+
describe('JfrogOpencodePlugin JFrog remote MCP injection', () => {
251+
const ENV_KEYS = [
252+
'JFROG_URL',
253+
'JF_URL',
254+
'JFROG_PLATFORM_URL',
255+
'JFROG_ACCESS_TOKEN',
256+
'JF_ACCESS_TOKEN',
257+
'JFROG_MCP_DISABLE',
258+
];
259+
let savedEnv: Record<string, string | undefined>;
260+
261+
beforeEach(() => {
262+
savedEnv = {};
263+
for (const key of ENV_KEYS) {
264+
savedEnv[key] = process.env[key];
265+
delete process.env[key];
266+
}
267+
});
268+
269+
afterEach(() => {
270+
for (const key of ENV_KEYS) {
271+
const value = savedEnv[key];
272+
if (value === undefined) {
273+
delete process.env[key];
274+
} else {
275+
process.env[key] = value;
276+
}
277+
}
278+
});
279+
280+
async function runConfig(): Promise<Config> {
281+
const hooks = await server(pluginInput());
282+
const config = {} as Config;
283+
await hooks.config?.(config);
284+
return config;
285+
}
286+
287+
it('injects a remote jfrog MCP when JFROG_URL + JFROG_ACCESS_TOKEN are set', async () => {
288+
process.env.JFROG_URL = 'https://example.jfrog.io';
289+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
290+
const jfrog = mcpOf(await runConfig())?.jfrog;
291+
expect(jfrog).toBeDefined();
292+
expect(jfrog?.type).toBe('remote');
293+
expect(jfrog?.url).toBe('https://example.jfrog.io/mcp');
294+
expect(jfrog?.oauth).toBe(false);
295+
expect(jfrog?.enabled).toBe(true);
296+
});
297+
298+
it('injects the resolved token into the Authorization header', async () => {
299+
// OpenCode does not expand {env:} in plugin-injected config, so the token value is materialized.
300+
process.env.JFROG_URL = 'https://example.jfrog.io';
301+
process.env.JFROG_ACCESS_TOKEN = 'eyJresolvedtokenvalue';
302+
const jfrog = mcpOf(await runConfig())?.jfrog;
303+
expect(jfrog?.headers?.Authorization).toBe('Bearer eyJresolvedtokenvalue');
304+
});
305+
306+
it('normalizes scheme and trailing slash in the host', async () => {
307+
process.env.JFROG_URL = 'https://x.jfrog.io/';
308+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
309+
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://x.jfrog.io/mcp');
310+
});
311+
312+
it('preserves an explicit http:// scheme (no silent https upgrade)', async () => {
313+
process.env.JFROG_URL = 'http://internal.corp/';
314+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
315+
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('http://internal.corp/mcp');
316+
});
317+
318+
it('defaults to https:// when the host omits a scheme', async () => {
319+
process.env.JFROG_URL = 'bare.jfrog.io';
320+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
321+
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://bare.jfrog.io/mcp');
322+
});
323+
324+
it('accepts the legacy JF_URL host name', async () => {
325+
process.env.JF_URL = 'legacy.jfrog.io';
326+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
327+
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://legacy.jfrog.io/mcp');
328+
});
329+
330+
it('accepts the cursor-compat JFROG_PLATFORM_URL host name', async () => {
331+
process.env.JFROG_PLATFORM_URL = 'cursor.jfrog.io';
332+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
333+
expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://cursor.jfrog.io/mcp');
334+
});
335+
336+
it('uses the legacy JF_ACCESS_TOKEN value when only it is set', async () => {
337+
process.env.JFROG_URL = 'https://example.jfrog.io';
338+
process.env.JF_ACCESS_TOKEN = 'eyJlegacytokenvalue';
339+
const auth = mcpOf(await runConfig())?.jfrog?.headers?.Authorization;
340+
expect(auth).toBe('Bearer eyJlegacytokenvalue');
341+
});
342+
343+
it('skips injection when the host is missing', async () => {
344+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
345+
expect(mcpOf(await runConfig())).toBeUndefined();
346+
});
347+
348+
it('skips injection when the token is missing', async () => {
349+
process.env.JFROG_URL = 'https://example.jfrog.io';
350+
expect(mcpOf(await runConfig())).toBeUndefined();
351+
});
352+
353+
it('registers the MCP even when the token is not a JWT (shape only warns, never gates)', async () => {
354+
process.env.JFROG_URL = 'https://example.jfrog.io';
355+
process.env.JFROG_ACCESS_TOKEN = 'reference-token-not-a-jwt';
356+
const jfrog = mcpOf(await runConfig())?.jfrog;
357+
expect(jfrog).toBeDefined();
358+
expect(jfrog?.url).toBe('https://example.jfrog.io/mcp');
359+
expect(jfrog?.headers?.Authorization).toBe('Bearer reference-token-not-a-jwt');
360+
});
361+
362+
it('skips injection when JFROG_MCP_DISABLE=true', async () => {
363+
process.env.JFROG_URL = 'https://example.jfrog.io';
364+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
365+
process.env.JFROG_MCP_DISABLE = 'true';
366+
expect(mcpOf(await runConfig())).toBeUndefined();
367+
});
368+
369+
it('does not overwrite a user-defined jfrog MCP entry', async () => {
370+
process.env.JFROG_URL = 'https://example.jfrog.io';
371+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
372+
const existing: McpEntry = { type: 'remote', url: 'https://user.example/mcp', enabled: false };
373+
const hooks = await server(pluginInput());
374+
const config = { mcp: { jfrog: existing } } as unknown as Config;
375+
await hooks.config?.(config);
376+
expect(mcpOf(config)?.jfrog).toEqual(existing);
377+
});
378+
379+
it('is idempotent across repeated config calls', async () => {
380+
process.env.JFROG_URL = 'https://example.jfrog.io';
381+
process.env.JFROG_ACCESS_TOKEN = 'jwt-token';
382+
const hooks = await server(pluginInput());
383+
const config = {} as Config;
384+
await hooks.config?.(config);
385+
const first = mcpOf(config)?.jfrog;
386+
await hooks.config?.(config);
387+
expect(mcpOf(config)?.jfrog).toEqual(first);
388+
});
389+
});

0 commit comments

Comments
 (0)