Files
neondatabase__agent-skills/scripts/validate-agent-plugin.mjs
Tony Dang 64e8bcce32 Make the repo root installable as an Agent Plugins v1 package (#80)
Clients implementing the Agent Plugins v1 specification look for a
manifest at the plugin root, so today this repo is only installable
through the Claude and Cursor packaging under plugins/. Adding a root
plugin.json and mcp.json makes the repository itself a conforming
portable package that reads skills/ in place, with no vendoring.

The v1 manifest schema is closed, so the client fields the existing
manifests carry (skills, mcpServers, logo, displayName) cannot appear
at the top level and MCP configuration moves to its own file. The root
mcp.json therefore spells the transport "streamable-http" while
plugins/neon-postgres/mcp.json keeps Claude and Cursor's "http" for the
same endpoint; the two files are intentionally not copies.

Apart from the version bump below, the packaging under plugins/ is
untouched, so existing Claude and Cursor installs behave as before.

validate-agent-plugin.mjs gates the new files in CI, checking the closed
manifest field set, MCP transport and containment rules, and skill
discovery. It shares the frontmatter semantics skills-ref uses so the
two validators cannot disagree about the same SKILL.md. Containment is
checked all the way down a skill rather than only at its root, since a
nested symlink escaping the repo would otherwise ship to a v1 client.
A link that will not resolve is reported rather than passing as
contained on the strength of a failed realpath.

Releases this as 1.1.1, and makes package.json the single source of
truth for that number. The root manifest added a sixth place the
version is declared and nothing kept them in step, so a bump that
missed one could publish a catalog entry advertising a different
version than the plugin it installs. sync-versions.mjs generates all
six from package.json, the pre-commit hook runs it the way it already
runs sync-plugin-skills.mjs, and validate:versions is that same script
in --check mode so CI still catches a hand-edited manifest. A release
is now one edit: bump package.json and commit.

The rewrite is a surgical text replacement rather than a JSON
re-serialize, so every manifest keeps its formatting. Nothing is written
until every file re-parses to exactly the expected result.
package-lock.json declares the version twice as well, but it repeats
"version" for every dependency, which makes a text rewrite unsafe; npm
owns that file, so the script checks it and leaves it alone.

The check refuses to pass quietly on a file it did not understand: one
that exists but declares no version is an error, and so is one that
cannot be read, since reporting "in sync" for a file never opened would
defeat the point. Each failure carries its own remedy, and the
sync:versions hint is withheld from the two that command cannot repair.

The hook stages only the manifests sync-versions.mjs reports rewriting,
which it prints to stdout. Staging all six unconditionally would sweep
an in-progress edit to a description or keyword list into an unrelated
commit, since those files are hand-authored apart from the version
field. The vendored skills are still staged a directory at a time so a
deletion is staged too, which is safe because every file under
plugins/*/skills is generated. That loop skips a directory that does
not exist: an unmatched glob is passed through literally, and git add
would abort the commit with a pathspec error the first time a plugin
ships without one of the two client packaging formats.

Also adds the marketplace catalogs to the workflow path filters; editing
one previously skipped CI even though validate:plugins reads them.
2026-08-06 22:34:54 -07:00

590 lines
18 KiB
JavaScript

#!/usr/bin/env node
// Validates the repo root as an Agent Plugins v1.0.0 portable package:
// the root plugin.json manifest, the optional root mcp.json, and the skill
// discovery rules for skills/. The client-specific packaging under plugins/
// is a compatibility layer and is validated separately.
//
// Spec: https://agent-plugins.org/specification
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
const SPEC_VERSION = "1.0.0";
const SCHEMA_BASE = `https://agent-plugins.org/schemas/${SPEC_VERSION}`;
const PLUGIN_SCHEMA = `${SCHEMA_BASE}/plugin.schema.json`;
const MCP_SCHEMA = `${SCHEMA_BASE}/mcp.schema.json`;
// Names are 1-64 chars, lowercase alphanumerics with hyphens and periods,
// bounded by alphanumerics, and may not contain "--" or "..".
const NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
const NAMESPACE_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/i;
const CWD_PATTERN =
/^(?:\.\/|\$\{PLUGIN_ROOT\}(?:\/|$)|\$\{PLUGIN_DATA\}(?:\/|$))/;
// RFC 9110 field name (token) and field value (visible ASCII, space, tab).
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const HEADER_VALUE_PATTERN = /^[\t\x20-\x7e\x80-\xff]*$/;
const MANIFEST_FIELDS = new Set([
"$schema",
"name",
"version",
"description",
"author",
"homepage",
"repository",
"license",
"keywords",
"extensions",
]);
const STRING_FIELDS = [
"version",
"description",
"homepage",
"repository",
"license",
];
const AUTHOR_FIELDS = new Set(["name", "email", "url"]);
const RESERVED_ENV = new Set(["PLUGIN_ROOT", "PLUGIN_DATA"]);
const TRANSPORTS = {
stdio: {
required: ["type", "command"],
allowed: ["type", "command", "args", "env", "cwd"],
},
"streamable-http": {
required: ["type", "url"],
allowed: ["type", "url", "headers"],
},
sse: {
required: ["type", "url"],
allowed: ["type", "url", "headers"],
},
};
const repoRoot = process.cwd();
const errors = [];
function addError(message) {
errors.push(message);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
// True when a plugin-relative or ${PLUGIN_*}-rooted path climbs out of its
// root once "." and ".." segments are collapsed.
function escapesRoot(value) {
const rooted = value.replace(/^\$\{PLUGIN_(?:ROOT|DATA)\}\/?/, "");
const normalized = path.posix.normalize(rooted.replace(/\\/g, "/"));
return normalized === ".." || normalized.startsWith("../");
}
// §4.1 requires containment of the *filesystem-resolved* path, so a lexical
// check alone would miss a symlink pointing outside. Resolve bundled paths
// that exist on disk; anything absent can only be checked lexically.
async function escapesRootOnDisk(value) {
if (value.startsWith("${PLUGIN_DATA}")) {
// PLUGIN_DATA is a client-managed directory outside the package.
return false;
}
const relative = value.replace(/^\$\{PLUGIN_ROOT\}\/?/, "");
try {
const root = await fs.realpath(repoRoot);
const resolved = await fs.realpath(path.resolve(root, relative));
return resolved !== root && !resolved.startsWith(root + path.sep);
} catch {
return false;
}
}
function isLoopback(hostname) {
return (
hostname === "localhost" ||
hostname === "[::1]" ||
/^127\.\d+\.\d+\.\d+$/.test(hostname)
);
}
async function readJsonFile(filePath, context) {
let stat;
try {
stat = await fs.lstat(filePath);
} catch {
return { found: false };
}
if (!stat.isFile()) {
// Stricter than §4.1, which allows a symlink that stays inside the
// package. Kept deliberately: this repo bans symlinks in distributed
// packaging because Cursor and Claude silently drop them on install.
const relative = path.relative(repoRoot, filePath);
addError(`${context} must be a regular file: ${relative}`);
return { found: true };
}
try {
return {
found: true,
data: JSON.parse(await fs.readFile(filePath, "utf8")),
};
} catch (error) {
addError(`${context} could not be read as JSON: ${error.message}`);
return { found: true };
}
}
function validateManifest(manifest) {
if (!isPlainObject(manifest)) {
addError("plugin.json must be a JSON object.");
return;
}
if (manifest.$schema !== PLUGIN_SCHEMA) {
addError(`plugin.json: "$schema" must be "${PLUGIN_SCHEMA}".`);
}
if (typeof manifest.name !== "string") {
addError('plugin.json: "name" is required and must be a string.');
} else if (manifest.name.length > 64 || !NAME_PATTERN.test(manifest.name)) {
addError(
`plugin.json: "name" must be 1-64 lowercase alphanumerics, hyphens, or periods bounded by alphanumerics, with no "--" or "..": "${manifest.name}"`,
);
}
// The v1 manifest schema is closed. Client fields such as "skills",
// "mcpServers", or "hooks" belong in mcp.json or an extension namespace.
for (const field of Object.keys(manifest)) {
if (!MANIFEST_FIELDS.has(field)) {
addError(
`plugin.json: "${field}" is not an allowed top-level field. Use "extensions" for client-specific data.`,
);
}
}
for (const field of STRING_FIELDS) {
if (field in manifest && typeof manifest[field] !== "string") {
addError(`plugin.json: "${field}" must be a string.`);
}
}
if ("author" in manifest) {
const { author } = manifest;
if (!isPlainObject(author)) {
addError('plugin.json: "author" must be an object.');
} else {
for (const field of Object.keys(author)) {
if (!AUTHOR_FIELDS.has(field)) {
addError(`plugin.json: "author.${field}" is not an allowed field.`);
} else if (typeof author[field] !== "string") {
addError(`plugin.json: "author.${field}" must be a string.`);
}
}
}
}
if ("keywords" in manifest) {
const { keywords } = manifest;
const valid =
Array.isArray(keywords) && keywords.every((k) => typeof k === "string");
if (!valid) {
addError('plugin.json: "keywords" must be an array of strings.');
}
}
if ("extensions" in manifest) {
const { extensions } = manifest;
if (!isPlainObject(extensions)) {
addError('plugin.json: "extensions" must be an object.');
} else {
for (const [namespace, value] of Object.entries(extensions)) {
if (!NAMESPACE_PATTERN.test(namespace)) {
addError(
`plugin.json: extension namespace "${namespace}" must be a reverse-domain identifier such as "com.vendor.client".`,
);
}
if (!isPlainObject(value)) {
addError(
`plugin.json: extension "${namespace}" must map to an object.`,
);
}
}
}
}
}
function validateServerUrl(name, url) {
let parsed;
try {
parsed = new URL(url);
} catch {
addError(`mcp.json: server "${name}" has an invalid "url": "${url}"`);
return;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
addError(`mcp.json: server "${name}" requires an http or https "url".`);
return;
}
if (parsed.username || parsed.password) {
addError(`mcp.json: server "${name}" must not embed credentials in "url".`);
}
if (parsed.hash) {
addError(`mcp.json: server "${name}" must not put a fragment in "url".`);
}
if (parsed.protocol !== "https:" && !isLoopback(parsed.hostname)) {
addError(
`mcp.json: server "${name}" must use HTTPS for a non-loopback URL.`,
);
}
}
// §7.2.1: command MUST be either a bare executable name or a plugin-relative
// path beginning with "./". Absolute paths are neither form.
async function validateCommand(name, command) {
if (command.startsWith("./")) {
// A plugin-relative path is a single token even when it contains spaces,
// so only containment matters here.
if (escapesRoot(command) || (await escapesRootOnDisk(command))) {
addError(
`mcp.json: server "${name}" has a "command" that escapes the plugin: "${command}"`,
);
}
return;
}
if (/\s/.test(command)) {
addError(
`mcp.json: server "${name}" has "command" with whitespace. Use one executable token and pass the rest via "args".`,
);
return;
}
if (/[\\/]/.test(command)) {
addError(
`mcp.json: server "${name}" has "command" path "${command}". Use a bare executable name or a plugin-relative path beginning with "./".`,
);
}
}
function validateEnv(name, env) {
if (!isPlainObject(env)) {
addError(`mcp.json: server "${name}" requires "env" to be an object.`);
return;
}
for (const [key, value] of Object.entries(env)) {
if (RESERVED_ENV.has(key)) {
addError(
`mcp.json: server "${name}" must not set the reserved env variable "${key}".`,
);
}
if (typeof value !== "string") {
addError(`mcp.json: server "${name}" env "${key}" must be a string.`);
}
}
}
function validateHeaders(name, headers) {
if (!isPlainObject(headers)) {
addError(`mcp.json: server "${name}" requires "headers" to be an object.`);
return;
}
const seen = new Set();
for (const [key, value] of Object.entries(headers)) {
const lower = key.toLowerCase();
if (seen.has(lower)) {
addError(
`mcp.json: server "${name}" repeats header "${key}" under a different casing.`,
);
}
seen.add(lower);
if (!HEADER_NAME_PATTERN.test(key)) {
addError(
`mcp.json: server "${name}" header "${key}" is not a valid HTTP header name.`,
);
}
if (typeof value !== "string") {
addError(`mcp.json: server "${name}" header "${key}" must be a string.`);
} else if (!HEADER_VALUE_PATTERN.test(value)) {
addError(
`mcp.json: server "${name}" header "${key}" has a value that is not a valid HTTP field value.`,
);
}
}
}
async function validateServerFields(name, server) {
if ("args" in server) {
const { args } = server;
const valid =
Array.isArray(args) && args.every((a) => typeof a === "string");
if (!valid) {
addError(
`mcp.json: server "${name}" requires "args" to be an array of strings.`,
);
}
}
if ("env" in server) {
validateEnv(name, server.env);
}
if ("cwd" in server) {
const { cwd } = server;
if (typeof cwd !== "string" || !CWD_PATTERN.test(cwd)) {
addError(
`mcp.json: server "${name}" requires "cwd" to start with "./", "\${PLUGIN_ROOT}", or "\${PLUGIN_DATA}".`,
);
} else if (escapesRoot(cwd) || (await escapesRootOnDisk(cwd))) {
addError(
`mcp.json: server "${name}" has a "cwd" that escapes the plugin: "${cwd}"`,
);
}
}
if ("headers" in server) {
validateHeaders(name, server.headers);
}
}
async function validateMcp(config) {
if (!isPlainObject(config)) {
addError("mcp.json must be a JSON object.");
return;
}
if (config.$schema !== MCP_SCHEMA) {
addError(
`mcp.json: "$schema" must be "${MCP_SCHEMA}" so the MCP configuration targets the same Agent Plugins version as plugin.json.`,
);
}
for (const field of Object.keys(config)) {
if (field !== "$schema" && field !== "mcpServers") {
addError(`mcp.json: "${field}" is not an allowed top-level field.`);
}
}
const servers = config.mcpServers;
if (!isPlainObject(servers)) {
addError('mcp.json: "mcpServers" is required and must be an object.');
return;
}
for (const [name, server] of Object.entries(servers)) {
if (!isPlainObject(server)) {
addError(`mcp.json: server "${name}" must be an object.`);
continue;
}
// Own-property lookup: "constructor" and friends are truthy on a plain
// object and would sail past this guard into a crash below.
const transport = Object.hasOwn(TRANSPORTS, server.type)
? TRANSPORTS[server.type]
: undefined;
if (!transport) {
addError(
`mcp.json: server "${name}" has type "${server.type}". Use "stdio", "streamable-http", or "sse".`,
);
continue;
}
for (const field of transport.required) {
if (typeof server[field] !== "string" || server[field].length === 0) {
addError(`mcp.json: server "${name}" requires a non-empty "${field}".`);
}
}
for (const field of Object.keys(server)) {
if (!transport.allowed.includes(field)) {
addError(
`mcp.json: server "${name}" (${server.type}) does not allow "${field}".`,
);
}
}
await validateServerFields(name, server);
if (server.type === "stdio") {
if (typeof server.command === "string") {
await validateCommand(name, server.command);
}
} else if (typeof server.url === "string") {
validateServerUrl(name, server.url);
}
}
}
// Unwraps a YAML scalar the way a real parser would, so a quoted name or a
// trailing comment does not disagree with skills-ref over the same file.
function parseScalar(raw) {
const value = raw.trim();
const quote = value[0];
if (quote === '"' || quote === "'") {
const closing = value.indexOf(quote, 1);
if (closing !== -1) {
return value.slice(1, closing);
}
}
const comment = value.search(/\s#/);
return comment === -1 ? value : value.slice(0, comment).trim();
}
function parseFrontmatter(content) {
const normalized = content.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return null;
}
const closingIndex = normalized.indexOf("\n---\n", 4);
if (closingIndex === -1) {
return null;
}
const fields = {};
for (const line of normalized.slice(4, closingIndex).split("\n")) {
const trimmed = line.trimStart();
const separator = line.indexOf(":");
// Skip comments and indented lines so only top-level keys are collected.
if (separator === -1 || trimmed !== line || trimmed.startsWith("#")) {
continue;
}
fields[line.slice(0, separator).trim()] = parseScalar(
line.slice(separator + 1),
);
}
return fields;
}
async function validateSkill(skillsRoot, name) {
// Only immediate children of skills/ are discovered.
const skillFile = path.join(skillsRoot, name, "SKILL.md");
let stat;
try {
stat = await fs.lstat(skillFile);
} catch {
addError(`skills/${name} is missing a SKILL.md file.`);
return;
}
if (!stat.isFile()) {
addError(`skills/${name}/SKILL.md must be a regular file.`);
return;
}
const frontmatter = parseFrontmatter(await fs.readFile(skillFile, "utf8"));
if (!frontmatter) {
addError(`skills/${name}/SKILL.md is missing YAML frontmatter.`);
return;
}
if (frontmatter.name !== name) {
addError(
`skills/${name}/SKILL.md declares name "${frontmatter.name ?? ""}", which must match its directory.`,
);
}
if (!frontmatter.description) {
addError(`skills/${name}/SKILL.md is missing a "description".`);
}
}
// §4.1 requires every path in the package to resolve inside it. The caller
// rejects a symlinked skill directory outright; a nested symlink is legal so
// long as it stays contained, so those are resolved rather than banned.
//
// Resolves here rather than through escapesRootOnDisk, which reports an
// unresolvable path as contained. That is right for the MCP command and cwd
// callers, which may name paths that do not exist until the server runs, but
// here it would pass a link whose containment was never established.
async function validateSkillContainment(dir, root) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isSymbolicLink()) {
const relative = path.relative(repoRoot, entryPath);
let resolved;
try {
resolved = await fs.realpath(entryPath);
} catch {
addError(
`${relative} is a symlink that does not resolve, so it cannot be shown to stay inside the plugin.`,
);
continue;
}
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
addError(`${relative} is a symlink that resolves outside the plugin.`);
}
continue;
}
if (entry.isDirectory()) {
await validateSkillContainment(entryPath, root);
}
}
}
async function validateSkills() {
const skillsRoot = path.join(repoRoot, "skills");
let rootStat;
try {
rootStat = await fs.lstat(skillsRoot);
} catch {
// A plugin without skills is valid; it just exposes nothing portable here.
return;
}
if (!rootStat.isDirectory()) {
addError("skills exists at the plugin root but is not a directory.");
return;
}
// Resolved once: every nested symlink is compared against it.
const root = await fs.realpath(repoRoot);
const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.isSymbolicLink()) {
addError(
`skills/${entry.name} is a symlink; commit a real directory instead.`,
);
continue;
}
if (!entry.isDirectory()) {
continue;
}
// Stricter than §7.1, which ignores a child directory without a SKILL.md.
// Kept deliberately: validate-skills.mjs runs skills-ref over every child
// of skills/, so such a directory already fails CI. Staying silent here
// would imply it is fine.
await validateSkill(skillsRoot, entry.name);
await validateSkillContainment(path.join(skillsRoot, entry.name), root);
}
}
async function main() {
const manifest = await readJsonFile(
path.join(repoRoot, "plugin.json"),
"Agent Plugins manifest",
);
if (!manifest.found) {
addError("plugin.json is missing from the repository root.");
} else if (manifest.data !== undefined) {
validateManifest(manifest.data);
}
// mcp.json is optional; validate it only when the plugin ships MCP servers.
const mcp = await readJsonFile(
path.join(repoRoot, "mcp.json"),
"Agent Plugins MCP configuration",
);
if (mcp.data !== undefined) {
await validateMcp(mcp.data);
}
await validateSkills();
if (errors.length > 0) {
console.error("Agent Plugins validation failed:");
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
console.log(`Agent Plugins v${SPEC_VERSION} validation passed.`);
}
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});