ubon
Version:
Security scanner for AI-generated apps (Cursor, Lovable, Windsurf, v0). Catches hardcoded secrets, prompt injection, hallucinated imports, Server Actions / Edge runtime mistakes, and the vibe-coded vulnerabilities traditional linters miss.
448 lines • 18 kB
JavaScript
;
/**
* Ubon MCP server.
*
* Exposes the Ubon scanner over the Model Context Protocol so AI assistants
* (Cursor, Claude Desktop, Windsurf, Cline, OpenAI Apps) can call:
*
* ubon.scan — run a full scan and return the v2.0.0 JSON report
* ubon.check — alias for scan with skipBuild=true (cheap loop)
* ubon.explain — return rule metadata + remediation hints
* ubon.preview-fixes — return file-level diffs for auto-fixable findings
* ubon.apply-fixes — write the auto-fixes to disk (gated by `--apply`)
*
* The `@modelcontextprotocol/sdk` is an *optional* dependency. We import it
* dynamically and degrade gracefully if it isn't installed, so users who
* never touch MCP don't carry the extra weight.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.MCP_TEST_HANDLERS = void 0;
exports.startMcpServer = startMcpServer;
const fs_1 = require("fs");
const path_1 = require("path");
const __1 = require("..");
const rules_1 = require("../rules");
const fix_1 = require("../utils/fix");
const git_1 = require("../utils/git");
const issue_context_1 = require("../utils/issue-context");
function resolveDirectory(input) {
return (0, path_1.resolve)(input || process.cwd());
}
function stableStringify(value, indent = 2) {
const replacer = (_key, v) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.keys(v)
.sort()
.reduce((acc, k) => {
acc[k] = v[k];
return acc;
}, {});
}
return v;
};
return JSON.stringify(value, replacer, indent);
}
function json(payload) {
return { content: [{ type: 'text', text: stableStringify(payload, 2) }] };
}
function buildScanOptions(args) {
const directory = resolveDirectory(args.directory);
const changedFiles = args.changedFiles && args.changedFiles.length > 0
? args.changedFiles
: (args.gitChangedSince || args.baseSha)
? (0, git_1.getChangedFilesSince)((args.gitChangedSince || args.baseSha), directory)
: undefined;
return {
directory,
profile: args.profile || 'auto',
fast: !!args.fast,
skipBuild: true,
minConfidence: typeof args.minConfidence === 'number' ? args.minConfidence : undefined,
changedFiles,
gitChangedSince: args.gitChangedSince || args.baseSha,
enabledRules: args.enabledRules,
disabledRules: args.disabledRules,
baselinePath: args.baseline,
focusNew: !!args.focusNew,
focusSecurity: !!args.focusSecurity,
focusCritical: !!args.focusCritical,
showContext: !!args.showContext,
explain: !!args.explain,
quiet: true
};
}
async function runScan(args) {
const scanner = new __1.UbonScan(false, true);
const options = buildScanOptions(args);
const results = await scanner.diagnose(options);
const payload = {
schemaVersion: '2.0.0',
toolVersion: require('../../package.json').version,
summary: {
total: results.length,
errors: results.filter((r) => r.type === 'error').length,
warnings: results.filter((r) => r.type === 'warning').length,
info: results.filter((r) => r.type === 'info').length
},
issues: results.map((result) => ({
...result,
context: args.showContext ? (0, issue_context_1.buildIssueContext)(options.directory, result.file, result.line) : undefined
}))
};
return json(payload);
}
async function runExplain(args) {
const id = args.ruleId;
if (!id) {
return {
content: [{ type: 'text', text: stableStringify({ error: 'missing ruleId' }) }]
};
}
const rule = (0, rules_1.getRule)(id);
if (!rule) {
return {
content: [
{ type: 'text', text: stableStringify({ error: `unknown rule ${id}`, knownRules: Object.keys(rules_1.RULES).sort() }) }
]
};
}
return {
content: [
{
type: 'text',
text: stableStringify({
id: rule.meta.id,
category: rule.meta.category,
severity: rule.meta.severity,
message: rule.meta.message,
fix: rule.meta.fix,
impact: rule.meta.impact,
helpUri: rule.meta.helpUri
})
}
]
};
}
async function runPreviewFixes(args) {
const scanner = new __1.UbonScan(false, true);
const options = buildScanOptions(args);
const results = await scanner.diagnose(options);
const previews = (0, fix_1.previewFixes)(results, options.directory);
return json({ previews });
}
async function runApplyFixes(args) {
const scanner = new __1.UbonScan(false, true);
const options = buildScanOptions(args);
const results = await scanner.diagnose(options);
// `apply: false` keeps this tool safe by default — clients must opt in.
const dryRun = args.apply !== true;
const { changedFiles, appliedEditCount } = (0, fix_1.applyFixes)(results, options.directory, dryRun);
return {
content: [
{
type: 'text',
text: stableStringify({ dryRun, changedFiles, appliedEditCount })
}
]
};
}
async function runPlanFixes(args) {
const scanner = new __1.UbonScan(false, true);
const options = buildScanOptions({ ...args, fast: true });
const results = await scanner.diagnose(options);
const steps = results
.filter((result) => result.fix)
.map((result) => ({
ruleId: result.ruleId,
severity: result.severity,
confidence: result.confidence,
file: result.file,
line: result.line,
fix: result.fix,
autofixable: !!result.fixEdits?.length
}));
return json({ steps });
}
async function runStatus(args) {
const directory = resolveDirectory(args.directory);
return json({
directory,
toolVersion: require('../../package.json').version,
config: {
json: (0, fs_1.existsSync)((0, path_1.join)(directory, 'ubon.config.json')),
js: (0, fs_1.existsSync)((0, path_1.join)(directory, 'ubon.config.js')),
packageJson: (0, fs_1.existsSync)((0, path_1.join)(directory, 'package.json'))
},
harness: {
cursorHooks: (0, fs_1.existsSync)((0, path_1.join)(directory, '.cursor', 'hooks.json')),
cursorRule: (0, fs_1.existsSync)((0, path_1.join)(directory, '.cursor', 'rules', 'ubon.mdc')),
agentsMd: (0, fs_1.existsSync)((0, path_1.join)(directory, 'AGENTS.md')),
claudeMd: (0, fs_1.existsSync)((0, path_1.join)(directory, 'CLAUDE.md')),
preCommit: (0, fs_1.existsSync)((0, path_1.join)(directory, '.pre-commit-config.yaml')),
githubWorkflow: (0, fs_1.existsSync)((0, path_1.join)(directory, '.github', 'workflows', 'ubon.yml')),
baseline: (0, fs_1.existsSync)((0, path_1.join)(directory, '.ubon.baseline.json')),
cacheIgnored: (0, fs_1.existsSync)((0, path_1.join)(directory, '.gitignore')) &&
(0, fs_1.readFileSync)((0, path_1.join)(directory, '.gitignore'), 'utf-8').split(/\r?\n/).includes('.ubon/')
},
ruleCount: Object.keys(rules_1.RULES).length
});
}
async function runRuleCatalog() {
return json({
rules: Object.values(rules_1.RULES)
.map((rule) => ({
id: rule.id,
category: rule.category,
severity: rule.severity,
message: rule.message,
fix: rule.fix,
helpUri: rule.helpUri
}))
.sort((a, b) => a.id.localeCompare(b.id))
});
}
async function runVerify(args) {
const scanner = new __1.UbonScan(false, true);
const options = buildScanOptions({ ...args, fast: true, focusCritical: args.focusCritical ?? true });
const results = await scanner.diagnose(options);
const errors = results.filter((r) => r.type === 'error').length;
const warnings = results.filter((r) => r.type === 'warning').length;
const failOn = args.failOn || 'error';
const ok = failOn === 'none' || (failOn === 'error' ? errors === 0 : errors + warnings === 0);
return json({
ok,
failOn,
summary: {
total: results.length,
errors,
warnings,
info: results.filter((r) => r.type === 'info').length
},
issues: results
});
}
const TOOLS = [
{
name: 'ubon.scan',
description: 'Run a full Ubon scan on the given directory. Returns the v2.0.0 JSON report (use the schema at docs/schema/ubon-finding.schema.json).',
inputSchema: {
type: 'object',
properties: {
directory: { type: 'string', description: 'Absolute path to scan (defaults to CWD).' },
profile: { type: 'string', enum: ['auto', 'lovable', 'react', 'next', 'sveltekit', 'astro', 'remix', 'hono'] },
fast: { type: 'boolean', description: 'Skip OSV / link checks for a faster loop.' },
minConfidence: { type: 'number', minimum: 0, maximum: 1 },
changedFiles: { type: 'array', items: { type: 'string' } },
gitChangedSince: { type: 'string' },
baseSha: { type: 'string' },
enabledRules: { type: 'array', items: { type: 'string' } },
disabledRules: { type: 'array', items: { type: 'string' } },
baseline: { type: 'string' },
focusNew: { type: 'boolean' },
focusSecurity: { type: 'boolean' },
focusCritical: { type: 'boolean' },
showContext: { type: 'boolean' },
explain: { type: 'boolean' }
}
},
handler: runScan
},
{
name: 'ubon.check',
description: 'Cheap static-analysis-only scan. Same shape as ubon.scan.',
inputSchema: {
type: 'object',
properties: {
directory: { type: 'string' },
profile: { type: 'string' },
minConfidence: { type: 'number', minimum: 0, maximum: 1 },
changedFiles: { type: 'array', items: { type: 'string' } },
gitChangedSince: { type: 'string' },
baseSha: { type: 'string' },
enabledRules: { type: 'array', items: { type: 'string' } },
disabledRules: { type: 'array', items: { type: 'string' } },
focusSecurity: { type: 'boolean' },
focusCritical: { type: 'boolean' },
showContext: { type: 'boolean' },
explain: { type: 'boolean' }
}
},
handler: (args) => runScan({ ...args, fast: true })
},
{
name: 'ubon.explain',
description: 'Return metadata for a rule (severity, fix, impact, helpUri).',
inputSchema: {
type: 'object',
properties: { ruleId: { type: 'string' } },
required: ['ruleId']
},
handler: runExplain
},
{
name: 'ubon.preview-fixes',
description: 'Return file-level diffs for auto-fixable findings without writing to disk.',
inputSchema: {
type: 'object',
properties: {
directory: { type: 'string' },
profile: { type: 'string' }
}
},
handler: runPreviewFixes
},
{
name: 'ubon.apply-fixes',
description: 'Apply auto-fixes. Defaults to dry-run; pass `apply: true` to write changes to disk.',
inputSchema: {
type: 'object',
properties: {
directory: { type: 'string' },
profile: { type: 'string' },
apply: { type: 'boolean' }
}
},
handler: runApplyFixes
},
{
name: 'ubon.plan-fixes',
description: 'Return ordered fix steps for current findings without writing to disk.',
inputSchema: {
type: 'object',
properties: {
directory: { type: 'string' },
profile: { type: 'string' },
changedFiles: { type: 'array', items: { type: 'string' } },
gitChangedSince: { type: 'string' },
baseSha: { type: 'string' }
}
},
handler: runPlanFixes
},
{
name: 'ubon.status',
description: 'Return Ubon config and agent-harness status for the project.',
inputSchema: {
type: 'object',
properties: {
directory: { type: 'string' }
}
},
handler: runStatus
},
{
name: 'ubon.rule-catalog',
description: 'Return the machine-readable rule catalog.',
inputSchema: {
type: 'object',
properties: {}
},
handler: runRuleCatalog
},
{
name: 'ubon.verify',
description: 'Run a compact deterministic verification gate for agents and hooks.',
inputSchema: {
type: 'object',
properties: {
directory: { type: 'string' },
profile: { type: 'string' },
changedFiles: { type: 'array', items: { type: 'string' } },
gitChangedSince: { type: 'string' },
baseSha: { type: 'string' },
failOn: { type: 'string', enum: ['none', 'warning', 'error'] },
focusCritical: { type: 'boolean' }
}
},
handler: runVerify
}
];
exports.MCP_TEST_HANDLERS = Object.fromEntries(TOOLS.map((tool) => [tool.name, tool.handler]));
async function startMcpServer() {
// Dynamic import to keep the SDK truly optional.
let McpServer;
let StdioServerTransport;
try {
const serverMod = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/server/index.js')));
const stdioMod = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/server/stdio.js')));
McpServer = serverMod.Server;
StdioServerTransport = stdioMod.StdioServerTransport;
}
catch (err) {
process.stderr.write('🪷 ubon mcp: the optional dependency `@modelcontextprotocol/sdk` is not installed.\n' +
' It ships as an optionalDependency of `ubon`, so this only happens if your\n' +
' install skipped optional deps (`npm install --no-optional`, container images,\n' +
' some lockfile tooling). Install it manually:\n' +
' npm install -g @modelcontextprotocol/sdk\n' +
' See docs/MCP.md for details.\n');
process.exit(1);
}
const server = new McpServer({
name: 'ubon',
version: require('../../package.json').version
}, {
capabilities: { tools: {} }
});
// Register tool list + dispatcher. The SDK exposes both `setRequestHandler`
// and a higher-level helper depending on version; we use the request-handler
// form for maximum portability across SDK minor versions.
const typesMod = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/types.js')));
server.setRequestHandler(typesMod.ListToolsRequestSchema, async () => ({
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))
}));
server.setRequestHandler(typesMod.CallToolRequestSchema, async (request) => {
const tool = TOOLS.find((t) => t.name === request.params.name);
if (!tool) {
return {
content: [{ type: 'text', text: JSON.stringify({ error: `unknown tool ${request.params.name}` }) }],
isError: true
};
}
try {
return await tool.handler(request.params.arguments || {});
}
catch (err) {
return {
content: [{ type: 'text', text: JSON.stringify({ error: err?.message || String(err) }) }],
isError: true
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write('🪷 ubon mcp: connected over stdio\n');
}
//# sourceMappingURL=server.js.map