From 4b080e3c3bbed6359e1f1b64a9a1a5335f15c64e Mon Sep 17 00:00:00 2001 From: Michae Touito Date: Wed, 24 Jun 2026 09:58:21 +0300 Subject: [PATCH 1/4] 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:///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:}". 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 --- README.md | 26 +++++++++ src/index.test.ts | 136 +++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 39 ++++++++++++- 3 files changed, 198 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e6c6481..a90783c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,32 @@ 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:///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. + +**Security:** the token is referenced indirectly via OpenCode's `{env:JFROG_ACCESS_TOKEN}` substitution — +the raw token value is never written into the config object, so it stays out of logs and serialized +state and survives rotation. + ## Updating the bundled skills The skills are vendored at a pinned version. Updating them is a build-time step and **requires a new diff --git a/src/index.test.ts b/src/index.test.ts index d820f64..f16af8e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,5 @@ // (c) JFrog Ltd. (2026) -import { describe, it, expect, mock } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -30,6 +30,18 @@ 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; +}; + +function mcpOf(config: Config): Record | undefined { + return (config as { mcp?: Record }).mcp; +} + describe('jfrog opencode plugin exports', () => { it('exposes the same plugin as server and JfrogOpencodePlugin', () => { expect(server).toBe(JfrogOpencodePlugin); @@ -111,3 +123,125 @@ 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; + + 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 { + 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('references the token via {env:} and never embeds the raw token value', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'SUPER_SECRET_TOKEN_VALUE'; + const config = await runConfig(); + expect(mcpOf(config)?.jfrog?.headers?.Authorization).toBe('Bearer {env:JFROG_ACCESS_TOKEN}'); + expect(JSON.stringify(config)).not.toContain('SUPER_SECRET_TOKEN_VALUE'); + }); + + 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('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 {env:JF_ACCESS_TOKEN} when only the legacy token name is set', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JF_ACCESS_TOKEN = 'jwt-token'; + expect(mcpOf(await runConfig())?.jfrog?.headers?.Authorization).toBe( + 'Bearer {env:JF_ACCESS_TOKEN}' + ); + }); + + 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('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); + }); +}); diff --git a/src/index.ts b/src/index.ts index 8ab46f8..a85aa4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,10 @@ const LOG_FILE = join(process.cwd(), '.opencode', 'event-log.txt'); const BUNDLED_SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills'); type Logger = (_message: string) => void; -type ConfigWithSkills = Config & { skills?: { paths?: string[] } }; +type ConfigWithJfrog = Config & { + skills?: { paths?: string[] }; + mcp?: Record; +}; const isNonEmptyDir = (dir: string): boolean => { if (!existsSync(dir)) { @@ -48,7 +51,7 @@ const jfrogOpencodePlugin: Plugin = async ({ client }) => { return { config: async (config) => { - const cfg = config as ConfigWithSkills; + const cfg = config as ConfigWithJfrog; cfg.skills = cfg.skills ?? {}; cfg.skills.paths = cfg.skills.paths ?? []; @@ -67,6 +70,38 @@ const jfrogOpencodePlugin: Plugin = async ({ client }) => { } log('config.skills.paths=' + JSON.stringify(cfg.skills.paths)); + // Register the JFrog Platform remote MCP (token auth, headless). Pure config mutation: no + // network on load. The token is referenced via {env:} so the raw value never enters the config. + const rawHost = process.env.JFROG_URL ?? process.env.JF_URL ?? process.env.JFROG_PLATFORM_URL; + const tokenVar = process.env.JFROG_ACCESS_TOKEN + ? 'JFROG_ACCESS_TOKEN' + : process.env.JF_ACCESS_TOKEN + ? 'JF_ACCESS_TOKEN' + : undefined; + const mcpDisabled = process.env.JFROG_MCP_DISABLE === 'true'; + + if (rawHost && tokenVar && !mcpDisabled) { + const host = rawHost.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + const url = `https://${host}/mcp`; + cfg.mcp = cfg.mcp ?? {}; + // Non-destructive: never clobber a user-defined `jfrog` MCP server. + if (!cfg.mcp.jfrog) { + cfg.mcp.jfrog = { + type: 'remote', + url, + oauth: false, + headers: { Authorization: `Bearer {env:${tokenVar}}` }, + enabled: true, + }; + log('mcp: registered jfrog remote MCP at ' + url); + } + } else { + log( + 'mcp: jfrog remote MCP not registered ' + + '(need JFROG_URL + JFROG_ACCESS_TOKEN; or JFROG_MCP_DISABLE set)' + ); + } + // R2 interim nudge until package-manager setup is handled by a skill. if (!nudgeShown) { nudgeShown = true; From 3cf6b18afddb6f033a043cc2b7777965d359a5b5 Mon Sep 17 00:00:00 2001 From: Michae Touito Date: Thu, 25 Jun 2026 10:44:42 +0300 Subject: [PATCH 2/4] 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 --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index a90783c..7136013 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,13 @@ network call on plugin load. 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. + **Security:** the token is referenced indirectly via OpenCode's `{env:JFROG_ACCESS_TOKEN}` substitution — the raw token value is never written into the config object, so it stays out of logs and serialized state and survives rotation. From 6281adca71d00115944d55271829c0cc20050962 Mon Sep 17 00:00:00 2001 From: Michae Touito Date: Sun, 28 Jun 2026 12:32:06 +0300 Subject: [PATCH 3/4] 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 --- README.md | 14 ++- src/index.test.ts | 176 ++++++++++++++++++++++++++++++----- src/index.ts | 232 ++++++++++++++++++++++++++++++++-------------- 3 files changed, 328 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 7136013..f8d4457 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,11 @@ if that overhead matters for your workflow, disable it with `JFROG_MCP_DISABLE=t 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. -**Security:** the token is referenced indirectly via OpenCode's `{env:JFROG_ACCESS_TOKEN}` substitution — -the raw token value is never written into the config object, so it stays out of logs and serialized -state and survives rotation. +**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 ` 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 @@ -116,6 +118,12 @@ Logs are written to `/.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: diff --git a/src/index.test.ts b/src/index.test.ts index f16af8e..d738f18 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,16 @@ // (c) JFrog Ltd. (2026) import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +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'; @@ -42,6 +52,20 @@ function mcpOf(config: Config): Record | undefined { return (config as { mcp?: Record }).mcp; } +function toastCount(client: PluginInput['client'], substr: string): number { + const showToast = client.tui.showToast as unknown as ReturnType; + return showToast.mock.calls.filter((args) => + String((args[0] as { body?: { message?: string } })?.body?.message ?? '').includes(substr) + ).length; +} + +async function runBash(hooks: Awaited>, command: string): Promise { + 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); @@ -49,10 +73,10 @@ describe('jfrog opencode plugin exports', () => { }); 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 () => { @@ -74,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; - 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; + 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); }); }); @@ -173,12 +295,12 @@ describe('JfrogOpencodePlugin JFrog remote MCP injection', () => { expect(jfrog?.enabled).toBe(true); }); - it('references the token via {env:} and never embeds the raw token value', async () => { + 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 = 'SUPER_SECRET_TOKEN_VALUE'; - const config = await runConfig(); - expect(mcpOf(config)?.jfrog?.headers?.Authorization).toBe('Bearer {env:JFROG_ACCESS_TOKEN}'); - expect(JSON.stringify(config)).not.toContain('SUPER_SECRET_TOKEN_VALUE'); + 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 () => { @@ -199,12 +321,11 @@ describe('JfrogOpencodePlugin JFrog remote MCP injection', () => { expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://cursor.jfrog.io/mcp'); }); - it('uses {env:JF_ACCESS_TOKEN} when only the legacy token name is set', async () => { + 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 = 'jwt-token'; - expect(mcpOf(await runConfig())?.jfrog?.headers?.Authorization).toBe( - 'Bearer {env:JF_ACCESS_TOKEN}' - ); + 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 () => { @@ -217,6 +338,15 @@ describe('JfrogOpencodePlugin JFrog remote MCP injection', () => { 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'; diff --git a/src/index.ts b/src/index.ts index a85aa4f..8a851da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,114 +1,210 @@ // (c) JFrog Ltd. (2026) import type { Config, Plugin } from '@opencode-ai/plugin'; -import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs'; +import { + accessSync, + appendFileSync, + constants, + existsSync, + mkdirSync, + readdirSync, + statSync, +} from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +// ── Constants ───────────────────────────────────────────────────────────────── + const LOG_FILE = join(process.cwd(), '.opencode', 'event-log.txt'); -// dist/index.js -> ../skills after build/install; src/index.ts -> ../skills in dev. +// 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; + +const JF_CLI_INSTALL_HINT = + 'JFrog: the `jf` CLI was not found on your PATH. Install it ' + + '(https://jfrog.com/getting-started-with-jfrog-cli/) to run JFrog commands and `jf setup `.'; + +// ── Types ───────────────────────────────────────────────────────────────────── + type Logger = (_message: string) => void; +type Toast = (_message: string, _variant: 'error' | 'info') => void; type ConfigWithJfrog = Config & { skills?: { paths?: string[] }; mcp?: Record; }; +type McpCredentials = { host: string; tokenVar: string }; +type McpServer = NonNullable[string]; + +// ── Pure helpers ──────────────────────────────────────────────────────────────── const isNonEmptyDir = (dir: string): boolean => { - if (!existsSync(dir)) { - return false; - } try { - return statSync(dir).isDirectory() && readdirSync(dir).length > 0; + return existsSync(dir) && statSync(dir).isDirectory() && readdirSync(dir).length > 0; } catch { return false; } }; +/** True if `cmd` is found as an executable on PATH. Cheap synchronous scan; spawns no subprocess. */ +const commandExists = (cmd: string): boolean => + (process.env.PATH ?? '').split(':').some((dir) => { + if (!dir) { + return false; + } + try { + accessSync(join(dir, cmd), constants.X_OK); + return true; + } catch { + return false; + } + }); + +const firstDefinedEnv = (names: readonly string[]): string | undefined => + names.map((name) => process.env[name]).find((value) => !!value); + +const normalizeHost = (raw: string): string => raw.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + +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. + */ +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 ? { host: normalizeHost(host), tokenVar } : undefined; +}; + +/** + * Build the OpenCode remote-MCP entry with the resolved Bearer token. + * + * 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 = ({ host }: McpCredentials, token: string): McpServer => ({ + type: 'remote', + url: `https://${host}/mcp`, + oauth: false, + headers: { Authorization: `Bearer ${token}` }, + enabled: true, +}); + +// ── Config mutators (side-effecting, but localized) ─────────────────────────────── + +/** Register the bundled skills dir. Returns false (and toasts) when the package is broken. */ +const registerSkills = (cfg: ConfigWithJfrog, log: Logger, toast: Toast): boolean => { + cfg.skills = cfg.skills ?? {}; + cfg.skills.paths = cfg.skills.paths ?? []; + + if (!isNonEmptyDir(BUNDLED_SKILLS_DIR)) { + const message = + `JFrog: bundled skills not found at ${BUNDLED_SKILLS_DIR}. ` + + 'The plugin package may be broken; reinstall @jfrog/opencode-jfrog-plugin.'; + log('ERROR ' + message); + toast(message, 'error'); + return false; + } + + if (!cfg.skills.paths.includes(BUNDLED_SKILLS_DIR)) { + cfg.skills.paths.push(BUNDLED_SKILLS_DIR); + } + log('config.skills.paths=' + JSON.stringify(cfg.skills.paths)); + return true; +}; + +/** Inject the JFrog Platform remote MCP. No network on load; never clobbers a user-defined `jfrog`. */ +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)' + ); + 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 https://${credentials.host}/mcp`); +}; + +// ── Plugin ──────────────────────────────────────────────────────────────────── + /** OpenCode loads plugins via the `server` export (see `PluginModule` in @opencode-ai/plugin). */ const jfrogOpencodePlugin: Plugin = async ({ client }) => { - const logDir = dirname(LOG_FILE); - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); + if (!existsSync(dirname(LOG_FILE))) { + mkdirSync(dirname(LOG_FILE), { recursive: true }); } + const log: Logger = (message) => { if (process.env.JFROG_DEBUG_LOGS === 'true') { appendFileSync(LOG_FILE, message + '\n', 'utf-8'); } }; - // Fire-and-forget: showToast never resolves in headless sessions (no TUI to ack it), so awaiting it - // would hang the config hook. Durable signals always go through log() regardless of UI. - const toast = (message: string, variant: 'error' | 'info'): void => { + + // Fire-and-forget: in headless sessions showToast never resolves, awaiting it would hang the hook. + const toast: Toast = (message, variant) => { void client.tui .showToast({ body: { message, variant, duration: 10000 } }) .catch(() => undefined); }; + log('JfrogOpencodePlugin starting...'); - // The config hook can run multiple times per session; nudge the user only once. - let nudgeShown = false; + // 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 + // 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); + + // Nudge to install the CLI just-in-time — on the first `jf` command — at most once per session. A + // tool hook fires mid-session (TUI live); a config-hook toast would be dropped at bootstrap before + // the TUI subscribes to events. + let installHintShown = false; + const adviseInstallOnce = (): void => { + if (installHintShown || hasJfCli) { + return; + } + installHintShown = true; + toast(JF_CLI_INSTALL_HINT, 'info'); + }; return { config: async (config) => { const cfg = config as ConfigWithJfrog; - cfg.skills = cfg.skills ?? {}; - cfg.skills.paths = cfg.skills.paths ?? []; - - // Fail loud: a missing/empty bundled dir means a broken build/package. - if (!isNonEmptyDir(BUNDLED_SKILLS_DIR)) { - const message = - `JFrog: bundled skills not found at ${BUNDLED_SKILLS_DIR}. ` + - 'The plugin package may be broken; reinstall @jfrog/opencode-jfrog-plugin.'; - log('ERROR ' + message); - toast(message, 'error'); + if (!registerSkills(cfg, log, toast)) { return; } - - if (!cfg.skills.paths.includes(BUNDLED_SKILLS_DIR)) { - cfg.skills.paths.push(BUNDLED_SKILLS_DIR); - } - log('config.skills.paths=' + JSON.stringify(cfg.skills.paths)); - - // Register the JFrog Platform remote MCP (token auth, headless). Pure config mutation: no - // network on load. The token is referenced via {env:} so the raw value never enters the config. - const rawHost = process.env.JFROG_URL ?? process.env.JF_URL ?? process.env.JFROG_PLATFORM_URL; - const tokenVar = process.env.JFROG_ACCESS_TOKEN - ? 'JFROG_ACCESS_TOKEN' - : process.env.JF_ACCESS_TOKEN - ? 'JF_ACCESS_TOKEN' - : undefined; - const mcpDisabled = process.env.JFROG_MCP_DISABLE === 'true'; - - if (rawHost && tokenVar && !mcpDisabled) { - const host = rawHost.replace(/^https?:\/\//, '').replace(/\/+$/, ''); - const url = `https://${host}/mcp`; - cfg.mcp = cfg.mcp ?? {}; - // Non-destructive: never clobber a user-defined `jfrog` MCP server. - if (!cfg.mcp.jfrog) { - cfg.mcp.jfrog = { - type: 'remote', - url, - oauth: false, - headers: { Authorization: `Bearer {env:${tokenVar}}` }, - enabled: true, - }; - log('mcp: registered jfrog remote MCP at ' + url); - } - } else { - log( - 'mcp: jfrog remote MCP not registered ' + - '(need JFROG_URL + JFROG_ACCESS_TOKEN; or JFROG_MCP_DISABLE set)' - ); + registerMcp(cfg, log); + }, + 'tool.execute.before': async (input, output) => { + if (installHintShown || hasJfCli || input.tool !== 'bash') { + return; } - - // R2 interim nudge until package-manager setup is handled by a skill. - if (!nudgeShown) { - nudgeShown = true; - toast( - 'JFrog: run `jf setup ` to configure package managers against Artifactory.', - 'info' - ); + const command = String((output.args as { command?: string })?.command ?? ''); + if (isJfCommand(command)) { + adviseInstallOnce(); } }, }; From c38990fca86f786cc044a77314ec49193397fff2 Mon Sep 17 00:00:00 2001 From: Michae Touito Date: Sun, 28 Jun 2026 18:06:17 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(mcp):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?cross-platform=20PATH,=20scheme=20preservation,=20toast=20dedup?= =?UTF-8?q?,=20skills/MCP=20decoupling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/index.test.ts | 12 ++++++++ src/index.ts | 73 ++++++++++++++++++++++++++++------------------- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index d738f18..b1fbb4a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -309,6 +309,18 @@ describe('JfrogOpencodePlugin JFrog remote MCP injection', () => { 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'; diff --git a/src/index.ts b/src/index.ts index 8a851da..7476f39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { readdirSync, statSync, } from 'fs'; -import { dirname, join } from 'path'; +import { delimiter, dirname, join } from 'path'; import { fileURLToPath } from 'url'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -35,7 +35,7 @@ type ConfigWithJfrog = Config & { skills?: { paths?: string[] }; mcp?: Record; }; -type McpCredentials = { host: string; tokenVar: string }; +type McpCredentials = { baseUrl: string; tokenVar: string }; type McpServer = NonNullable[string]; // ── Pure helpers ──────────────────────────────────────────────────────────────── @@ -48,24 +48,34 @@ const isNonEmptyDir = (dir: string): boolean => { } }; -/** True if `cmd` is found as an executable on PATH. Cheap synchronous scan; spawns no subprocess. */ -const commandExists = (cmd: string): boolean => - (process.env.PATH ?? '').split(':').some((dir) => { - if (!dir) { - return false; - } - try { - accessSync(join(dir, cmd), constants.X_OK); - return true; - } catch { - return false; - } - }); +/** + * True if `cmd` is found as an executable on PATH. Cheap synchronous scan; spawns no subprocess. + * Cross-platform: uses the OS PATH delimiter and probes Windows executable extensions. + */ +const commandExists = (cmd: string): boolean => { + const names = process.platform === 'win32' ? [`${cmd}.exe`, `${cmd}.cmd`, `${cmd}.bat`] : [cmd]; + return (process.env.PATH ?? '').split(delimiter).some((dir) => + !dir + ? false + : names.some((name) => { + try { + accessSync(join(dir, name), constants.X_OK); + return true; + } catch { + return false; + } + }) + ); +}; const firstDefinedEnv = (names: readonly string[]): string | undefined => names.map((name) => process.env[name]).find((value) => !!value); -const normalizeHost = (raw: string): string => raw.replace(/^https?:\/\//, '').replace(/\/+$/, ''); +// Preserve an explicit http/https scheme (default https when none); strip trailing slashes. +const toBaseUrl = (raw: string): string => { + const trimmed = raw.replace(/\/+$/, ''); + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +}; const isJfCommand = (command: string): boolean => /(?:^|[\s;&|(])jf(?:\s|$)/.test(command); @@ -82,7 +92,7 @@ const resolveMcpCredentials = (): McpCredentials | undefined => { } const host = firstDefinedEnv(HOST_ENV_VARS); const tokenVar = TOKEN_ENV_VARS.find((name) => process.env[name]); - return host && tokenVar ? { host: normalizeHost(host), tokenVar } : undefined; + return host && tokenVar ? { baseUrl: toBaseUrl(host), tokenVar } : undefined; }; /** @@ -92,9 +102,9 @@ const resolveMcpCredentials = (): McpCredentials | undefined => { * 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 = ({ host }: McpCredentials, token: string): McpServer => ({ +const mcpServerEntry = ({ baseUrl }: McpCredentials, token: string): McpServer => ({ type: 'remote', - url: `https://${host}/mcp`, + url: `${baseUrl}/mcp`, oauth: false, headers: { Authorization: `Bearer ${token}` }, enabled: true, @@ -102,17 +112,23 @@ const mcpServerEntry = ({ host }: McpCredentials, token: string): McpServer => ( // ── Config mutators (side-effecting, but localized) ─────────────────────────────── -/** Register the bundled skills dir. Returns false (and toasts) when the package is broken. */ +// The config hook runs multiple times per session; surface the broken-package error only once. +let skillsErrorShown = false; + +/** Register the bundled skills dir. Returns false (and toasts once) when the package is broken. */ const registerSkills = (cfg: ConfigWithJfrog, log: Logger, toast: Toast): boolean => { cfg.skills = cfg.skills ?? {}; cfg.skills.paths = cfg.skills.paths ?? []; if (!isNonEmptyDir(BUNDLED_SKILLS_DIR)) { - const message = - `JFrog: bundled skills not found at ${BUNDLED_SKILLS_DIR}. ` + - 'The plugin package may be broken; reinstall @jfrog/opencode-jfrog-plugin.'; - log('ERROR ' + message); - toast(message, 'error'); + if (!skillsErrorShown) { + skillsErrorShown = true; + const message = + `JFrog: bundled skills not found at ${BUNDLED_SKILLS_DIR}. ` + + 'The plugin package may be broken; reinstall @jfrog/opencode-jfrog-plugin.'; + log('ERROR ' + message); + toast(message, 'error'); + } return false; } @@ -146,7 +162,7 @@ const registerMcp = (cfg: ConfigWithJfrog, log: Logger): void => { return; } cfg.mcp.jfrog = mcpServerEntry(credentials, token); - log(`mcp: registered jfrog remote MCP at https://${credentials.host}/mcp`); + log(`mcp: registered jfrog remote MCP at ${credentials.baseUrl}/mcp`); }; // ── Plugin ──────────────────────────────────────────────────────────────────── @@ -193,9 +209,8 @@ const jfrogOpencodePlugin: Plugin = async ({ client }) => { return { config: async (config) => { const cfg = config as ConfigWithJfrog; - if (!registerSkills(cfg, log, toast)) { - return; - } + // Skills and MCP are independent features — register both even if one is broken. + registerSkills(cfg, log, toast); registerMcp(cfg, log); }, 'tool.execute.before': async (input, output) => {