@fenglimg/fabric-server 1.7.0 → 1.8.0-rc.1

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/dist/index.js CHANGED
@@ -3,180 +3,290 @@ import {
3
3
  EVENT_LEDGER_PATH,
4
4
  LEDGER_PATH,
5
5
  LEGACY_LEDGER_PATH,
6
- RULE_SECTION_NAMES,
7
6
  buildRuleMeta,
8
7
  computeRuleTestIndex,
9
8
  computeRulesBasedAgentsMeta,
10
9
  deriveRuleMetaLayer,
11
10
  deriveRuleMetaTopologyType,
11
+ ensureRulesFresh,
12
+ flushAndSyncEventLedger,
12
13
  getEventLedgerPath,
13
14
  getLedgerPath,
14
15
  getLegacyLedgerPath,
15
16
  getRuleSections,
16
17
  isSameRuleTestIndex,
17
18
  planContext,
19
+ reconcileRules,
18
20
  resolveProjectRoot,
19
21
  runDoctorFix,
20
22
  runDoctorReport,
21
23
  stableStringify,
22
24
  writeRuleMeta
23
- } from "./chunk-PTFSYO4Y.js";
25
+ } from "./chunk-E3BHIUIW.js";
24
26
 
25
27
  // src/index.ts
28
+ import { existsSync as existsSync2 } from "fs";
26
29
  import { readFile } from "fs/promises";
27
- import { join, resolve } from "path";
30
+ import { join as join2, resolve } from "path";
28
31
  import { fileURLToPath } from "url";
29
32
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
30
33
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
31
34
 
35
+ // src/services/in-flight-tracker.ts
36
+ function createInFlightTracker() {
37
+ const active = /* @__PURE__ */ new Map();
38
+ let resolveDrained = null;
39
+ return {
40
+ enter(id) {
41
+ active.set(id, Date.now());
42
+ },
43
+ exit(id) {
44
+ active.delete(id);
45
+ if (active.size === 0 && resolveDrained) {
46
+ resolveDrained();
47
+ resolveDrained = null;
48
+ }
49
+ },
50
+ drain(deadlineMs) {
51
+ const startedWith = active.size;
52
+ if (startedWith === 0) return Promise.resolve({ drained: 0, timed_out: 0 });
53
+ return new Promise((resolve2) => {
54
+ const timer = setTimeout(() => {
55
+ const drainedCount = startedWith - active.size;
56
+ resolveDrained = null;
57
+ resolve2({ drained: drainedCount, timed_out: active.size });
58
+ }, deadlineMs);
59
+ resolveDrained = () => {
60
+ clearTimeout(timer);
61
+ resolve2({ drained: startedWith, timed_out: 0 });
62
+ };
63
+ });
64
+ },
65
+ size() {
66
+ return active.size;
67
+ }
68
+ };
69
+ }
70
+
32
71
  // src/tools/plan-context.ts
33
- import { z } from "zod";
34
- var inputSchema = {
35
- paths: z.array(z.string()).min(1).describe("Candidate file paths to build neutral rule selection context for"),
36
- intent: z.string().optional().describe("User-stated requirement or implementation intent; used only to build a neutral requirement profile"),
37
- known_tech: z.array(z.string()).optional().describe("Known technologies involved in the requirement profile"),
38
- detected_entities: z.record(z.array(z.string())).optional().describe("Optional path-keyed detected entities for the requirement profile"),
39
- client_hash: z.string().optional().describe("Revision hash from a prior fab_plan_context response; enables stale detection"),
40
- correlation_id: z.string().optional().describe("Optional caller-provided correlation id for Event Ledger records"),
41
- session_id: z.string().optional().describe("Optional caller-provided session id for Event Ledger records")
42
- };
43
- var ruleDescriptionSchema = z.object({
44
- summary: z.string(),
45
- intent_clues: z.array(z.string()),
46
- tech_stack: z.array(z.string()),
47
- impact: z.array(z.string()),
48
- must_read_if: z.string(),
49
- entities: z.array(z.string()).optional()
50
- });
51
- var descriptionIndexItemSchema = z.object({
52
- stable_id: z.string(),
53
- level: z.enum(["L0", "L1", "L2"]),
54
- required: z.boolean(),
55
- selectable: z.boolean(),
56
- description: ruleDescriptionSchema
57
- });
58
- var requirementProfileSchema = z.object({
59
- target_path: z.string(),
60
- path_segments: z.array(z.string()),
61
- extension: z.string(),
62
- inferred_domain: z.array(z.string()),
63
- known_tech: z.array(z.string()),
64
- user_intent: z.string(),
65
- intent_tokens: z.array(z.string()),
66
- impact_hints: z.array(z.string()),
67
- detected_entities: z.array(z.string())
68
- });
69
- var selectionPolicySchema = z.object({
70
- required_levels: z.tuple([z.literal("L0"), z.literal("L2")]),
71
- ai_selectable_levels: z.tuple([z.literal("L1")]),
72
- final_fetch_rule: z.literal("required_stable_ids + ai_selected_l1_stable_ids")
73
- });
74
- var outputSchema = z.object({
75
- revision_hash: z.string(),
76
- stale: z.boolean(),
77
- selection_token: z.string(),
78
- entries: z.array(
79
- z.object({
80
- path: z.string(),
81
- requirement_profile: requirementProfileSchema,
82
- description_index: z.array(descriptionIndexItemSchema),
83
- required_stable_ids: z.array(z.string()),
84
- ai_selectable_stable_ids: z.array(z.string()),
85
- initial_selected_stable_ids: z.array(z.string()),
86
- selection_policy: selectionPolicySchema
87
- })
88
- ),
89
- shared: z.object({
90
- required_stable_ids: z.array(z.string()),
91
- ai_selectable_stable_ids: z.array(z.string()),
92
- description_index: z.array(descriptionIndexItemSchema),
93
- preflight_diagnostics: z.array(
94
- z.object({
95
- code: z.literal("missing_description"),
96
- severity: z.literal("warn"),
97
- message: z.string(),
98
- stable_ids: z.array(z.string()).optional(),
99
- path: z.string().optional()
100
- })
101
- )
102
- })
103
- });
104
- function registerPlanContext(server) {
72
+ import { randomUUID } from "crypto";
73
+ import {
74
+ planContextAnnotations,
75
+ planContextInputSchema,
76
+ planContextOutputSchema
77
+ } from "@fenglimg/fabric-shared/schemas/api-contracts";
78
+ import { enforcePayloadLimit } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
79
+
80
+ // src/config-loader.ts
81
+ import { existsSync, readFileSync } from "fs";
82
+ import { join } from "path";
83
+ function readFabricConfig(projectRoot) {
84
+ const configPath = join(projectRoot, "fabric.config.json");
85
+ if (!existsSync(configPath)) {
86
+ return {};
87
+ }
88
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
89
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
90
+ throw new Error(`Expected object in ${configPath}`);
91
+ }
92
+ return parsed;
93
+ }
94
+ function readPayloadLimits(projectRoot) {
95
+ return readFabricConfig(projectRoot).mcpPayloadLimits;
96
+ }
97
+
98
+ // src/tools/plan-context.ts
99
+ function registerPlanContext(server, tracker) {
105
100
  server.registerTool(
106
101
  "fab_plan_context",
107
102
  {
108
103
  description: "Use during plan or architecture phases to build a neutral Fabric rule description index and selection token before fetching rule sections.",
109
- inputSchema,
110
- outputSchema,
111
- annotations: { readOnlyHint: true }
104
+ inputSchema: planContextInputSchema,
105
+ outputSchema: planContextOutputSchema,
106
+ annotations: planContextAnnotations
112
107
  },
113
108
  async ({ paths, intent, known_tech, detected_entities, client_hash, correlation_id, session_id }) => {
114
- const projectRoot = resolveProjectRoot();
115
- const result = await planContext(projectRoot, {
116
- paths,
117
- intent,
118
- known_tech,
119
- detected_entities,
120
- client_hash,
121
- correlation_id,
122
- session_id
123
- });
124
- return {
125
- content: [{ type: "text", text: JSON.stringify(result) }],
126
- structuredContent: result
127
- };
109
+ const requestId = randomUUID();
110
+ tracker?.enter(requestId);
111
+ try {
112
+ const projectRoot = resolveProjectRoot();
113
+ const syncReport = await ensureRulesFresh(projectRoot);
114
+ const result = await planContext(projectRoot, {
115
+ paths,
116
+ intent,
117
+ known_tech,
118
+ detected_entities,
119
+ client_hash,
120
+ correlation_id,
121
+ session_id
122
+ });
123
+ const response = {
124
+ ...result,
125
+ warnings: [...syncReport.warnings]
126
+ };
127
+ const payloadLimits = readPayloadLimits(projectRoot);
128
+ const serialized = JSON.stringify(response);
129
+ const guardResult = enforcePayloadLimit(serialized, payloadLimits);
130
+ if (guardResult.warning) {
131
+ response.warnings = [
132
+ ...response.warnings,
133
+ {
134
+ code: guardResult.warning.code,
135
+ file: "<response>",
136
+ action_hint: "Consider narrowing the request scope to reduce response size"
137
+ }
138
+ ];
139
+ }
140
+ return {
141
+ content: [{ type: "text", text: JSON.stringify(response) }],
142
+ structuredContent: response
143
+ };
144
+ } finally {
145
+ tracker?.exit(requestId);
146
+ }
128
147
  }
129
148
  );
130
149
  }
131
150
 
132
151
  // src/tools/rule-sections.ts
133
- import { z as z2 } from "zod";
134
- var inputSchema2 = {
135
- selection_token: z2.string().min(1).describe("Selection token returned by fab_plan_context"),
136
- sections: z2.array(z2.enum(RULE_SECTION_NAMES)).min(1).describe("Structured rule sections to fetch"),
137
- ai_selected_stable_ids: z2.array(z2.string()).describe("AI-selected L1 stable_ids chosen from fab_plan_context ai_selectable_stable_ids"),
138
- ai_selection_reasons: z2.record(z2.string().min(1)).describe("Reason for each AI-selected L1 stable_id"),
139
- correlation_id: z2.string().optional().describe("Optional caller-provided correlation id for Event Ledger records"),
140
- session_id: z2.string().optional().describe("Optional caller-provided session id for Event Ledger records")
141
- };
142
- var outputSchema2 = z2.object({
143
- revision_hash: z2.string(),
144
- precedence: z2.tuple([z2.literal("L2"), z2.literal("L1"), z2.literal("L0")]),
145
- selected_stable_ids: z2.array(z2.string()),
146
- rules: z2.array(
147
- z2.object({
148
- stable_id: z2.string(),
149
- level: z2.enum(["L0", "L1", "L2"]),
150
- path: z2.string(),
151
- sections: z2.record(z2.string())
152
- })
153
- ),
154
- diagnostics: z2.array(
155
- z2.object({
156
- code: z2.literal("missing_section"),
157
- severity: z2.literal("warn"),
158
- stable_id: z2.string(),
159
- section: z2.enum(RULE_SECTION_NAMES),
160
- message: z2.string()
161
- })
162
- )
163
- });
164
- function registerRuleSections(server) {
152
+ import { randomUUID as randomUUID2 } from "crypto";
153
+ import {
154
+ ruleSectionsAnnotations,
155
+ ruleSectionsInputSchema,
156
+ ruleSectionsOutputSchema
157
+ } from "@fenglimg/fabric-shared/schemas/api-contracts";
158
+ import { enforcePayloadLimit as enforcePayloadLimit2 } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
159
+ function registerRuleSections(server, tracker) {
165
160
  server.registerTool(
166
161
  "fab_get_rule_sections",
167
162
  {
168
163
  description: "Fetch structured Fabric rule sections after fab_plan_context. Required L0/L2 rules are merged with AI-selected L1 rules server-side.",
169
- inputSchema: inputSchema2,
170
- outputSchema: outputSchema2,
171
- annotations: { readOnlyHint: true }
164
+ inputSchema: ruleSectionsInputSchema,
165
+ outputSchema: ruleSectionsOutputSchema,
166
+ annotations: ruleSectionsAnnotations
172
167
  },
173
168
  async (input) => {
174
- const projectRoot = resolveProjectRoot();
175
- const result = await getRuleSections(projectRoot, input);
176
- return {
177
- content: [{ type: "text", text: JSON.stringify(result) }],
178
- structuredContent: result
179
- };
169
+ const requestId = randomUUID2();
170
+ tracker?.enter(requestId);
171
+ try {
172
+ const projectRoot = resolveProjectRoot();
173
+ const syncReport = await ensureRulesFresh(projectRoot);
174
+ const result = await getRuleSections(projectRoot, input);
175
+ const response = {
176
+ ...result,
177
+ warnings: [...syncReport.warnings]
178
+ };
179
+ const payloadLimits = readPayloadLimits(projectRoot);
180
+ const serialized = JSON.stringify(response);
181
+ const guardResult = enforcePayloadLimit2(serialized, payloadLimits);
182
+ if (guardResult.warning) {
183
+ response.warnings = [
184
+ ...response.warnings,
185
+ {
186
+ code: guardResult.warning.code,
187
+ file: "<response>",
188
+ action_hint: "Consider narrowing the request scope to reduce response size"
189
+ }
190
+ ];
191
+ }
192
+ return {
193
+ content: [{ type: "text", text: JSON.stringify(response) }],
194
+ structuredContent: response
195
+ };
196
+ } finally {
197
+ tracker?.exit(requestId);
198
+ }
199
+ }
200
+ );
201
+ }
202
+
203
+ // src/services/serve-lock.ts
204
+ import fs from "fs";
205
+ import path from "path";
206
+ import { IOFabricError } from "@fenglimg/fabric-shared/errors";
207
+ var LOCK_FILENAME = ".serve.lock";
208
+ var ServeLockHeldError = class extends IOFabricError {
209
+ code = "SERVE_LOCK_HELD";
210
+ httpStatus = 423;
211
+ };
212
+ function lockPath(projectRoot) {
213
+ return path.join(projectRoot, ".fabric", LOCK_FILENAME);
214
+ }
215
+ function isAlive(pid) {
216
+ try {
217
+ process.kill(pid, 0);
218
+ return true;
219
+ } catch (e) {
220
+ const err = e;
221
+ if (err.code === "ESRCH") return false;
222
+ if (err.code === "EPERM") return true;
223
+ throw e;
224
+ }
225
+ }
226
+ function acquireLock(projectRoot, opts) {
227
+ const p = lockPath(projectRoot);
228
+ if (fs.existsSync(p)) {
229
+ let state = null;
230
+ try {
231
+ state = JSON.parse(fs.readFileSync(p, "utf8"));
232
+ } catch {
233
+ }
234
+ if (state && state.pid && state.pid !== process.pid && isAlive(state.pid) && !opts?.force) {
235
+ throw new ServeLockHeldError(
236
+ `serve lock held by live PID ${state.pid}`,
237
+ {
238
+ actionHint: `Stop the other process (PID ${state.pid}) or run with --force to override`,
239
+ details: state
240
+ }
241
+ );
242
+ }
243
+ if (state && state.pid && !isAlive(state.pid)) {
244
+ process.stderr.write(`[serve-lock] stale lock from PID ${state.pid} \u2014 overwriting
245
+ `);
246
+ }
247
+ }
248
+ fs.mkdirSync(path.dirname(p), { recursive: true });
249
+ fs.writeFileSync(
250
+ p,
251
+ JSON.stringify({ pid: process.pid, acquiredAt: Date.now(), host: process.env.HOSTNAME })
252
+ );
253
+ }
254
+ function releaseLock(projectRoot) {
255
+ const p = lockPath(projectRoot);
256
+ try {
257
+ if (fs.existsSync(p)) {
258
+ const state = JSON.parse(fs.readFileSync(p, "utf8"));
259
+ if (state.pid === process.pid) {
260
+ fs.unlinkSync(p);
261
+ }
262
+ }
263
+ } catch {
264
+ }
265
+ }
266
+ function readLockState(projectRoot) {
267
+ const p = lockPath(projectRoot);
268
+ if (!fs.existsSync(p)) return null;
269
+ try {
270
+ return JSON.parse(fs.readFileSync(p, "utf8"));
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+ function checkLockOrThrow(projectRoot, opts) {
276
+ const state = readLockState(projectRoot);
277
+ if (state === null) return;
278
+ if (state.pid === process.pid) return;
279
+ if (!isAlive(state.pid)) {
280
+ process.stderr.write(`[serve-lock] stale lock from PID ${state.pid} \u2014 ignoring
281
+ `);
282
+ return;
283
+ }
284
+ if (opts?.force) return;
285
+ throw new ServeLockHeldError(
286
+ `serve lock held by live PID ${state.pid}`,
287
+ {
288
+ actionHint: `Stop the other serve process (PID ${state.pid}) before running this command, or pass --force to override`,
289
+ details: state
180
290
  }
181
291
  );
182
292
  }
@@ -192,13 +302,20 @@ function formatError(error) {
192
302
  }
193
303
  return `Unknown error: ${String(error)}`;
194
304
  }
195
- function createFabricServer() {
305
+ function formatPreexistingRootMessage(projectRoot) {
306
+ const preexisting = [];
307
+ if (existsSync2(join2(projectRoot, "CLAUDE.md"))) preexisting.push("CLAUDE.md");
308
+ if (existsSync2(join2(projectRoot, "AGENTS.md"))) preexisting.push("AGENTS.md");
309
+ if (preexisting.length === 0) return null;
310
+ return `[startup] info: detected ${preexisting.join(", ")} at project root. Note: Fabric serves rules from .fabric/rules/ via MCP \u2014 root markdown files are not auto-loaded into the AI context.`;
311
+ }
312
+ function createFabricServer(tracker) {
196
313
  const server = new McpServer({
197
314
  name: "fabric-context-server",
198
- version: "1.7.0"
315
+ version: "1.8.0-rc.1"
199
316
  });
200
- registerPlanContext(server);
201
- registerRuleSections(server);
317
+ registerPlanContext(server, tracker);
318
+ registerRuleSections(server, tracker);
202
319
  server.registerResource(
203
320
  "bootstrap README",
204
321
  AGENTS_MD_RESOURCE_URI,
@@ -208,7 +325,7 @@ function createFabricServer() {
208
325
  },
209
326
  async (_uri) => {
210
327
  const projectRoot = process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
211
- const content = await readFile(join(projectRoot, ".fabric", "bootstrap", "README.md"), "utf8");
328
+ const content = await readFile(join2(projectRoot, ".fabric", "bootstrap", "README.md"), "utf8");
212
329
  return {
213
330
  contents: [
214
331
  {
@@ -223,12 +340,73 @@ function createFabricServer() {
223
340
  return server;
224
341
  }
225
342
  async function startStdioServer() {
226
- const server = createFabricServer();
343
+ const tracker = createInFlightTracker();
344
+ const projectRoot = resolveProjectRoot();
345
+ const syncStart = Date.now();
346
+ const reconcileResult = await reconcileRules(projectRoot, { trigger: "startup" });
347
+ const syncDurationMs = Date.now() - syncStart;
348
+ process.stderr.write(
349
+ `[startup] rule sync: status=${reconcileResult.status}, events=${reconcileResult.events.length}, ${syncDurationMs}ms
350
+ `
351
+ );
352
+ const rootMsg = formatPreexistingRootMessage(projectRoot);
353
+ if (rootMsg !== null) {
354
+ process.stderr.write(`${rootMsg}
355
+ `);
356
+ }
357
+ const server = createFabricServer(tracker);
227
358
  const transport = new StdioServerTransport();
228
359
  await server.connect(transport);
360
+ const closeServer = async () => {
361
+ await server.close();
362
+ };
363
+ process.on(
364
+ "SIGINT",
365
+ createShutdownHandler({ signal: "SIGINT", tracker, projectRoot, closeServer })
366
+ );
367
+ process.on(
368
+ "SIGTERM",
369
+ createShutdownHandler({ signal: "SIGTERM", tracker, projectRoot, closeServer })
370
+ );
371
+ process.on(
372
+ "SIGHUP",
373
+ createShutdownHandler({ signal: "SIGHUP", tracker, projectRoot, closeServer })
374
+ );
375
+ }
376
+ function createShutdownHandler(deps) {
377
+ const exit = deps.exit ?? ((code) => process.exit(code));
378
+ const deadlineMs = deps.drainDeadlineMs ?? 5e3;
379
+ let invoked = false;
380
+ return () => {
381
+ void (async () => {
382
+ if (invoked) {
383
+ process.stderr.write(`
384
+ [shutdown] ${deps.signal} repeated \u2014 forcing exit(1)
385
+ `);
386
+ exit(1);
387
+ return;
388
+ }
389
+ invoked = true;
390
+ process.stderr.write(
391
+ `
392
+ [shutdown] ${deps.signal} received \u2014 draining ${deps.tracker.size()} requests (${deadlineMs / 1e3}s deadline)
393
+ `
394
+ );
395
+ const result = await deps.tracker.drain(deadlineMs);
396
+ process.stderr.write(`[shutdown] drained ${result.drained}, timed_out ${result.timed_out}
397
+ `);
398
+ flushAndSyncEventLedger(deps.projectRoot);
399
+ process.stderr.write("[shutdown] ledger fsynced; closing server\n");
400
+ try {
401
+ await deps.closeServer();
402
+ } catch {
403
+ }
404
+ exit(0);
405
+ })();
406
+ };
229
407
  }
230
408
  async function startHttpServer(options) {
231
- const { createFabricHttpApp } = await import("./http-6LFZLHCN.js");
409
+ const { createFabricHttpApp } = await import("./http-MEFXOG3L.js");
232
410
  const { port, projectRoot, host = "127.0.0.1", authToken, dashboardDistPath, dev } = options;
233
411
  const app = createFabricHttpApp({ projectRoot, host, authToken, dashboardDistPath, dev });
234
412
  return await new Promise((resolveServer, rejectServer) => {
@@ -258,16 +436,27 @@ export {
258
436
  EVENT_LEDGER_PATH,
259
437
  LEDGER_PATH,
260
438
  LEGACY_LEDGER_PATH,
439
+ ServeLockHeldError,
440
+ acquireLock,
261
441
  buildRuleMeta,
442
+ checkLockOrThrow,
262
443
  computeRuleTestIndex,
263
444
  computeRulesBasedAgentsMeta,
264
445
  createFabricServer,
446
+ createInFlightTracker,
447
+ createShutdownHandler,
265
448
  deriveRuleMetaLayer,
266
449
  deriveRuleMetaTopologyType,
450
+ ensureRulesFresh,
451
+ flushAndSyncEventLedger,
452
+ formatPreexistingRootMessage,
267
453
  getEventLedgerPath,
268
454
  getLedgerPath,
269
455
  getLegacyLedgerPath,
270
456
  isSameRuleTestIndex,
457
+ readLockState,
458
+ reconcileRules,
459
+ releaseLock,
271
460
  runDoctorFix,
272
461
  runDoctorReport,
273
462
  stableStringify,