septum 0.1.1 → 0.1.3

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.
package/src/mcp/server.ts CHANGED
@@ -54,12 +54,51 @@ import {
54
54
  TraceVerticalSliceSchema,
55
55
  } from "./schemas.ts";
56
56
  import { SEPTUM_VERSION } from "../version.ts";
57
+ import { resolveWorkspaceRoot } from "../core/resolver/path-resolver.ts";
58
+ import type { ValidatedSeptumConfig } from "../core/config/schema.ts";
57
59
 
58
- export async function runMCPServer(): Promise<void> {
59
- const config = ConfigLoader.load();
60
+ interface WorkspaceContext {
61
+ root: string;
62
+ config: ValidatedSeptumConfig;
63
+ db: SeptumDatabase;
64
+ repo: SeptumRepository;
65
+ evaluator: BoundaryEvaluator;
66
+ pipeline: IngestionPipeline;
67
+ }
68
+
69
+ const workspaceContextCache = new Map<string, WorkspaceContext>();
70
+
71
+ function extractPathHint(args: Record<string, any> | undefined): string | undefined {
72
+ if (!args) return undefined;
73
+ if (typeof args.workspace_path === "string" && args.workspace_path) return args.workspace_path;
74
+ if (typeof args.target_path === "string" && args.target_path) return args.target_path;
75
+ if (typeof args.project_root === "string" && args.project_root) return args.project_root;
76
+ if (typeof args.file_path === "string" && args.file_path) return args.file_path;
77
+ if (Array.isArray(args.file_paths) && args.file_paths[0]) return args.file_paths[0];
78
+ if (typeof args.root === "string" && args.root) return args.root;
79
+ return undefined;
80
+ }
81
+
82
+ function getWorkspaceContext(pathHint?: string): WorkspaceContext {
83
+ const root = resolveWorkspaceRoot(pathHint);
84
+ let ctx = workspaceContextCache.get(root);
85
+ if (ctx) {
86
+ return ctx;
87
+ }
88
+
89
+ const config = ConfigLoader.load(undefined, root);
60
90
  const db = new SeptumDatabase(config.settings.db_path);
61
91
  const repo = new SeptumRepository(db.raw);
62
92
  const evaluator = new BoundaryEvaluator(repo);
93
+ const pipeline = new IngestionPipeline(repo, root);
94
+
95
+ ctx = { root, config, db, repo, evaluator, pipeline };
96
+ workspaceContextCache.set(root, ctx);
97
+ return ctx;
98
+ }
99
+
100
+ export async function runMCPServer(): Promise<void> {
101
+ const initialCtx = getWorkspaceContext();
63
102
 
64
103
  const server = new Server(
65
104
  {
@@ -297,7 +336,10 @@ export async function runMCPServer(): Promise<void> {
297
336
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
298
337
  const { name, arguments: args } = request.params;
299
338
  const startTime = performance.now();
300
- const activeSession = SessionManager.getActiveSession(process.cwd());
339
+ const pathHint = extractPathHint(args as Record<string, any>);
340
+ const ctx = getWorkspaceContext(pathHint);
341
+
342
+ const activeSession = SessionManager.getActiveSession(ctx.root);
301
343
  const sessionPayload = activeSession
302
344
  ? { feature_key: activeSession.feature_key, domain: activeSession.domain }
303
345
  : null;
@@ -316,7 +358,7 @@ export async function runMCPServer(): Promise<void> {
316
358
  ],
317
359
  };
318
360
  }
319
- return handleGetDomainCatalog(repo, config, parsed.data);
361
+ return handleGetDomainCatalog(ctx.repo, ctx.config, parsed.data, ctx.root);
320
362
  } else if (name === "septum_get_feature_context") {
321
363
  const parsed = GetFeatureContextSchema.safeParse(args ?? {});
322
364
  if (!parsed.success) {
@@ -330,7 +372,7 @@ export async function runMCPServer(): Promise<void> {
330
372
  ],
331
373
  };
332
374
  }
333
- return handleGetFeatureContext(repo, config, parsed.data);
375
+ return handleGetFeatureContext(ctx.repo, ctx.config, parsed.data);
334
376
  } else if (name === "septum_locate_symbol") {
335
377
  const parsed = LocateSymbolSchema.safeParse(args ?? {});
336
378
  if (!parsed.success) {
@@ -344,7 +386,7 @@ export async function runMCPServer(): Promise<void> {
344
386
  ],
345
387
  };
346
388
  }
347
- return handleLocateSymbol(repo, config, parsed.data);
389
+ return await handleLocateSymbol(ctx.repo, ctx.config, parsed.data, ctx.pipeline, ctx.root);
348
390
  } else if (name === "septum_get_symbol") {
349
391
  const parsed = GetSymbolSchema.safeParse(args ?? {});
350
392
  if (!parsed.success) {
@@ -358,7 +400,7 @@ export async function runMCPServer(): Promise<void> {
358
400
  ],
359
401
  };
360
402
  }
361
- return handleGetSymbol(repo, config, parsed.data);
403
+ return handleGetSymbol(ctx.repo, ctx.config, parsed.data);
362
404
  } else if (name === "septum_get_symbol_impact") {
363
405
  const parsed = GetSymbolImpactSchema.safeParse(args ?? {});
364
406
  if (!parsed.success) {
@@ -372,7 +414,7 @@ export async function runMCPServer(): Promise<void> {
372
414
  ],
373
415
  };
374
416
  }
375
- return handleGetSymbolImpact(repo, config, parsed.data);
417
+ return handleGetSymbolImpact(ctx.repo, ctx.config, parsed.data);
376
418
  } else if (name === "septum_trace_vertical_slice") {
377
419
  const parsed = TraceVerticalSliceSchema.safeParse(args ?? {});
378
420
  if (!parsed.success) {
@@ -386,7 +428,7 @@ export async function runMCPServer(): Promise<void> {
386
428
  ],
387
429
  };
388
430
  }
389
- return handleTraceVerticalSlice(repo, config, parsed.data);
431
+ return handleTraceVerticalSlice(ctx.repo, ctx.config, parsed.data);
390
432
  } else if (name === "septum_clear_feature_context") {
391
433
  const cleared = handleClearFeatureContext();
392
434
  return {
@@ -412,7 +454,7 @@ export async function runMCPServer(): Promise<void> {
412
454
  ],
413
455
  };
414
456
  }
415
- return handleCheckBoundary(evaluator, config, parsed.data);
457
+ return handleCheckBoundary(ctx.evaluator, ctx.config, parsed.data, ctx.repo);
416
458
  } else if (name === "septum_get_symbol_hotspots") {
417
459
  const parsed = GetSymbolHotspotsSchema.safeParse(args ?? {});
418
460
  if (!parsed.success) {
@@ -426,7 +468,7 @@ export async function runMCPServer(): Promise<void> {
426
468
  ],
427
469
  };
428
470
  }
429
- return handleGetSymbolHotspots(repo, config, parsed.data);
471
+ return handleGetSymbolHotspots(ctx.repo, ctx.config, parsed.data);
430
472
  } else if (name === "septum_register_domain") {
431
473
  const parsed = RegisterDomainSchema.safeParse(args ?? {});
432
474
  if (!parsed.success) {
@@ -440,7 +482,7 @@ export async function runMCPServer(): Promise<void> {
440
482
  ],
441
483
  };
442
484
  }
443
- return await handleRegisterDomain(repo, config, parsed.data);
485
+ return await handleRegisterDomain(ctx.repo, ctx.config, parsed.data);
444
486
  } else {
445
487
  throw new Error(`Unknown tool: '${name}'`);
446
488
  }
@@ -522,7 +564,7 @@ export async function runMCPServer(): Promise<void> {
522
564
  ]);
523
565
 
524
566
  try {
525
- const bgPipeline = new IngestionPipeline(repo);
567
+ const bgPipeline = initialCtx.pipeline;
526
568
  const cwd = process.cwd();
527
569
 
528
570
  fsWatcher = watch(cwd, { recursive: true }, (_event, filename) => {
@@ -569,13 +611,14 @@ export async function runMCPServer(): Promise<void> {
569
611
  // Ignore watcher close errors during shutdown
570
612
  }
571
613
  }
572
- try {
573
- db.close();
574
- console.error("[Septum MCP] Database connection closed cleanly.");
575
- } catch (err) {
576
- const msg = err instanceof Error ? err.message : String(err);
577
- console.error(`[Septum MCP] Error closing database during cleanup: ${msg}`);
614
+ for (const ctx of workspaceContextCache.values()) {
615
+ try {
616
+ ctx.db.close();
617
+ } catch (err) {
618
+ // Ignore close error during shutdown
619
+ }
578
620
  }
621
+ console.error("[Septum MCP] All database connections closed cleanly.");
579
622
  process.exit(0);
580
623
  };
581
624
 
@@ -590,11 +633,12 @@ export async function runMCPServer(): Promise<void> {
590
633
  // Ignore watcher close errors
591
634
  }
592
635
  }
593
- try {
594
- db.close();
595
- } catch (err) {
596
- const msg = err instanceof Error ? err.message : String(err);
597
- console.error(`[Septum MCP] Error closing database on beforeExit: ${msg}`);
636
+ for (const ctx of workspaceContextCache.values()) {
637
+ try {
638
+ ctx.db.close();
639
+ } catch {
640
+ // Ignore close error
641
+ }
598
642
  }
599
643
  });
600
644
 
@@ -1,23 +1,44 @@
1
1
  import type { BoundaryEvaluator } from "../../core/boundary/evaluator.ts";
2
2
  import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
3
+ import type { SeptumRepository } from "../../core/database/repository.ts";
3
4
 
4
5
  export interface CheckBoundaryArgs {
5
6
  file_path?: string;
7
+ filePath?: string;
8
+ file?: string;
9
+ path?: string;
6
10
  file_paths?: string[];
11
+ filePaths?: string[];
12
+ files?: string[];
13
+ paths?: string[];
7
14
  proposed_imports?: string[];
15
+ proposedImports?: string[];
16
+ imports?: string[];
8
17
  feature_key?: string;
18
+ featureKey?: string;
19
+ feature?: string;
9
20
  }
10
21
 
11
22
  export function handleCheckBoundary(
12
23
  evaluator: BoundaryEvaluator,
13
24
  config: ValidatedSeptumConfig,
14
- args: CheckBoundaryArgs
25
+ args: CheckBoundaryArgs,
26
+ repo?: SeptumRepository
15
27
  ) {
28
+ const rawFilePaths = args.file_paths ?? args.filePaths ?? args.files ?? args.paths;
29
+ const rawFilePath = args.file_path ?? args.filePath ?? args.file ?? args.path;
30
+ const proposedImports = args.proposed_imports ?? args.proposedImports ?? args.imports ?? [];
31
+ const featureKey = args.feature_key ?? args.featureKey ?? args.feature;
32
+
16
33
  const filesToCheck: string[] = [];
17
- if (args.file_paths && Array.isArray(args.file_paths) && args.file_paths.length > 0) {
18
- filesToCheck.push(...args.file_paths);
19
- } else if (args.file_path) {
20
- filesToCheck.push(args.file_path);
34
+ if (rawFilePaths && Array.isArray(rawFilePaths) && rawFilePaths.length > 0) {
35
+ filesToCheck.push(...rawFilePaths);
36
+ } else if (rawFilePath) {
37
+ if (Array.isArray(rawFilePath)) {
38
+ filesToCheck.push(...rawFilePath);
39
+ } else {
40
+ filesToCheck.push(rawFilePath);
41
+ }
21
42
  } else {
22
43
  throw new Error("Missing required argument: specify either 'file_path' or 'file_paths'");
23
44
  }
@@ -25,11 +46,20 @@ export function handleCheckBoundary(
25
46
  // Multi-file batch evaluation
26
47
  if (filesToCheck.length > 1) {
27
48
  const results = filesToCheck.map((filePath) => {
28
- const violations = evaluator.evaluate(filePath, args.proposed_imports ?? [], args.feature_key, config);
49
+ const violations = evaluator.evaluate(filePath, proposedImports, featureKey, config);
50
+ const impact = repo?.getFileInboundImpact(filePath);
29
51
  return {
30
52
  file: filePath,
31
53
  status: violations.length > 0 ? "rejected" : "approved",
32
54
  violations,
55
+ impact_summary: impact
56
+ ? {
57
+ direct_dependents: impact.direct_dependents_count,
58
+ top_consumers: impact.top_consumers,
59
+ symbols_count: impact.symbols_count,
60
+ risk_level: impact.risk_level,
61
+ }
62
+ : undefined,
33
63
  };
34
64
  });
35
65
 
@@ -60,7 +90,8 @@ export function handleCheckBoundary(
60
90
 
61
91
  // Single file evaluation (backward-compatible output)
62
92
  const singleFile = filesToCheck[0];
63
- const violations = evaluator.evaluate(singleFile, args.proposed_imports ?? [], args.feature_key, config);
93
+ const violations = evaluator.evaluate(singleFile, proposedImports, featureKey, config);
94
+ const impact = repo?.getFileInboundImpact(singleFile);
64
95
 
65
96
  if (violations.length > 0) {
66
97
  return {
@@ -73,6 +104,14 @@ export function handleCheckBoundary(
73
104
  file: singleFile,
74
105
  violations_count: violations.length,
75
106
  violations,
107
+ impact_summary: impact
108
+ ? {
109
+ direct_dependents: impact.direct_dependents_count,
110
+ top_consumers: impact.top_consumers,
111
+ symbols_count: impact.symbols_count,
112
+ risk_level: impact.risk_level,
113
+ }
114
+ : undefined,
76
115
  message: "Boundary violation detected. Proposed imports violate bounded context architecture.",
77
116
  },
78
117
  null,
@@ -91,7 +130,18 @@ export function handleCheckBoundary(
91
130
  {
92
131
  status: "approved",
93
132
  file: singleFile,
94
- message: "No boundary violations detected. Proposed imports are permissible.",
133
+ impact_summary: impact
134
+ ? {
135
+ direct_dependents: impact.direct_dependents_count,
136
+ top_consumers: impact.top_consumers,
137
+ symbols_count: impact.symbols_count,
138
+ risk_level: impact.risk_level,
139
+ }
140
+ : undefined,
141
+ message:
142
+ impact && impact.direct_dependents_count > 0
143
+ ? `No boundary violations detected. Caution: ${impact.direct_dependents_count} dependent file(s) consume this file.`
144
+ : "No boundary violations detected. Proposed imports are permissible.",
95
145
  },
96
146
  null,
97
147
  2
@@ -6,15 +6,45 @@ import type { SeptumRepository } from "../../core/database/repository.ts";
6
6
  export interface GetDomainCatalogArgs {
7
7
  domain?: string;
8
8
  archetype_filter?: string;
9
+ workspace_path?: string;
9
10
  }
10
11
 
11
12
  export function handleGetDomainCatalog(
12
13
  repo: SeptumRepository,
13
14
  config: ValidatedSeptumConfig,
14
- args: GetDomainCatalogArgs
15
+ args: GetDomainCatalogArgs,
16
+ workspaceRoot?: string
15
17
  ) {
16
18
  if (!args.domain) {
17
19
  const allDomains = repo.getAllDomains();
20
+ if (allDomains.length === 0) {
21
+ const root = workspaceRoot || process.cwd();
22
+ const diagnostic = {
23
+ status: "NOT_INDEXED",
24
+ total_domains: 0,
25
+ message: `Workspace '${root}' belum memiliki domain terdaftar dalam Septum catalog.`,
26
+ suggested_action: {
27
+ tool: "septum_register_domain",
28
+ params: {
29
+ name: "core",
30
+ root: "src",
31
+ ingest_now: true,
32
+ },
33
+ },
34
+ agent_guidance:
35
+ "Panggil tool 'septum_register_domain(name: \"core\", root: \"src\", ingest_now: true)' untuk mendaftarkan dan mengindeks domain utama secara otomatis, atau jalankan 'septum init' di terminal workspace.",
36
+ };
37
+ return {
38
+ content: [
39
+ {
40
+ type: "text" as const,
41
+ text: JSON.stringify(diagnostic, null, 2),
42
+ },
43
+ ],
44
+ metadata: diagnostic,
45
+ };
46
+ }
47
+
18
48
  const projectType = repo.getMeta("project_type") || "generic";
19
49
  const framework = repo.getMeta("framework") || "unknown";
20
50
  const archStyle = repo.getMeta("architecture_style") || "modular";
@@ -1,5 +1,8 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { isAbsolute, join, basename } from "node:path";
1
3
  import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
2
4
  import type { SeptumRepository } from "../../core/database/repository.ts";
5
+ import type { IngestionPipeline } from "../../core/ingestion/pipeline.ts";
3
6
  import {
4
7
  SymbolLocator,
5
8
  type SymbolLocationResult,
@@ -8,12 +11,15 @@ import {
8
11
  export interface LocateSymbolArgs {
9
12
  query: string;
10
13
  domain?: string;
14
+ workspace_path?: string;
11
15
  }
12
16
 
13
- export function handleLocateSymbol(
17
+ export async function handleLocateSymbol(
14
18
  repo: SeptumRepository,
15
19
  config: ValidatedSeptumConfig,
16
- args: LocateSymbolArgs
20
+ args: LocateSymbolArgs,
21
+ pipeline?: IngestionPipeline,
22
+ workspaceRoot?: string
17
23
  ) {
18
24
  if (!args.query) {
19
25
  throw new Error(
@@ -21,8 +27,79 @@ export function handleLocateSymbol(
21
27
  );
22
28
  }
23
29
 
30
+ const root = workspaceRoot || process.cwd();
31
+
32
+ // 1. Guarded Lazy Ingestion on Empty Catalog / Cold Start
33
+ if (repo.countFiles() === 0 && pipeline) {
34
+ try {
35
+ await pipeline.run(config);
36
+ } catch {
37
+ // Ingestion fallback
38
+ }
39
+
40
+ if (repo.countFiles() === 0) {
41
+ const diagnostic = {
42
+ status: "NOT_INDEXED",
43
+ found: false,
44
+ query: args.query,
45
+ message: `Workspace '${root}' belum terindeks dalam Septum catalog.`,
46
+ suggested_action: {
47
+ tool: "septum_register_domain",
48
+ params: { name: "core", root: "src", ingest_now: true },
49
+ },
50
+ agent_guidance:
51
+ "Panggil tool 'septum_register_domain(name: \"core\", root: \"src\", ingest_now: true)' untuk mendaftarkan dan mengindeks codebase ini, atau jalankan 'septum init' di terminal workspace.",
52
+ };
53
+
54
+ return {
55
+ content: [
56
+ {
57
+ type: "text",
58
+ text: JSON.stringify(diagnostic, null, 2),
59
+ },
60
+ ],
61
+ metadata: diagnostic,
62
+ };
63
+ }
64
+ }
65
+
24
66
  const locator = new SymbolLocator(repo);
25
- const result: SymbolLocationResult = locator.locate(args.query, args.domain);
67
+ let result: SymbolLocationResult = locator.locate(args.query, args.domain);
68
+
69
+ // 2. JIT Delta Sync: Check if located file has changed on disk since last ingest
70
+ if (result.found && result.file_path && pipeline) {
71
+ const fullPath = isAbsolute(result.file_path)
72
+ ? result.file_path
73
+ : join(root, result.file_path);
74
+ try {
75
+ const reindexed = await pipeline.ingestFile(fullPath, config);
76
+ if (reindexed) {
77
+ result = locator.locate(args.query, args.domain);
78
+ }
79
+ } catch {
80
+ // Silently fall back to existing result
81
+ }
82
+ }
83
+
84
+ // 3. Fallback on Cache Miss: Search for candidate source files matching the query terms
85
+ if (!result.found && pipeline) {
86
+ try {
87
+ const cleanTerm = args.query.replace(/[^a-zA-Z0-9_]/g, " ").trim().split(/\s+/)[0] || "";
88
+ if (cleanTerm.length >= 3) {
89
+ const candidateFiles = findCandidateFiles(root, cleanTerm);
90
+ let anyReindexed = false;
91
+ for (const candidate of candidateFiles) {
92
+ const reindexed = await pipeline.ingestFile(candidate, config);
93
+ if (reindexed) anyReindexed = true;
94
+ }
95
+ if (anyReindexed) {
96
+ result = locator.locate(args.query, args.domain);
97
+ }
98
+ }
99
+ } catch {
100
+ // Ignore candidate scan errors
101
+ }
102
+ }
26
103
 
27
104
  let formattedText = "";
28
105
 
@@ -72,3 +149,42 @@ export function handleLocateSymbol(
72
149
  metadata: result,
73
150
  };
74
151
  }
152
+
153
+ /**
154
+ * Searches for potential candidate files whose names resemble parts of the query term.
155
+ */
156
+ function findCandidateFiles(root: string, term: string): string[] {
157
+ const matched: string[] = [];
158
+ const lowerTerm = term.toLowerCase().replace(/_/g, "");
159
+ const searchDirs = ["src", "app", "lib"].map((d) => join(root, d)).filter((d) => existsSync(d));
160
+
161
+ function scan(dir: string, depth: number = 0) {
162
+ if (depth > 4) return;
163
+ try {
164
+ const entries = readdirSync(dir, { withFileTypes: true });
165
+ for (const e of entries) {
166
+ if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
167
+ const full = join(dir, e.name);
168
+ if (e.isDirectory()) {
169
+ scan(full, depth + 1);
170
+ } else if (e.isFile() && /\.(ts|tsx|js|jsx|php|py|go)$/i.test(e.name)) {
171
+ const lowerName = e.name.toLowerCase().replace(/[-_.]/g, "");
172
+ if (lowerName.includes(lowerTerm) || lowerTerm.includes(lowerName.replace(/\.[^.]+$/, ""))) {
173
+ matched.push(full);
174
+ if (matched.length >= 5) return;
175
+ }
176
+ }
177
+ }
178
+ } catch {
179
+ // Ignore
180
+ }
181
+ }
182
+
183
+ for (const sDir of searchDirs) {
184
+ scan(sDir);
185
+ if (matched.length >= 5) break;
186
+ }
187
+
188
+ return matched;
189
+ }
190
+
@@ -18,7 +18,8 @@ export function handleTraceVerticalSlice(
18
18
  );
19
19
  }
20
20
 
21
- const tracer = new VerticalSliceTracer(repo);
21
+ const workspaceRoot = (config.settings as Record<string, unknown>)?.workspace_root as string || process.cwd();
22
+ const tracer = new VerticalSliceTracer(repo, workspaceRoot);
22
23
  const result: VerticalSliceTraceResponse = tracer.trace(args.query);
23
24
 
24
25
  let formattedText = "";
@@ -13,6 +13,7 @@ export type ArchetypeKind =
13
13
  | "request"
14
14
  | "resource"
15
15
  | "util"
16
+ | "view"
16
17
  | "unknown";
17
18
 
18
19
  export type SymbolKind =
@@ -23,7 +24,9 @@ export type SymbolKind =
23
24
  | "method"
24
25
  | "function"
25
26
  | "type"
26
- | "property";
27
+ | "property"
28
+ | "component"
29
+ | "view";
27
30
 
28
31
  export type Visibility = "public" | "protected" | "private";
29
32