Problem
The rule engine was designed to take rules as input, and no user can supply any.
Engine in src/engine/engine.ts takes its rules by constructor injection:
export class Engine {
private readonly rules: Rule[];
constructor(rules: Rule[]) { this.rules = rules; }
but src/cli.ts only ever passes ALL_RULES from src/rules/index.ts, and McpAuditConfig in src/config.ts has no key for loading anything else. The config supports disabledRules, enabledRules, severityOverrides, failOn, and ignore: five ways to turn built-in rules down, and no way to add one.
So the extension point exists in the type signature, is exercised by test/engine.test.ts, and is unreachable for anyone who installs the package. The Rule type and createRuleContext are already the whole contract a third party rule would need.
Why it matters
Every linter that got adopted got adopted because teams could encode their own policy in it. The organization-specific rules are the ones nobody upstream will ever write: "no tool may be named deploy_* without a confirmation parameter", "every resource URI must be under our namespace", "this internal server must never expose a filesystem tool". Today the only options are to fork the package or to not use it.
It is also how the rule catalog grows. Contributors who write a rule for their own use are the natural source of the next built-in rule.
Suggested approach
- Add a
rules (or plugins) key to McpAuditConfig: an array of module specifiers, resolved relative to the config file's directory.
- Load them with dynamic
import() in loadConfig or a new loadRules module. Each module default-exports a Rule[]. Since the package is ESM, this is a few lines.
- Validate what comes back: every entry must have an id, a severity in
ALL_SEVERITIES, a category, and an evaluate function. Reject a plugin whose rule id collides with a built-in one, or with another plugin, with a clear error naming both sources.
- Reserve the
MCP id prefix for built-ins and require custom rules to use a different prefix, so the SARIF rule catalog stays unambiguous.
- Make sure the existing safety net still applies:
Engine.run already wraps rule.evaluate in try/catch and converts a throw into an info finding, which is exactly the right behavior for third party code.
- Document it in the README with one complete worked example, and export the
Rule and rule-context types from src/index.ts so plugin authors can type against them.
- Add a fixture plugin under
fixtures/ and test: it loads, its findings appear, --disable can turn it off, severityOverrides applies to it, an id collision errors, and a throwing plugin does not crash the audit.
Done when
- A user can add a rule from a config file without forking.
- Plugin rules participate in
--only, --disable, severityOverrides, and SARIF output exactly like built-ins.
- Invalid or colliding plugins fail loudly at load time.
- The README has a worked example.
Design first please: comment with the config shape and the module contract you propose before writing it, since this becomes a public interface the moment it ships.
If you want to take this on, comment on the issue to claim it and it will be assigned. Please keep to a maximum of 2 open claims per person at a time so other contributors get a chance.
Problem
The rule engine was designed to take rules as input, and no user can supply any.
Engineinsrc/engine/engine.tstakes its rules by constructor injection:but
src/cli.tsonly ever passesALL_RULESfromsrc/rules/index.ts, andMcpAuditConfiginsrc/config.tshas no key for loading anything else. The config supportsdisabledRules,enabledRules,severityOverrides,failOn, andignore: five ways to turn built-in rules down, and no way to add one.So the extension point exists in the type signature, is exercised by
test/engine.test.ts, and is unreachable for anyone who installs the package. TheRuletype andcreateRuleContextare already the whole contract a third party rule would need.Why it matters
Every linter that got adopted got adopted because teams could encode their own policy in it. The organization-specific rules are the ones nobody upstream will ever write: "no tool may be named
deploy_*without a confirmation parameter", "every resource URI must be under our namespace", "this internal server must never expose a filesystem tool". Today the only options are to fork the package or to not use it.It is also how the rule catalog grows. Contributors who write a rule for their own use are the natural source of the next built-in rule.
Suggested approach
rules(orplugins) key toMcpAuditConfig: an array of module specifiers, resolved relative to the config file's directory.import()inloadConfigor a newloadRulesmodule. Each module default-exports aRule[]. Since the package is ESM, this is a few lines.ALL_SEVERITIES, a category, and anevaluatefunction. Reject a plugin whose rule id collides with a built-in one, or with another plugin, with a clear error naming both sources.MCPid prefix for built-ins and require custom rules to use a different prefix, so the SARIF rule catalog stays unambiguous.Engine.runalready wrapsrule.evaluatein try/catch and converts a throw into an info finding, which is exactly the right behavior for third party code.Ruleand rule-context types fromsrc/index.tsso plugin authors can type against them.fixtures/and test: it loads, its findings appear,--disablecan turn it off,severityOverridesapplies to it, an id collision errors, and a throwing plugin does not crash the audit.Done when
--only,--disable,severityOverrides, and SARIF output exactly like built-ins.Design first please: comment with the config shape and the module contract you propose before writing it, since this becomes a public interface the moment it ships.
If you want to take this on, comment on the issue to claim it and it will be assigned. Please keep to a maximum of 2 open claims per person at a time so other contributors get a chance.