secufusion-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +519 -0
  2. package/package.json +39 -0
package/index.js ADDED
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SecuFusion MCP Server
4
+ *
5
+ * Provides developer-workflow tooling for the SecuFusion platform:
6
+ * - manage_feature_spec : create / update task blueprints (.current-task-spec.md)
7
+ * - log_rejected_pattern : record bad patterns so they are never repeated (.rejected-patterns.json)
8
+ * - run_pre_pr_checks : gate PRs with automated guardrail validation
9
+ *
10
+ * Guardrails enforced:
11
+ * 1. All DB queries and event payloads must carry tenantId.
12
+ * 2. No console.log (TS/JS) or System.out.println (Java) in source files.
13
+ * 3. No hardcoded UAT/Prod IPs or environment URLs.
14
+ * 4. Every modified JPA @Entity must have a matching Flyway SQL migration.
15
+ */
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { z } from "zod";
19
+ import fs from "fs";
20
+ import path from "path";
21
+ // ─────────────────────────────────────────────
22
+ // Constants / helpers
23
+ // ─────────────────────────────────────────────
24
+ const SPEC_FILE = ".current-task-spec.md";
25
+ const REJECTED_FILE = ".rejected-patterns.json";
26
+ /** Resolve a path relative to cwd (where the MCP server is invoked). */
27
+ function resolve(file) {
28
+ return path.resolve(process.cwd(), file);
29
+ }
30
+ function readFileSafe(filePath) {
31
+ try {
32
+ return fs.readFileSync(filePath, "utf-8");
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function writeFile(filePath, content) {
39
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
40
+ fs.writeFileSync(filePath, content, "utf-8");
41
+ }
42
+ // ─────────────────────────────────────────────
43
+ // manage_feature_spec helpers
44
+ // ─────────────────────────────────────────────
45
+ function buildInitialSpec(taskDescription, workItemId, referenceSnippet) {
46
+ const now = new Date().toISOString();
47
+ const wiLine = workItemId
48
+ ? `**Azure DevOps Work Item:** [${workItemId}](https://dev.azure.com/secufusion/_workitems/edit/${workItemId})\n`
49
+ : "";
50
+ const refSection = referenceSnippet
51
+ ? `\n## Reference Code Pattern\n\n> Extracted from the provided reference file to guide implementation style.\n\n\`\`\`\n${referenceSnippet.slice(0, 800)}\n\`\`\`\n`
52
+ : "";
53
+ return `# SecuFusion Feature Spec
54
+ <!-- AUTO-GENERATED — do not remove the checkboxes; they are used by run_pre_pr_checks -->
55
+
56
+ **Generated:** ${now}
57
+ ${wiLine}
58
+ ## Task Description
59
+
60
+ ${taskDescription}
61
+ ${refSection}
62
+ ## Guardrails Checklist
63
+
64
+ - [ ] All DB queries / event payloads are scoped with \`tenantId\`
65
+ - [ ] No \`console.log\` / \`System.out.println\` left in source files
66
+ - [ ] No hardcoded UAT/Prod IPs or environment URLs
67
+ - [ ] Flyway SQL migration created for every modified JPA \`@Entity\`
68
+ - [ ] Unit tests written / updated for new business logic
69
+ - [ ] API contract (OpenAPI / TS types) updated if endpoints changed
70
+
71
+ ## Acceptance Criteria
72
+
73
+ - [ ] AC-1: (fill in from ticket)
74
+ - [ ] AC-2: (fill in from ticket)
75
+ - [ ] AC-3: (fill in from ticket)
76
+
77
+ ## Implementation Notes
78
+
79
+ > Add architecture decisions, edge-cases, and external dependency notes here.
80
+
81
+ ## Session Log
82
+
83
+ | Timestamp | Update |
84
+ |-----------|--------|
85
+ | ${now} | Spec created |
86
+ `;
87
+ }
88
+ function appendSessionLog(existing, update) {
89
+ const ts = new Date().toISOString();
90
+ const logRow = `| ${ts} | ${update} |`;
91
+ return existing.replace(/(\| [^\n]+ \|)\s*$/, `$1\n${logRow}`);
92
+ }
93
+ // ─────────────────────────────────────────────
94
+ // run_pre_pr_checks helpers
95
+ // ─────────────────────────────────────────────
96
+ const CONSOLE_LOG_PATTERNS = [
97
+ { regex: /console\.log\s*\(/g, label: "console.log()", ext: [".ts", ".tsx", ".js", ".jsx"] },
98
+ { regex: /System\.out\.println\s*\(/g, label: "System.out.println()", ext: [".java"] },
99
+ ];
100
+ const HARDCODED_URL_PATTERN = /(?:https?:\/\/)?(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?|(?:uat|prod|staging|preprod)\.[a-z0-9.-]+\.[a-z]{2,}/gi;
101
+ function walkDir(dir, exts) {
102
+ const results = [];
103
+ if (!fs.existsSync(dir))
104
+ return results;
105
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
106
+ const full = path.join(dir, entry.name);
107
+ if (entry.isDirectory()) {
108
+ if (["node_modules", ".git", "dist", "build", "target", ".idea"].includes(entry.name))
109
+ continue;
110
+ results.push(...walkDir(full, exts));
111
+ }
112
+ else if (exts.some((e) => entry.name.endsWith(e))) {
113
+ results.push(full);
114
+ }
115
+ }
116
+ return results;
117
+ }
118
+ function checkSpecFile() {
119
+ const content = readFileSafe(resolve(SPEC_FILE));
120
+ if (!content)
121
+ return { unchecked: [], missing: true };
122
+ const unchecked = [];
123
+ for (const line of content.split("\n")) {
124
+ const match = line.match(/^- \[ \] (.+)/);
125
+ if (match)
126
+ unchecked.push(match[1].trim());
127
+ }
128
+ return { unchecked, missing: false };
129
+ }
130
+ function checkConsoleLogs(rootDir) {
131
+ const violations = [];
132
+ for (const { regex, label, ext } of CONSOLE_LOG_PATTERNS) {
133
+ const files = walkDir(rootDir, ext);
134
+ for (const file of files) {
135
+ const content = readFileSafe(file);
136
+ if (!content)
137
+ continue;
138
+ const lines = content.split("\n");
139
+ lines.forEach((line, idx) => {
140
+ if (regex.test(line)) {
141
+ violations.push(`${label} found at ${path.relative(rootDir, file)}:${idx + 1}`);
142
+ }
143
+ regex.lastIndex = 0;
144
+ });
145
+ }
146
+ }
147
+ return violations;
148
+ }
149
+ function checkHardcodedUrls(rootDir) {
150
+ const violations = [];
151
+ const exts = [".ts", ".tsx", ".js", ".jsx", ".java", ".properties", ".yml", ".yaml"];
152
+ const files = walkDir(rootDir, exts);
153
+ for (const file of files) {
154
+ const content = readFileSafe(file);
155
+ if (!content)
156
+ continue;
157
+ const matches = content.match(HARDCODED_URL_PATTERN);
158
+ if (matches) {
159
+ violations.push(`Hardcoded URL/IP in ${path.relative(rootDir, file)}: ${[...new Set(matches)].join(", ")}`);
160
+ }
161
+ }
162
+ return violations;
163
+ }
164
+ function checkFlywayMigrations(rootDir) {
165
+ const warnings = [];
166
+ const javaFiles = walkDir(rootDir, [".java"]);
167
+ const entityFiles = javaFiles.filter((f) => {
168
+ const content = readFileSafe(f);
169
+ return content && /@Entity\b/.test(content);
170
+ });
171
+ if (entityFiles.length === 0)
172
+ return warnings;
173
+ const flywayDirs = [
174
+ path.join(rootDir, "src", "main", "resources", "db", "migration"),
175
+ path.join(rootDir, "src", "main", "resources", "db", "migrations"),
176
+ path.join(rootDir, "resources", "db", "migration"),
177
+ ];
178
+ const flywayDir = flywayDirs.find((d) => fs.existsSync(d));
179
+ if (!flywayDir) {
180
+ warnings.push(`Found ${entityFiles.length} JPA @Entity file(s) but no Flyway migration directory detected. ` +
181
+ `Expected at: src/main/resources/db/migration/`);
182
+ return warnings;
183
+ }
184
+ const migrationFiles = fs.readdirSync(flywayDir).filter((f) => /^V\d+__.*\.sql$/i.test(f));
185
+ if (migrationFiles.length === 0) {
186
+ warnings.push(`Found ${entityFiles.length} JPA @Entity file(s) but no Flyway SQL migrations exist in ${flywayDir}`);
187
+ }
188
+ return warnings;
189
+ }
190
+ // ─────────────────────────────────────────────
191
+ // MCP Server bootstrap
192
+ // ─────────────────────────────────────────────
193
+ const server = new McpServer({
194
+ name: "secufusion-mcp",
195
+ version: "1.0.0",
196
+ });
197
+ // ─── Tool 1: manage_feature_spec ─────────────────────────────────────────────
198
+ server.tool("manage_feature_spec", "Create or update the .current-task-spec.md task blueprint. " +
199
+ "Pass `task_description` to create a new spec, or `update_content` to patch the existing one " +
200
+ "(e.g., checking off completed acceptance criteria). Optionally provide `reference_file_path` " +
201
+ "so the spec generator can observe existing coding patterns.", {
202
+ task_description: z
203
+ .string()
204
+ .optional()
205
+ .describe("Full description of the new feature/task (used when creating a spec from scratch)."),
206
+ update_content: z
207
+ .string()
208
+ .optional()
209
+ .describe("Partial markdown update / session-log entry to merge into the existing spec. " +
210
+ "Use to tick checkboxes: replace '- [ ] AC-1' with '- [x] AC-1'."),
211
+ work_item_id: z
212
+ .string()
213
+ .optional()
214
+ .describe("Azure DevOps work item ID to embed in the spec header."),
215
+ reference_file_path: z
216
+ .string()
217
+ .optional()
218
+ .describe("Absolute or cwd-relative path to an existing source file whose coding patterns should " +
219
+ "be embedded as a reference snippet in the new spec."),
220
+ action: z
221
+ .enum(["create", "update", "read"])
222
+ .default("create")
223
+ .describe("'create' generates a new spec (overwrites if one exists), " +
224
+ "'update' patches the existing spec, " +
225
+ "'read' returns the current spec content without modification."),
226
+ }, async ({ task_description, update_content, work_item_id, reference_file_path, action }) => {
227
+ const specPath = resolve(SPEC_FILE);
228
+ if (action === "read") {
229
+ const content = readFileSafe(specPath);
230
+ if (!content) {
231
+ return {
232
+ content: [
233
+ {
234
+ type: "text",
235
+ text: `No spec file found at ${SPEC_FILE}. Run with action='create' to generate one.`,
236
+ },
237
+ ],
238
+ };
239
+ }
240
+ return { content: [{ type: "text", text: content }] };
241
+ }
242
+ if (action === "create") {
243
+ if (!task_description) {
244
+ return {
245
+ content: [
246
+ {
247
+ type: "text",
248
+ text: "ERROR: `task_description` is required when action='create'.",
249
+ },
250
+ ],
251
+ isError: true,
252
+ };
253
+ }
254
+ let refSnippet = null;
255
+ if (reference_file_path) {
256
+ refSnippet = readFileSafe(path.isAbsolute(reference_file_path)
257
+ ? reference_file_path
258
+ : resolve(reference_file_path));
259
+ }
260
+ const spec = buildInitialSpec(task_description, work_item_id, refSnippet);
261
+ writeFile(specPath, spec);
262
+ return {
263
+ content: [
264
+ {
265
+ type: "text",
266
+ text: `✅ Spec created at \`${SPEC_FILE}\`.\n\n` +
267
+ `Review and fill in the Acceptance Criteria before starting implementation.\n\n` +
268
+ `---\n\n${spec}`,
269
+ },
270
+ ],
271
+ };
272
+ }
273
+ if (action === "update") {
274
+ if (!update_content) {
275
+ return {
276
+ content: [
277
+ {
278
+ type: "text",
279
+ text: "ERROR: `update_content` is required when action='update'.",
280
+ },
281
+ ],
282
+ isError: true,
283
+ };
284
+ }
285
+ const existing = readFileSafe(specPath);
286
+ if (!existing) {
287
+ return {
288
+ content: [
289
+ {
290
+ type: "text",
291
+ text: `ERROR: No spec file found at ${SPEC_FILE}. Create one first with action='create'.`,
292
+ },
293
+ ],
294
+ isError: true,
295
+ };
296
+ }
297
+ let updated = existing;
298
+ for (const line of update_content.split("\n")) {
299
+ const checkedMatch = line.match(/^- \[x\] (.+)/);
300
+ if (checkedMatch) {
301
+ const label = checkedMatch[1].trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
302
+ updated = updated.replace(new RegExp(`- \\[ \\] ${label}`, "g"), `- [x] ${checkedMatch[1].trim()}`);
303
+ }
304
+ }
305
+ updated = appendSessionLog(updated, update_content.replace(/\n/g, " ").slice(0, 120));
306
+ writeFile(specPath, updated);
307
+ return {
308
+ content: [
309
+ {
310
+ type: "text",
311
+ text: `✅ Spec updated at \`${SPEC_FILE}\`.\n\n---\n\n${updated}`,
312
+ },
313
+ ],
314
+ };
315
+ }
316
+ return {
317
+ content: [{ type: "text", text: "Unknown action." }],
318
+ isError: true,
319
+ };
320
+ });
321
+ // ─── Tool 2: log_rejected_pattern ────────────────────────────────────────────
322
+ server.tool("log_rejected_pattern", "Record a coding pattern that was rejected by the team so it is never repeated. " +
323
+ "Appends to .rejected-patterns.json which is checked implicitly before every architectural suggestion.", {
324
+ pattern: z
325
+ .string()
326
+ .describe("The exact bad pattern, approach, or code construct that was rejected."),
327
+ reason: z
328
+ .string()
329
+ .describe("Why it was rejected and what the correct alternative is."),
330
+ category: z
331
+ .enum([
332
+ "architecture",
333
+ "security",
334
+ "database",
335
+ "logging",
336
+ "api-design",
337
+ "testing",
338
+ "other",
339
+ ])
340
+ .default("other")
341
+ .describe("Classification to make future lookup easier."),
342
+ file_context: z
343
+ .string()
344
+ .optional()
345
+ .describe("Optional: the file or code area where this was observed."),
346
+ }, async ({ pattern, reason, category, file_context }) => {
347
+ const rejectedPath = resolve(REJECTED_FILE);
348
+ let records = [];
349
+ const existing = readFileSafe(rejectedPath);
350
+ if (existing) {
351
+ try {
352
+ records = JSON.parse(existing);
353
+ }
354
+ catch {
355
+ records = [];
356
+ }
357
+ }
358
+ const newEntry = {
359
+ id: records.length + 1,
360
+ timestamp: new Date().toISOString(),
361
+ category,
362
+ pattern,
363
+ reason,
364
+ ...(file_context ? { file_context } : {}),
365
+ };
366
+ records.push(newEntry);
367
+ writeFile(rejectedPath, JSON.stringify(records, null, 2));
368
+ return {
369
+ content: [
370
+ {
371
+ type: "text",
372
+ text: `✅ Rejected pattern #${newEntry.id} logged to \`${REJECTED_FILE}\`.\n\n` +
373
+ `**Category:** ${category}\n` +
374
+ `**Pattern:** ${pattern}\n` +
375
+ `**Reason:** ${reason}\n\n` +
376
+ `This will be checked automatically in all future architectural suggestions.`,
377
+ },
378
+ ],
379
+ };
380
+ });
381
+ // ─── Tool 3: run_pre_pr_checks ───────────────────────────────────────────────
382
+ server.tool("run_pre_pr_checks", "Run all SecuFusion pre-PR guardrail checks against the current working directory. " +
383
+ "Validates: spec checkboxes, console.log/System.out.println usage, hardcoded IPs/URLs, " +
384
+ "and Flyway migration coverage for JPA entities. " +
385
+ "MUST pass (exit with no errors) before a PR is raised.", {
386
+ work_item_id: z
387
+ .string()
388
+ .describe("Azure DevOps work item ID to include in the PR summary."),
389
+ root_dir: z
390
+ .string()
391
+ .optional()
392
+ .describe("Root directory to scan (defaults to cwd). Use when the source repo is in a sub-folder."),
393
+ skip_checks: z
394
+ .array(z.enum([
395
+ "spec_boxes",
396
+ "console_logs",
397
+ "hardcoded_urls",
398
+ "flyway_migrations",
399
+ ]))
400
+ .optional()
401
+ .describe("Explicitly skip specific checks. Use sparingly — document the reason in your PR description."),
402
+ }, async ({ work_item_id, root_dir, skip_checks = [] }) => {
403
+ const scanRoot = root_dir ? path.resolve(root_dir) : process.cwd();
404
+ const errors = [];
405
+ const warnings = [];
406
+ const passed = [];
407
+ // ── 1. Spec checkbox check ─────────────────────────────────────────────
408
+ if (!skip_checks.includes("spec_boxes")) {
409
+ const { unchecked, missing } = checkSpecFile();
410
+ if (missing) {
411
+ errors.push(`❌ [SPEC] No \`.current-task-spec.md\` found. ` +
412
+ `Call \`manage_feature_spec\` with action='create' before raising a PR.`);
413
+ }
414
+ else if (unchecked.length > 0) {
415
+ errors.push(`❌ [SPEC] ${unchecked.length} unchecked item(s) in the spec:\n` +
416
+ unchecked.map((u) => ` • ${u}`).join("\n"));
417
+ }
418
+ else {
419
+ passed.push("✅ [SPEC] All spec checkboxes are checked.");
420
+ }
421
+ }
422
+ else {
423
+ warnings.push("⚠️ [SPEC] Spec checkbox check SKIPPED (manually bypassed).");
424
+ }
425
+ // ── 2. Console log check ───────────────────────────────────────────────
426
+ if (!skip_checks.includes("console_logs")) {
427
+ const logViolations = checkConsoleLogs(scanRoot);
428
+ if (logViolations.length > 0) {
429
+ errors.push(`❌ [LOGGING] Found ${logViolations.length} prohibited log statement(s):\n` +
430
+ logViolations.map((v) => ` • ${v}`).join("\n"));
431
+ }
432
+ else {
433
+ passed.push("✅ [LOGGING] No console.log / System.out.println found.");
434
+ }
435
+ }
436
+ else {
437
+ warnings.push("⚠️ [LOGGING] Console log check SKIPPED (manually bypassed).");
438
+ }
439
+ // ── 3. Hardcoded URL check ─────────────────────────────────────────────
440
+ if (!skip_checks.includes("hardcoded_urls")) {
441
+ const urlViolations = checkHardcodedUrls(scanRoot);
442
+ if (urlViolations.length > 0) {
443
+ errors.push(`❌ [SECURITY] Found ${urlViolations.length} hardcoded URL/IP(s):\n` +
444
+ urlViolations.map((v) => ` • ${v}`).join("\n"));
445
+ }
446
+ else {
447
+ passed.push("✅ [SECURITY] No hardcoded UAT/Prod IPs or environment URLs found.");
448
+ }
449
+ }
450
+ else {
451
+ warnings.push("⚠️ [SECURITY] Hardcoded URL check SKIPPED (manually bypassed).");
452
+ }
453
+ // ── 4. Flyway migration check ──────────────────────────────────────────
454
+ if (!skip_checks.includes("flyway_migrations")) {
455
+ const flywayWarnings = checkFlywayMigrations(scanRoot);
456
+ if (flywayWarnings.length > 0) {
457
+ errors.push(...flywayWarnings.map((w) => `❌ [FLYWAY] ${w}`));
458
+ }
459
+ else {
460
+ passed.push("✅ [FLYWAY] Flyway migration coverage looks good.");
461
+ }
462
+ }
463
+ else {
464
+ warnings.push("⚠️ [FLYWAY] Flyway migration check SKIPPED (manually bypassed).");
465
+ }
466
+ // ── Surface rejected patterns as a reminder ───────────────────────────
467
+ const rejectedContent = readFileSafe(resolve(REJECTED_FILE));
468
+ let rejectedReminder = "";
469
+ if (rejectedContent) {
470
+ try {
471
+ const rejected = JSON.parse(rejectedContent);
472
+ if (rejected.length > 0) {
473
+ rejectedReminder =
474
+ `\n\n---\n### 🚫 Rejected Patterns Reminder (${rejected.length} on record)\n` +
475
+ rejected.map((r) => `- [${r.category}] ${r.pattern}`).join("\n");
476
+ }
477
+ }
478
+ catch {
479
+ /* ignore parse errors */
480
+ }
481
+ }
482
+ // ── Build final report ─────────────────────────────────────────────────
483
+ const hasErrors = errors.length > 0;
484
+ const timestamp = new Date().toISOString();
485
+ let report;
486
+ if (hasErrors) {
487
+ report =
488
+ `# ❌ Pre-PR Checks FAILED — Work Item #${work_item_id}\n\n` +
489
+ `**Scanned directory:** \`${scanRoot}\`\n` +
490
+ `**Timestamp:** ${timestamp}\n\n` +
491
+ `## Errors (must fix before PR)\n\n${errors.join("\n\n")}\n\n` +
492
+ (warnings.length > 0 ? `## Warnings\n\n${warnings.join("\n")}\n\n` : "") +
493
+ (passed.length > 0 ? `## Passed\n\n${passed.join("\n")}\n` : "") +
494
+ rejectedReminder;
495
+ }
496
+ else {
497
+ report =
498
+ `# ✅ Pre-PR Checks PASSED — Work Item #${work_item_id}\n\n` +
499
+ `**Scanned directory:** \`${scanRoot}\`\n` +
500
+ `**Timestamp:** ${timestamp}\n\n` +
501
+ `## All Checks Passed\n\n${passed.join("\n")}\n\n` +
502
+ (warnings.length > 0 ? `## Warnings\n\n${warnings.join("\n")}\n\n` : "") +
503
+ `## PR Summary\n\n` +
504
+ `- **Work Item:** [#${work_item_id}](https://dev.azure.com/secufusion/_workitems/edit/${work_item_id})\n` +
505
+ `- **Spec:** All acceptance criteria verified ✅\n` +
506
+ `- **Guardrails:** tenantId scoping, no debug logs, no hardcoded URLs, Flyway covered ✅\n` +
507
+ `- **Ready to raise PR** 🚀\n` +
508
+ rejectedReminder;
509
+ }
510
+ return {
511
+ content: [{ type: "text", text: report }],
512
+ isError: hasErrors,
513
+ };
514
+ });
515
+ // ─────────────────────────────────────────────
516
+ // Start transport
517
+ // ─────────────────────────────────────────────
518
+ const transport = new StdioServerTransport();
519
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "secufusion-mcp",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "SecuFusion MCP server - developer workflow tooling with guardrails",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "index.d.ts",
10
+ "README.md"
11
+ ],
12
+ "bin": {
13
+ "secufusion-mcp": "index.js"
14
+ },
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "prepublishOnly": "npm run build",
21
+ "start": "node index.js",
22
+ "dev": "tsc && node index.js"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "secufusion",
27
+ "developer-tools"
28
+ ],
29
+ "author": "",
30
+ "license": "ISC",
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.0.0",
33
+ "zod": "^3.23.8"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "typescript": "^5.0.0"
38
+ }
39
+ }