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' ;
414import { dirname , join } from 'node:path' ;
515import { fileURLToPath } from 'node:url' ;
616import 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+
3369describe ( 'jfrog opencode plugin exports' , ( ) => {
3470 it ( 'exposes the same plugin as server and JfrogOpencodePlugin' , ( ) => {
3571 expect ( server ) . toBe ( JfrogOpencodePlugin ) ;
3672 } ) ;
3773} ) ;
3874
3975describe ( '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