Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/validate-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ name: Validate plugins

on:
pull_request:
paths:
- ".cursor-plugin/marketplace.json"
- "**/plugin.json"
- "schemas/**"

jobs:
validate:
Expand All @@ -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
50 changes: 50 additions & 0 deletions scripts/validate-plugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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(
Expand Down
77 changes: 77 additions & 0 deletions scripts/validate-plugins.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});