From 2e215faf4c5fbe85f1a4f26b993c36eb56f05951 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 10:36:59 -0400 Subject: [PATCH] fix: validate MCP plugin variable declarations --- .github/workflows/validate-plugins.yml | 7 +-- scripts/validate-plugins.mjs | 50 +++++++++++++++++ scripts/validate-plugins.test.mjs | 77 ++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 scripts/validate-plugins.test.mjs diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index 03e8e1928..e7b6e34b6 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -2,10 +2,6 @@ name: Validate plugins on: pull_request: - paths: - - ".cursor-plugin/marketplace.json" - - "**/plugin.json" - - "schemas/**" jobs: validate: @@ -20,5 +16,8 @@ jobs: - name: Install dependencies run: npm install --no-save ajv ajv-formats + - name: Test plugin validation + run: node --test scripts/validate-plugins.test.mjs + - name: Validate plugin definitions run: node scripts/validate-plugins.mjs diff --git a/scripts/validate-plugins.mjs b/scripts/validate-plugins.mjs index 6a7870854..5bb32192c 100644 --- a/scripts/validate-plugins.mjs +++ b/scripts/validate-plugins.mjs @@ -31,6 +31,54 @@ function fail(message) { errors++; } +// Cursor runtime interpolation is not user-configured plugin configuration. +// https://cursor.com/docs/reference/plugins#variables +// https://cursor.com/docs/mcp#config-interpolation +const runtimeVariables = new Set([ + "CURSOR_PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT", "userHome", "workspaceFolder", + "workspaceFolderBasename", "pathSeparator", "/", +]); + +function checkMcpVariables(plugin, pluginDir, name) { + const declared = plugin.variables?.properties ?? {}; + const configs = plugin.mcpServers === undefined + ? (existsSync(resolve(pluginDir, "mcp.json")) ? ["mcp.json"] : []) + : (Array.isArray(plugin.mcpServers) ? plugin.mcpServers : [plugin.mcpServers]); + + function visit(value, location) { + if (typeof value === "string") { + for (const [, variable] of value.matchAll(/\$\{([^{}]+)\}/g)) { + // Leave environment references and shell/default expressions alone; + // only plain ${VAR} references are plugin-variable declarations. + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable) || + runtimeVariables.has(variable) || Object.hasOwn(declared, variable)) continue; + fail(`Plugin "${name}": ${location} references undeclared variable "${variable}" (declare it in variables.properties)`); + } + } else if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, `${location}[${index}]`)); + } else if (value && typeof value === "object") { + for (const [key, item] of Object.entries(value)) visit(item, `${location}.${key}`); + } + } + + for (const [index, config] of configs.entries()) { + if (typeof config !== "string") { + visit(config, `mcpServers[${index}]`); + continue; + } + const path = resolve(pluginDir, config); + let content; + try { + content = loadJSON(path); + } catch (error) { + const reason = error instanceof SyntaxError ? "invalid JSON" : error.code; + fail(`Plugin "${name}": cannot read MCP config "${config}" (${reason})`); + continue; + } + visit(content, config); + } +} + // 1. Validate marketplace.json const marketplacePath = resolve(root, ".cursor-plugin/marketplace.json"); @@ -84,6 +132,8 @@ for (const entry of marketplace.plugins ?? []) { } } + checkMcpVariables(pluginJson, pluginDir, entry.name); + // Check that marketplace name matches plugin name if (pluginJson.name && pluginJson.name !== entry.name) { fail( diff --git a/scripts/validate-plugins.test.mjs b/scripts/validate-plugins.test.mjs new file mode 100644 index 000000000..b73d76ecb --- /dev/null +++ b/scripts/validate-plugins.test.mjs @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const schema = { type: "object", properties: { TOKEN: { type: "string" } } }; +const config = (value) => ({ mcpServers: { example: { url: "https://example.com/mcp", headers: { Authorization: value } } } }); + +function validate(t, { plugin = {}, entry = {}, files = {} } = {}) { + const dir = mkdtempSync(resolve(tmpdir(), "plugin-validation-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + mkdirSync(resolve(dir, "scripts")); + cpSync(resolve(root, "scripts/validate-plugins.mjs"), resolve(dir, "scripts/validate-plugins.mjs")); + cpSync(resolve(root, "schemas"), resolve(dir, "schemas"), { recursive: true }); + symlinkSync(resolve(root, "node_modules"), resolve(dir, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + const contents = { + ".cursor-plugin/marketplace.json": { name: "test", owner: { name: "Test" }, plugins: [{ name: "example", source: "example", ...entry }] }, + "example/.cursor-plugin/plugin.json": { name: "example", ...plugin }, + ...Object.fromEntries(Object.entries(files).map(([path, content]) => [`example/${path}`, content])), + }; + for (const [path, content] of Object.entries(contents)) { + mkdirSync(dirname(resolve(dir, path)), { recursive: true }); + writeFileSync(resolve(dir, path), typeof content === "string" ? content : JSON.stringify(content)); + } + return spawnSync(process.execPath, [resolve(dir, "scripts/validate-plugins.mjs")], { encoding: "utf8" }); +} + +for (const [name, plugin, files] of [ + ["default discovery", {}, { "mcp.json": config("Bearer ${TYPO}") }], + ["explicit path", { mcpServers: "configs/server.json" }, { "configs/server.json": config("${TYPO}") }], + ["inline config", { mcpServers: config("${TYPO}") }, {}], + ["mixed array", { mcpServers: [config("${TOKEN}"), "other.json"], variables: schema }, { "other.json": config("${TYPO}") }], + ["no declared schema", { mcpServers: config("${TOKEN}") }, {}], + ["inherited Object property", { mcpServers: config("${toString}"), variables: schema }, {}], +]) { + test(`rejects undeclared variables in ${name}`, (t) => { + const result = validate(t, { plugin, files }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /references undeclared variable/); + assert.match(result.stderr, /variables.properties/); + }); +} + +test("declared variables, defaults and runtime interpolation remain valid", (t) => { + const result = validate(t, { plugin: { variables: schema, mcpServers: { + mcpServers: { example: { command: "${CURSOR_PLUGIN_ROOT}/bin/server", cwd: "${CLAUDE_PLUGIN_ROOT}", args: ["${workspaceFolder}", "${workspaceFolderBasename}", "${userHome}", "${pathSeparator}", "${/}"], env: { TOKEN: "${TOKEN}", OTHER: "${env:OTHER}", OPTIONAL: "${CLIENT_SECRET:-}" } } }, + } } }); + assert.equal(result.status, 0, result.stderr); +}); + +test("declared variables work in discovered files and mixed arrays", (t) => { + for (const mcpServers of [undefined, [config("${TOKEN}"), "mcp.json"]]) { + const result = validate(t, { plugin: { variables: schema, ...(mcpServers === undefined ? {} : { mcpServers }) }, files: { "mcp.json": config("${TOKEN}") } }); + assert.equal(result.status, 0, result.stderr); + } +}); + +test("explicit MCP configuration replaces default discovery", (t) => { + const result = validate(t, { plugin: { mcpServers: config("literal") }, files: { "mcp.json": config("${TYPO}") } }); + assert.equal(result.status, 0, result.stderr); +}); + +for (const [name, files] of [["missing", {}], ["invalid JSON", { "custom.json": "{broken" }]]) { + test(`reports ${name} MCP files with plugin context`, (t) => { + const result = validate(t, { plugin: { mcpServers: "custom.json" }, files }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /Plugin "example": cannot read MCP config "custom.json"/); + }); +} + +test("plugins without MCP configuration remain valid", (t) => { + assert.equal(validate(t).status, 0); +});