d365fo-mcp 1.17.2 → 1.17.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/dist/index.js CHANGED
@@ -104,8 +104,37 @@ console.error = (...args) => {
104
104
  const isModuleDebugMessage = /^\[[\w\- ]+\]/.test(firstArg) && !hasErrorIndicator;
105
105
  if (!isModuleDebugMessage) {
106
106
  originalConsoleError(...args);
107
+ return;
107
108
  }
109
+ logFileOnly(args);
108
110
  };
111
+ /**
112
+ * Write a line the client never sees to LOG_FILE anyway.
113
+ *
114
+ * Both filters below the tee — this one and the stdio console.log redirect — exist
115
+ * so the MCP client's stderr pane is not a scroll of operational chatter. They were
116
+ * dropping the line entirely, and since the tee sits on process.stderr, a dropped
117
+ * line never reached the log file either. So a session that hung for five and a
118
+ * half minutes on its first call left a log holding a start banner and nothing
119
+ * else: "Loading symbols…", "Database opened in Xs" and "Database loaded in N ms"
120
+ * are all log.step/log.ok, all suppressed, all the answer to what it was doing.
121
+ * Setting DEBUG_LOGGING=true was the only way to see them, and it turns the client
122
+ * pane into that same scroll. The file is the right place for both.
123
+ */
124
+ function logFileOnly(args) {
125
+ if (!_logStream)
126
+ return;
127
+ try {
128
+ // Timestamped, unlike the tee'd lines: these are the progress lines, and the
129
+ // question they answer ("which phase took the five minutes?") is unanswerable
130
+ // without the clock. Time only — the banner above carries the date.
131
+ const at = new Date().toISOString().slice(11, 23);
132
+ _logStream.write(`[${at}] ` + args.map(a => (typeof a === 'string' ? a : String(a))).join(' ') + '\n');
133
+ }
134
+ catch {
135
+ // Mirroring is best-effort; it must never take down the caller.
136
+ }
137
+ }
109
138
  // ─── Global safety net ────────────────────────────────────────────────────────
110
139
  // An unhandled promise rejection terminates the Node process by default
111
140
  // (Node ≥15, --unhandled-rejections=throw). In stdio mode that kills the MCP
@@ -550,7 +579,12 @@ async function main() {
550
579
  msg.includes('Error') || msg.includes('error') ||
551
580
  msg.includes('Failed') || msg.includes('failed')) {
552
581
  process.stderr.write(msg + '\n');
582
+ return;
553
583
  }
584
+ // Suppressed from the client's pane, kept in LOG_FILE — see logFileOnly.
585
+ // This is where every startup progress line goes: log.step/ok/detail are
586
+ // console.log, and console.log is this function in stdio mode.
587
+ logFileOnly([msg]);
554
588
  };
555
589
  console.log = stderrWrite;
556
590
  console.info = stderrWrite;
@@ -299,6 +299,15 @@ export declare class XppSymbolIndex {
299
299
  *
300
300
  * Returns the names of top-level objects that were removed (for cache invalidation).
301
301
  */
302
+ /**
303
+ * How many regular (non-extension) object names the prefix sample may draw.
304
+ *
305
+ * inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
306
+ * coverage threshold, so a few dozen settle the question as well as a few hundred
307
+ * — and this is the band that costs, since a model's extensions are counted in
308
+ * tens while its classes and tables run to thousands.
309
+ */
310
+ private static readonly REGULAR_NAME_SAMPLE;
302
311
  /**
303
312
  * Top-level object names belonging to one model — the evidence from which a
304
313
  * model's naming prefix is inferred (see utils/modelPrefixInference.ts).
@@ -330,6 +339,15 @@ export declare class XppSymbolIndex {
330
339
  * so the same model always yields the same sample — a silent, self-reinforcing
331
340
  * failure otherwise, since this server writes names with the inferred prefix and
332
341
  * those names become evidence for the next inference.
342
+ *
343
+ * The regular band is capped well below its share of the budget
344
+ * (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
345
+ * carries most of the signal: extensions state the infix outright, while regular
346
+ * objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
347
+ * leading token. Reading 400 of them to settle a 4-name question was paid on the
348
+ * first call of every session. They cannot be dropped altogether — the underscore
349
+ * form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
350
+ * object can decide it.
333
351
  */
334
352
  getModelObjectNames(model: string, limit?: number): string[];
335
353
  removeSymbolsByFile(filePath: string): {
@@ -1243,6 +1243,15 @@ export class XppSymbolIndex {
1243
1243
  *
1244
1244
  * Returns the names of top-level objects that were removed (for cache invalidation).
1245
1245
  */
1246
+ /**
1247
+ * How many regular (non-extension) object names the prefix sample may draw.
1248
+ *
1249
+ * inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
1250
+ * coverage threshold, so a few dozen settle the question as well as a few hundred
1251
+ * — and this is the band that costs, since a model's extensions are counted in
1252
+ * tens while its classes and tables run to thousands.
1253
+ */
1254
+ static REGULAR_NAME_SAMPLE = 60;
1246
1255
  /**
1247
1256
  * Top-level object names belonging to one model — the evidence from which a
1248
1257
  * model's naming prefix is inferred (see utils/modelPrefixInference.ts).
@@ -1274,6 +1283,15 @@ export class XppSymbolIndex {
1274
1283
  * so the same model always yields the same sample — a silent, self-reinforcing
1275
1284
  * failure otherwise, since this server writes names with the inferred prefix and
1276
1285
  * those names become evidence for the next inference.
1286
+ *
1287
+ * The regular band is capped well below its share of the budget
1288
+ * (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
1289
+ * carries most of the signal: extensions state the infix outright, while regular
1290
+ * objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
1291
+ * leading token. Reading 400 of them to settle a 4-name question was paid on the
1292
+ * first call of every session. They cannot be dropped altogether — the underscore
1293
+ * form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
1294
+ * object can decide it.
1277
1295
  */
1278
1296
  getModelObjectNames(model, limit = 400) {
1279
1297
  if (!model)
@@ -1285,10 +1303,21 @@ export class XppSymbolIndex {
1285
1303
  if (cap <= 0)
1286
1304
  return [];
1287
1305
  const rows = db
1288
- .prepare(`SELECT name FROM symbols
1306
+ .prepare(
1307
+ // Unary + on parent_name, for the same reason as searchCustomExtensions'
1308
+ // `+type IN (…)`: written plainly, `parent_name IS NULL` makes the planner
1309
+ // choose idx_symbols_parent_name, whose ANALYZE stats claim ~13 rows per
1310
+ // value. NULL is not one value — it is every top-level object of every
1311
+ // model, 180,664 of the 1,188,748 rows on the production DB, against 274
1312
+ // for the one model being asked about. Measured warm: 454 ms on the
1313
+ // parent_name plan, 1 ms on the model plan; cold it is the difference
1314
+ // between a 5-minute first get_workspace_info and an instant one.
1315
+ // EXPLAIN QUERY PLAN must keep reporting
1316
+ // `SEARCH symbols USING INDEX idx_symbols_model`.
1317
+ `SELECT name FROM symbols
1289
1318
  WHERE model = ?
1290
1319
  AND type ${extensions ? 'LIKE' : 'NOT LIKE'} '%-extension'
1291
- ${extensions ? '' : 'AND parent_name IS NULL'}
1320
+ ${extensions ? '' : 'AND +parent_name IS NULL'}
1292
1321
  AND type NOT IN ('method', 'field')
1293
1322
  ORDER BY type, name
1294
1323
  LIMIT ?`)
@@ -1300,7 +1329,7 @@ export class XppSymbolIndex {
1300
1329
  // regular objects before the infix evidence is ever read.
1301
1330
  const half = Math.max(1, Math.ceil(limit / 2));
1302
1331
  const extensionNames = band(true, half);
1303
- const regularNames = band(false, limit - extensionNames.length);
1332
+ const regularNames = band(false, Math.min(XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length));
1304
1333
  return [...extensionNames, ...regularNames];
1305
1334
  }
1306
1335
  removeSymbolsByFile(filePath) {
@@ -2271,6 +2271,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
2271
2271
  *
2272
2272
  * Returns the names of top-level objects that were removed (for cache invalidation).
2273
2273
  */
2274
+ /**
2275
+ * How many regular (non-extension) object names the prefix sample may draw.
2276
+ *
2277
+ * inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
2278
+ * coverage threshold, so a few dozen settle the question as well as a few hundred
2279
+ * — and this is the band that costs, since a model's extensions are counted in
2280
+ * tens while its classes and tables run to thousands.
2281
+ */
2282
+ static REGULAR_NAME_SAMPLE = 60;
2274
2283
  /**
2275
2284
  * Top-level object names belonging to one model — the evidence from which a
2276
2285
  * model's naming prefix is inferred (see utils/modelPrefixInference.ts).
@@ -2302,6 +2311,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
2302
2311
  * so the same model always yields the same sample — a silent, self-reinforcing
2303
2312
  * failure otherwise, since this server writes names with the inferred prefix and
2304
2313
  * those names become evidence for the next inference.
2314
+ *
2315
+ * The regular band is capped well below its share of the budget
2316
+ * (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
2317
+ * carries most of the signal: extensions state the infix outright, while regular
2318
+ * objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
2319
+ * leading token. Reading 400 of them to settle a 4-name question was paid on the
2320
+ * first call of every session. They cannot be dropped altogether — the underscore
2321
+ * form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
2322
+ * object can decide it.
2305
2323
  */
2306
2324
  getModelObjectNames(model, limit = 400) {
2307
2325
  if (!model) return [];
@@ -2310,10 +2328,20 @@ var XppSymbolIndex = class _XppSymbolIndex {
2310
2328
  const band = (extensions, cap) => {
2311
2329
  if (cap <= 0) return [];
2312
2330
  const rows = db.prepare(
2331
+ // Unary + on parent_name, for the same reason as searchCustomExtensions'
2332
+ // `+type IN (…)`: written plainly, `parent_name IS NULL` makes the planner
2333
+ // choose idx_symbols_parent_name, whose ANALYZE stats claim ~13 rows per
2334
+ // value. NULL is not one value — it is every top-level object of every
2335
+ // model, 180,664 of the 1,188,748 rows on the production DB, against 274
2336
+ // for the one model being asked about. Measured warm: 454 ms on the
2337
+ // parent_name plan, 1 ms on the model plan; cold it is the difference
2338
+ // between a 5-minute first get_workspace_info and an instant one.
2339
+ // EXPLAIN QUERY PLAN must keep reporting
2340
+ // `SEARCH symbols USING INDEX idx_symbols_model`.
2313
2341
  `SELECT name FROM symbols
2314
2342
  WHERE model = ?
2315
2343
  AND type ${extensions ? "LIKE" : "NOT LIKE"} '%-extension'
2316
- ${extensions ? "" : "AND parent_name IS NULL"}
2344
+ ${extensions ? "" : "AND +parent_name IS NULL"}
2317
2345
  AND type NOT IN ('method', 'field')
2318
2346
  ORDER BY type, name
2319
2347
  LIMIT ?`
@@ -2322,7 +2350,10 @@ var XppSymbolIndex = class _XppSymbolIndex {
2322
2350
  };
2323
2351
  const half = Math.max(1, Math.ceil(limit / 2));
2324
2352
  const extensionNames = band(true, half);
2325
- const regularNames = band(false, limit - extensionNames.length);
2353
+ const regularNames = band(
2354
+ false,
2355
+ Math.min(_XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length)
2356
+ );
2326
2357
  return [...extensionNames, ...regularNames];
2327
2358
  }
2328
2359
  removeSymbolsByFile(filePath) {
@@ -2210,6 +2210,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
2210
2210
  *
2211
2211
  * Returns the names of top-level objects that were removed (for cache invalidation).
2212
2212
  */
2213
+ /**
2214
+ * How many regular (non-extension) object names the prefix sample may draw.
2215
+ *
2216
+ * inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
2217
+ * coverage threshold, so a few dozen settle the question as well as a few hundred
2218
+ * — and this is the band that costs, since a model's extensions are counted in
2219
+ * tens while its classes and tables run to thousands.
2220
+ */
2221
+ static REGULAR_NAME_SAMPLE = 60;
2213
2222
  /**
2214
2223
  * Top-level object names belonging to one model — the evidence from which a
2215
2224
  * model's naming prefix is inferred (see utils/modelPrefixInference.ts).
@@ -2241,6 +2250,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
2241
2250
  * so the same model always yields the same sample — a silent, self-reinforcing
2242
2251
  * failure otherwise, since this server writes names with the inferred prefix and
2243
2252
  * those names become evidence for the next inference.
2253
+ *
2254
+ * The regular band is capped well below its share of the budget
2255
+ * (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
2256
+ * carries most of the signal: extensions state the infix outright, while regular
2257
+ * objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
2258
+ * leading token. Reading 400 of them to settle a 4-name question was paid on the
2259
+ * first call of every session. They cannot be dropped altogether — the underscore
2260
+ * form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
2261
+ * object can decide it.
2244
2262
  */
2245
2263
  getModelObjectNames(model, limit = 400) {
2246
2264
  if (!model) return [];
@@ -2249,10 +2267,20 @@ var XppSymbolIndex = class _XppSymbolIndex {
2249
2267
  const band = (extensions, cap) => {
2250
2268
  if (cap <= 0) return [];
2251
2269
  const rows = db.prepare(
2270
+ // Unary + on parent_name, for the same reason as searchCustomExtensions'
2271
+ // `+type IN (…)`: written plainly, `parent_name IS NULL` makes the planner
2272
+ // choose idx_symbols_parent_name, whose ANALYZE stats claim ~13 rows per
2273
+ // value. NULL is not one value — it is every top-level object of every
2274
+ // model, 180,664 of the 1,188,748 rows on the production DB, against 274
2275
+ // for the one model being asked about. Measured warm: 454 ms on the
2276
+ // parent_name plan, 1 ms on the model plan; cold it is the difference
2277
+ // between a 5-minute first get_workspace_info and an instant one.
2278
+ // EXPLAIN QUERY PLAN must keep reporting
2279
+ // `SEARCH symbols USING INDEX idx_symbols_model`.
2252
2280
  `SELECT name FROM symbols
2253
2281
  WHERE model = ?
2254
2282
  AND type ${extensions ? "LIKE" : "NOT LIKE"} '%-extension'
2255
- ${extensions ? "" : "AND parent_name IS NULL"}
2283
+ ${extensions ? "" : "AND +parent_name IS NULL"}
2256
2284
  AND type NOT IN ('method', 'field')
2257
2285
  ORDER BY type, name
2258
2286
  LIMIT ?`
@@ -2261,7 +2289,10 @@ var XppSymbolIndex = class _XppSymbolIndex {
2261
2289
  };
2262
2290
  const half = Math.max(1, Math.ceil(limit / 2));
2263
2291
  const extensionNames = band(true, half);
2264
- const regularNames = band(false, limit - extensionNames.length);
2292
+ const regularNames = band(
2293
+ false,
2294
+ Math.min(_XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length)
2295
+ );
2265
2296
  return [...extensionNames, ...regularNames];
2266
2297
  }
2267
2298
  removeSymbolsByFile(filePath) {
@@ -1381,6 +1381,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
1381
1381
  *
1382
1382
  * Returns the names of top-level objects that were removed (for cache invalidation).
1383
1383
  */
1384
+ /**
1385
+ * How many regular (non-extension) object names the prefix sample may draw.
1386
+ *
1387
+ * inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
1388
+ * coverage threshold, so a few dozen settle the question as well as a few hundred
1389
+ * — and this is the band that costs, since a model's extensions are counted in
1390
+ * tens while its classes and tables run to thousands.
1391
+ */
1392
+ static REGULAR_NAME_SAMPLE = 60;
1384
1393
  /**
1385
1394
  * Top-level object names belonging to one model — the evidence from which a
1386
1395
  * model's naming prefix is inferred (see utils/modelPrefixInference.ts).
@@ -1412,6 +1421,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
1412
1421
  * so the same model always yields the same sample — a silent, self-reinforcing
1413
1422
  * failure otherwise, since this server writes names with the inferred prefix and
1414
1423
  * those names become evidence for the next inference.
1424
+ *
1425
+ * The regular band is capped well below its share of the budget
1426
+ * (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
1427
+ * carries most of the signal: extensions state the infix outright, while regular
1428
+ * objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
1429
+ * leading token. Reading 400 of them to settle a 4-name question was paid on the
1430
+ * first call of every session. They cannot be dropped altogether — the underscore
1431
+ * form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
1432
+ * object can decide it.
1415
1433
  */
1416
1434
  getModelObjectNames(model, limit = 400) {
1417
1435
  if (!model) return [];
@@ -1420,10 +1438,20 @@ var XppSymbolIndex = class _XppSymbolIndex {
1420
1438
  const band = (extensions, cap) => {
1421
1439
  if (cap <= 0) return [];
1422
1440
  const rows = db.prepare(
1441
+ // Unary + on parent_name, for the same reason as searchCustomExtensions'
1442
+ // `+type IN (…)`: written plainly, `parent_name IS NULL` makes the planner
1443
+ // choose idx_symbols_parent_name, whose ANALYZE stats claim ~13 rows per
1444
+ // value. NULL is not one value — it is every top-level object of every
1445
+ // model, 180,664 of the 1,188,748 rows on the production DB, against 274
1446
+ // for the one model being asked about. Measured warm: 454 ms on the
1447
+ // parent_name plan, 1 ms on the model plan; cold it is the difference
1448
+ // between a 5-minute first get_workspace_info and an instant one.
1449
+ // EXPLAIN QUERY PLAN must keep reporting
1450
+ // `SEARCH symbols USING INDEX idx_symbols_model`.
1423
1451
  `SELECT name FROM symbols
1424
1452
  WHERE model = ?
1425
1453
  AND type ${extensions ? "LIKE" : "NOT LIKE"} '%-extension'
1426
- ${extensions ? "" : "AND parent_name IS NULL"}
1454
+ ${extensions ? "" : "AND +parent_name IS NULL"}
1427
1455
  AND type NOT IN ('method', 'field')
1428
1456
  ORDER BY type, name
1429
1457
  LIMIT ?`
@@ -1432,7 +1460,10 @@ var XppSymbolIndex = class _XppSymbolIndex {
1432
1460
  };
1433
1461
  const half = Math.max(1, Math.ceil(limit / 2));
1434
1462
  const extensionNames = band(true, half);
1435
- const regularNames = band(false, limit - extensionNames.length);
1463
+ const regularNames = band(
1464
+ false,
1465
+ Math.min(_XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length)
1466
+ );
1436
1467
  return [...extensionNames, ...regularNames];
1437
1468
  }
1438
1469
  removeSymbolsByFile(filePath) {
@@ -29,6 +29,29 @@
29
29
  * created without an Azure re-deploy.
30
30
  */
31
31
  export declare const LOCAL_TOOLS: Set<string>;
32
+ /**
33
+ * The LOCAL_TOOLS that do read the symbol database after all, and so must NOT
34
+ * inherit this set's exemption from the dbReady wait in toolHandler.
35
+ *
36
+ * "Local" answers a question about LOCALITY — can this run away from the K:\ drive
37
+ * — and the dbReady exemption rode along on it as if it also meant "needs no
38
+ * index". For two of them it does not:
39
+ *
40
+ * • get_workspace_info infers the model's prefix from symbols.getModelObjectNames
41
+ * and reads getLastIndexedAt. Exempt, it had no timeout either: while the 2.5 GB
42
+ * database opened, the tool a session STARTS with sat there with no answer and
43
+ * no way to fail. Measured live 2026-09-07: 337.5 s, and 356 s / 424 s on two
44
+ * earlier cold starts, where every other tool would have said "still loading,
45
+ * retry" after 55 s. It also answered from the empty stub index when it did
46
+ * return early — a configured prefix reported as if the model had taught it.
47
+ * • update_symbol_index writes THROUGH context.symbolIndex (removeSymbolsByFile,
48
+ * removeLabelsByFile). Before the swap that is the :memory: stub, so the call
49
+ * reports a successful re-index of a database nobody will ever read.
50
+ *
51
+ * The rest (build, bp-check, systest, verify) genuinely only touch the filesystem
52
+ * and the compiler, and keep the exemption.
53
+ */
54
+ export declare const DB_BACKED_LOCAL_TOOLS: Set<string>;
32
55
  /**
33
56
  * Tools exposed in EVERY server mode, bypassing the LOCAL_TOOLS partition,
34
57
  * because each spans both localities and gates unavailable actions at runtime:
@@ -36,6 +36,32 @@ export const LOCAL_TOOLS = new Set([
36
36
  'run_systest_class',
37
37
  'get_workspace_info',
38
38
  ]);
39
+ /**
40
+ * The LOCAL_TOOLS that do read the symbol database after all, and so must NOT
41
+ * inherit this set's exemption from the dbReady wait in toolHandler.
42
+ *
43
+ * "Local" answers a question about LOCALITY — can this run away from the K:\ drive
44
+ * — and the dbReady exemption rode along on it as if it also meant "needs no
45
+ * index". For two of them it does not:
46
+ *
47
+ * • get_workspace_info infers the model's prefix from symbols.getModelObjectNames
48
+ * and reads getLastIndexedAt. Exempt, it had no timeout either: while the 2.5 GB
49
+ * database opened, the tool a session STARTS with sat there with no answer and
50
+ * no way to fail. Measured live 2026-09-07: 337.5 s, and 356 s / 424 s on two
51
+ * earlier cold starts, where every other tool would have said "still loading,
52
+ * retry" after 55 s. It also answered from the empty stub index when it did
53
+ * return early — a configured prefix reported as if the model had taught it.
54
+ * • update_symbol_index writes THROUGH context.symbolIndex (removeSymbolsByFile,
55
+ * removeLabelsByFile). Before the swap that is the :memory: stub, so the call
56
+ * reports a successful re-index of a database nobody will ever read.
57
+ *
58
+ * The rest (build, bp-check, systest, verify) genuinely only touch the filesystem
59
+ * and the compiler, and keep the exemption.
60
+ */
61
+ export const DB_BACKED_LOCAL_TOOLS = new Set([
62
+ 'get_workspace_info',
63
+ 'update_symbol_index',
64
+ ]);
39
65
  /**
40
66
  * Tools exposed in EVERY server mode, bypassing the LOCAL_TOOLS partition,
41
67
  * because each spans both localities and gates unavailable actions at runtime:
@@ -72,7 +72,7 @@ function rowKey(r) {
72
72
  * the 2026-08-07 demo; `action="search"` never did, and search is the call an
73
73
  * agent makes BEFORE it reuses a label. Benchmark run d79f62a3 (2026-08-17) took
74
74
  * all three labels it needed from one search — the enum's, the field's and the
75
- * error message's — all reported as resolvable [AslFinanceSK] hits, none of them
75
+ * error message's — all reported as resolvable [ContosoFinanceSK] hits, none of them
76
76
  * on disk. `xppc` does not check labels, so the first build passed; the run paid
77
77
  * a second build, a second BP check and ~12 AIU to find out.
78
78
  *
@@ -244,6 +244,11 @@ async function readObject(ref, context, allowTypeCorrection = true) {
244
244
  if (options?.include === 'xml') {
245
245
  const xml = await readObjectXml(objectType, name, {
246
246
  modelName: options.modelName,
247
+ // Without this the lookup only ever sees the CONFIGURED model's folder, so
248
+ // every read of a Microsoft or other-model object came back as "pass
249
+ // options.modelName" — a round trip for a fact this same tool prints in
250
+ // every other include mode.
251
+ index: context.symbolIndex,
247
252
  startLine: options.startLine,
248
253
  endLine: options.endLine,
249
254
  maxChars: options.maxChars,
@@ -8,8 +8,14 @@
8
8
  * it. A recursive scan of PackagesLocalDirectory costs seconds; this is one
9
9
  * indexed lookup.
10
10
  */
11
+ import { type SymbolFileLookupSource } from '../../utils/objectFileLookup.js';
11
12
  export interface ObjectXmlOptions {
12
13
  modelName?: string;
14
+ /**
15
+ * The symbol index, when the caller has one. Without it this falls back to the
16
+ * configured model's folder layout, which only ever finds objects in that model.
17
+ */
18
+ index?: SymbolFileLookupSource;
13
19
  /** 1-based, inclusive. Omit both for the whole file (up to maxChars). */
14
20
  startLine?: number;
15
21
  endLine?: number;
@@ -21,7 +27,16 @@ export interface ObjectXmlResult {
21
27
  }
22
28
  /** Render a file already located. Split out so it is testable without config. */
23
29
  export declare function renderObjectXml(filePath: string, objectType: string, objectName: string, options?: ObjectXmlOptions): Promise<ObjectXmlResult>;
24
- /** The message for an object with no file — never an empty result. */
25
- export declare function objectXmlNotFound(objectType: string, objectName: string, modelName?: string): ObjectXmlResult;
30
+ /**
31
+ * The message for an object with no file — never an empty result.
32
+ *
33
+ * `elsewhere` are the models the index says DO hold an object of this name. Naming
34
+ * them is the whole difference between a message the caller can act on and one it
35
+ * can only guess at: "pass options.modelName" sent an agent looking for
36
+ * JournalVoucherNum in "Application Foundation" (wrong) before "Foundation"
37
+ * (right), three calls for one file. An empty list is itself an answer — no model
38
+ * has it, so retrying with a different modelName cannot help.
39
+ */
40
+ export declare function objectXmlNotFound(objectType: string, objectName: string, modelName?: string, elsewhere?: string[]): ObjectXmlResult;
26
41
  export declare function readObjectXml(objectType: string, objectName: string, options?: ObjectXmlOptions): Promise<ObjectXmlResult>;
27
42
  //# sourceMappingURL=objectXml.d.ts.map
@@ -9,7 +9,7 @@
9
9
  * indexed lookup.
10
10
  */
11
11
  import * as fs from 'fs/promises';
12
- import { findD365FileOnDisk } from '../../utils/objectFileLookup.js';
12
+ import { findD365FileOnDisk, findD365FileViaIndex, modelsHoldingObject, } from '../../utils/objectFileLookup.js';
13
13
  /** Default ceiling on returned XML. A large form is well past any useful read. */
14
14
  const DEFAULT_MAX_CHARS = 40_000;
15
15
  /** Render a file already located. Split out so it is testable without config. */
@@ -40,19 +40,45 @@ export async function renderObjectXml(filePath, objectType, objectName, options
40
40
  : '';
41
41
  return { isError: false, text: `${header}\n\`\`\`xml\n${body}\n\`\`\`${footer}` };
42
42
  }
43
- /** The message for an object with no file — never an empty result. */
44
- export function objectXmlNotFound(objectType, objectName, modelName) {
43
+ /**
44
+ * The message for an object with no file — never an empty result.
45
+ *
46
+ * `elsewhere` are the models the index says DO hold an object of this name. Naming
47
+ * them is the whole difference between a message the caller can act on and one it
48
+ * can only guess at: "pass options.modelName" sent an agent looking for
49
+ * JournalVoucherNum in "Application Foundation" (wrong) before "Foundation"
50
+ * (right), three calls for one file. An empty list is itself an answer — no model
51
+ * has it, so retrying with a different modelName cannot help.
52
+ */
53
+ export function objectXmlNotFound(objectType, objectName, modelName, elsewhere = []) {
54
+ const where = elsewhere.filter(m => m !== modelName);
55
+ const hint = where.length > 0
56
+ ? `The index has it in ${where.map(m => `"${m}"`).join(' · ')} — retry with ` +
57
+ `options.modelName="${where[0]}". If that also misses, the index row is stale ` +
58
+ `(the file was moved or deleted since the last index run).`
59
+ : `No model in the symbol index holds a ${objectType} of that name, so retrying with ` +
60
+ `a different options.modelName will not help — check the spelling and the ` +
61
+ `objectType, or the object does not exist yet.`;
45
62
  return {
46
63
  isError: true,
47
64
  text: `❌ get_object_info(include="xml"): no file on disk for ${objectType} "${objectName}"` +
48
- `${modelName ? ` in model "${modelName}"` : ''}.\n` +
49
- `The object may live in another model — pass options.modelName — or may not exist yet.`,
65
+ `${modelName ? ` in model "${modelName}"` : ''}.\n${hint}`,
50
66
  };
51
67
  }
52
68
  export async function readObjectXml(objectType, objectName, options = {}) {
53
- const filePath = await findD365FileOnDisk(objectType, objectName, options.modelName);
54
- if (!filePath)
55
- return objectXmlNotFound(objectType, objectName, options.modelName);
56
- return renderObjectXml(filePath, objectType, objectName, options);
69
+ // Configured-model layout first: it is a couple of fs.access calls, it is what a
70
+ // freshly created object (not yet indexed) resolves through, and it keeps the
71
+ // common case — reading something in the model being worked in — off the DB.
72
+ const onDisk = await findD365FileOnDisk(objectType, objectName, options.modelName);
73
+ if (onDisk)
74
+ return renderObjectXml(onDisk, objectType, objectName, options);
75
+ // Then the index, which knows every model rather than just the configured one.
76
+ if (options.index) {
77
+ const indexed = await findD365FileViaIndex(options.index, objectType, objectName, options.modelName);
78
+ if (indexed)
79
+ return renderObjectXml(indexed.filePath, objectType, objectName, options);
80
+ return objectXmlNotFound(objectType, objectName, options.modelName, modelsHoldingObject(options.index, objectType, objectName));
81
+ }
82
+ return objectXmlNotFound(objectType, objectName, options.modelName);
57
83
  }
58
84
  //# sourceMappingURL=objectXml.js.map
@@ -1,6 +1,6 @@
1
1
  import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { getConfigManager } from '../utils/configManager.js';
3
- import { SERVER_MODE, LOCAL_TOOLS, TOOL_PROFILE, isToolAllowedInMode, isToolInProfile, } from '../server/serverMode.js';
3
+ import { SERVER_MODE, LOCAL_TOOLS, DB_BACKED_LOCAL_TOOLS, TOOL_PROFILE, isToolAllowedInMode, isToolInProfile, } from '../server/serverMode.js';
4
4
  import { BRIDGE_BACKED_TOOLS, awaitBridgeReady } from '../bridge/bridgeReadiness.js';
5
5
  import { BRIDGE_FAILURE_MARKER, runWithBridgeFailureScope, renderBridgeFailureNote, } from '../bridge/bridgeFailure.js';
6
6
  import { runWithSideEffectScope, renderSideEffectNote, } from '../utils/writeSideEffects.js';
@@ -40,7 +40,7 @@ import { capToolResponse } from './responseCaps.js';
40
40
  */
41
41
  const WRITE_CAPABLE_TOOLS = new Set(['d365fo_file', 'labels']);
42
42
  import { buildProgressMessage } from '../utils/toolProgressMessage.js';
43
- import { createProgressReporter } from '../utils/progressReporter.js';
43
+ import { createProgressReporter, startProgressHeartbeat } from '../utils/progressReporter.js';
44
44
  /**
45
45
  * Extract workspace path from GitHub Copilot _meta.
46
46
  * HTTP requests must not overwrite the shared runtimeContext (AsyncLocalStorage
@@ -101,6 +101,8 @@ function extractWorkspaceFromMeta(meta) {
101
101
  * The bridge-readiness wait below is untouched — different, much shorter gate.
102
102
  */
103
103
  const DB_FREE_TOOLS = new Set(['get_knowledge']);
104
+ /** Tools that drive the progress reporter themselves, and must not be doubled up on. */
105
+ const SELF_REPORTING_TOOLS = new Set(['build_d365fo_project']);
104
106
  export function registerToolHandler(server, context) {
105
107
  startMetricsLogging();
106
108
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
@@ -125,8 +127,12 @@ export function registerToolHandler(server, context) {
125
127
  // ctx.dbReady resolves once the real symbol database is loaded; await it so
126
128
  // tools use the real index instead of silently returning empty results.
127
129
  // LOCAL_TOOLS need no DB (filesystem/in-memory config only) and skip the
128
- // wait; so do DB_FREE_TOOLS, whose answer is in-repo static data.
129
- if (context.dbReady && !LOCAL_TOOLS.has(toolName) && !DB_FREE_TOOLS.has(toolName)) {
130
+ // wait except DB_BACKED_LOCAL_TOOLS, which are local AND read the index, and
131
+ // whose exemption cost them the 55 s ceiling too. DB_FREE_TOOLS also skip it,
132
+ // their answer being in-repo static data.
133
+ const skipsDbWait = (LOCAL_TOOLS.has(toolName) && !DB_BACKED_LOCAL_TOOLS.has(toolName)) ||
134
+ DB_FREE_TOOLS.has(toolName);
135
+ if (context.dbReady && !skipsDbWait) {
130
136
  const t0 = Date.now();
131
137
  // Race dbReady against a 55-second timeout so VS Code's ~60 s client
132
138
  // timeout doesn't silently cancel the request. If the DB is still loading
@@ -268,6 +274,10 @@ export function registerToolHandler(server, context) {
268
274
  // The reporter never rejects (both sends are try/caught inside), and the
269
275
  // transport writes in call order, so the notification still precedes the result.
270
276
  void reportProgress(progressMsg, 0);
277
+ // …and then nothing, for however long the tool takes — see startProgressHeartbeat.
278
+ const stopHeartbeat = SELF_REPORTING_TOOLS.has(toolName)
279
+ ? null
280
+ : startProgressHeartbeat(reportProgress, progressMsg);
271
281
  return (async () => {
272
282
  switch (toolName) {
273
283
  case 'search':
@@ -340,7 +350,7 @@ export function registerToolHandler(server, context) {
340
350
  isError: true,
341
351
  };
342
352
  }
343
- })();
353
+ })().finally(() => stopHeartbeat?.());
344
354
  }));
345
355
  }
346
356
  catch (err) {
@@ -406,7 +406,7 @@ function extensionAddedFields(deps, tableName) {
406
406
  return names;
407
407
  }
408
408
  /**
409
- * `AslFinSK_QualityTier` → `qualitytier`. A member added to another model's
409
+ * `ConSK_QualityTier` → `qualitytier`. A member added to another model's
410
410
  * object carries that model's prefix (applyExtensionMemberPrefix mints it), so
411
411
  * the name in the agent's X++ and the name on disk routinely differ by exactly
412
412
  * this token.
@@ -34,6 +34,7 @@ export declare function findNewestMetadataMtime(rootDir: string): MtimeScanResul
34
34
  export type MtimeScanState = {
35
35
  status: 'ready';
36
36
  result: MtimeScanResult | null;
37
+ scannedAt: number;
37
38
  } | {
38
39
  status: 'pending';
39
40
  };
@@ -42,9 +43,16 @@ export type MtimeScanState = {
42
43
  *
43
44
  * `blocking: true` is the old behaviour — scan now, answer now — and is what
44
45
  * `diagnostics: true` uses, because the whole point of diagnostics is the full
45
- * picture. `blocking: false` answers from cache or says "pending" and schedules
46
- * the walk on a later tick, so the request path never carries it. Either way the
47
- * result lands in the same cache, so the NEXT call has the real verdict.
46
+ * picture. `blocking: false` answers from cache and schedules the walk on a later
47
+ * tick, so the request path never carries it.
48
+ *
49
+ * 'pending' is returned only while NO scan has ever completed for this root. Once
50
+ * one has, an expired entry is served — with the time it was taken — and a refresh
51
+ * is scheduled behind it. Expiring back to 'pending' made the "call again for the
52
+ * verdict" the caller is told to act on unwinnable: the entry lives SCAN_CACHE_MS
53
+ * (30 s) and an agent that re-asks any later than that gets the identical
54
+ * "still running" line, forever. Observed live on 2026-09-07: three
55
+ * get_workspace_info calls 105 s apart, byte-identical output every time.
48
56
  */
49
57
  export declare function findNewestMetadataMtimeCached(rootDir: string, opts?: {
50
58
  blocking?: boolean;
@@ -47,16 +47,27 @@ export function findNewestMetadataMtime(rootDir) {
47
47
  *
48
48
  * `blocking: true` is the old behaviour — scan now, answer now — and is what
49
49
  * `diagnostics: true` uses, because the whole point of diagnostics is the full
50
- * picture. `blocking: false` answers from cache or says "pending" and schedules
51
- * the walk on a later tick, so the request path never carries it. Either way the
52
- * result lands in the same cache, so the NEXT call has the real verdict.
50
+ * picture. `blocking: false` answers from cache and schedules the walk on a later
51
+ * tick, so the request path never carries it.
52
+ *
53
+ * 'pending' is returned only while NO scan has ever completed for this root. Once
54
+ * one has, an expired entry is served — with the time it was taken — and a refresh
55
+ * is scheduled behind it. Expiring back to 'pending' made the "call again for the
56
+ * verdict" the caller is told to act on unwinnable: the entry lives SCAN_CACHE_MS
57
+ * (30 s) and an agent that re-asks any later than that gets the identical
58
+ * "still running" line, forever. Observed live on 2026-09-07: three
59
+ * get_workspace_info calls 105 s apart, byte-identical output every time.
53
60
  */
54
61
  export function findNewestMetadataMtimeCached(rootDir, opts = {}) {
55
62
  const hit = scanCache.get(rootDir);
56
- if (hit && Date.now() - hit.at < SCAN_CACHE_MS)
57
- return { status: 'ready', result: hit.result };
58
- if (opts.blocking)
59
- return { status: 'ready', result: findNewestMetadataMtime(rootDir) };
63
+ if (hit && Date.now() - hit.at < SCAN_CACHE_MS) {
64
+ return { status: 'ready', result: hit.result, scannedAt: hit.at };
65
+ }
66
+ if (opts.blocking) {
67
+ findNewestMetadataMtime(rootDir);
68
+ const fresh = scanCache.get(rootDir);
69
+ return { status: 'ready', result: fresh.result, scannedAt: fresh.at };
70
+ }
60
71
  if (!scanInFlight.has(rootDir)) {
61
72
  scanInFlight.add(rootDir);
62
73
  // setTimeout, not a worker: the walk is synchronous fs work either way, but
@@ -75,6 +86,11 @@ export function findNewestMetadataMtimeCached(rootDir, opts = {}) {
75
86
  }, 0);
76
87
  timer.unref?.();
77
88
  }
89
+ // An expired entry still answers the question better than "ask again" does; the
90
+ // refresh just scheduled replaces it, and checkIndexStaleness qualifies a 'fresh'
91
+ // verdict drawn from an aged scan.
92
+ if (hit)
93
+ return { status: 'ready', result: hit.result, scannedAt: hit.at };
78
94
  return { status: 'pending' };
79
95
  }
80
96
  function scanNewestMetadataMtime(rootDir) {
@@ -176,6 +192,10 @@ export function checkIndexStaleness(lastIndexedAt, modelMetadataDir, opts = {})
176
192
  };
177
193
  }
178
194
  const scan = state.result;
195
+ // How old the walk behind this verdict is. Zero on the blocking path and on a
196
+ // cache hit inside SCAN_CACHE_MS; larger only when an expired entry is being
197
+ // served while its replacement runs — see findNewestMetadataMtimeCached.
198
+ const scanAgeMs = Date.now() - state.scannedAt;
179
199
  if (!scan) {
180
200
  lines.push(`ℹ️ No metadata files found under ${modelMetadataDir} — nothing to compare.`);
181
201
  return {
@@ -197,6 +217,20 @@ export function checkIndexStaleness(lastIndexedAt, modelMetadataDir, opts = {})
197
217
  ],
198
218
  };
199
219
  }
220
+ // A 'stale' verdict from an aged scan is still true — files only ever get newer —
221
+ // so only 'fresh' has to admit what it did not look at. TOLERANCE_MS is the window
222
+ // the comparison already forgives, so a scan younger than that adds no blind spot.
223
+ if (scanAgeMs > TOLERANCE_MS) {
224
+ const scanAgeMin = Math.round(scanAgeMs / 60_000);
225
+ lines.push(`✅ No workspace file was newer than the index as of the last scan (${scanAgeMin} min ago).`, ' A rescan is running in the background; call again for the current verdict.');
226
+ return {
227
+ status: 'fresh',
228
+ lines,
229
+ compactLines: [
230
+ `Index : up to date as of ${scanAgeMin} min ago (indexed ${ageHours} h ago, rescan running)`,
231
+ ],
232
+ };
233
+ }
200
234
  lines.push('✅ Index is up to date with the workspace.');
201
235
  return {
202
236
  status: 'fresh',
@@ -12,6 +12,50 @@
12
12
  * This handles objects that were just created and are not yet indexed in the symbol database.
13
13
  */
14
14
  export declare function findD365FileOnDisk(objectType: string, objectName: string, modelName?: string, explicitPackagePath?: string): Promise<string | null>;
15
+ /**
16
+ * The read surface findD365FileViaIndex needs, stated structurally so a util does
17
+ * not have to import the symbol index (and, through it, half the server) to ask
18
+ * one question of it. XppSymbolIndex satisfies it as-is.
19
+ */
20
+ export interface SymbolFileLookupSource {
21
+ getReadDb(): {
22
+ prepare(sql: string): {
23
+ all(...params: any[]): any[];
24
+ };
25
+ };
26
+ }
27
+ /** Where the index says an object lives, once the path has been verified on disk. */
28
+ export interface IndexedObjectFile {
29
+ filePath: string;
30
+ model: string;
31
+ }
32
+ /**
33
+ * Locate an object's XML through the SYMBOL INDEX rather than through the
34
+ * configured model's folder layout.
35
+ *
36
+ * findD365FileOnDisk answers "where would an object of this name be, in the model
37
+ * I am configured for" — which is the right question for a write and the wrong one
38
+ * for a read. Reads span every model: get_object_info(include="xml") on a Microsoft
39
+ * class, or on another custom model's class, hit the model-shaped lookup, missed,
40
+ * and were answered with "pass options.modelName" — a whole round trip to supply a
41
+ * fact the same server had already printed ("**Model:** Foundation") one call
42
+ * earlier, and one the caller then has to GUESS. Observed 2026-09-07: three such
43
+ * pairs in one session, one of which took three calls because the guess
44
+ * ("Application Foundation") was wrong.
45
+ *
46
+ * The index stores some paths absolute and some relative to a packages root (582
47
+ * of 60,918 classes on the production DB), so a relative row is resolved against
48
+ * the same roots findD365FileOnDisk prefers. Every candidate is checked on disk
49
+ * before it is returned: an index row whose file is gone is exactly the stale
50
+ * state `search` already warns about, and returning its path would turn a clean
51
+ * "not found" into "found it, but could not read it".
52
+ */
53
+ export declare function findD365FileViaIndex(index: SymbolFileLookupSource, objectType: string, objectName: string, modelName?: string): Promise<IndexedObjectFile | null>;
54
+ /**
55
+ * The models that hold an object of this type and name, for a "not found" message
56
+ * that can name the answer instead of asking the caller to guess it.
57
+ */
58
+ export declare function modelsHoldingObject(index: SymbolFileLookupSource, objectType: string, objectName: string, limit?: number): string[];
15
59
  /**
16
60
  * The path an object's XML WOULD have, whether or not it exists yet.
17
61
  *
@@ -147,6 +147,92 @@ export async function findD365FileOnDisk(objectType, objectName, modelName, expl
147
147
  }
148
148
  return null;
149
149
  }
150
+ /**
151
+ * Locate an object's XML through the SYMBOL INDEX rather than through the
152
+ * configured model's folder layout.
153
+ *
154
+ * findD365FileOnDisk answers "where would an object of this name be, in the model
155
+ * I am configured for" — which is the right question for a write and the wrong one
156
+ * for a read. Reads span every model: get_object_info(include="xml") on a Microsoft
157
+ * class, or on another custom model's class, hit the model-shaped lookup, missed,
158
+ * and were answered with "pass options.modelName" — a whole round trip to supply a
159
+ * fact the same server had already printed ("**Model:** Foundation") one call
160
+ * earlier, and one the caller then has to GUESS. Observed 2026-09-07: three such
161
+ * pairs in one session, one of which took three calls because the guess
162
+ * ("Application Foundation") was wrong.
163
+ *
164
+ * The index stores some paths absolute and some relative to a packages root (582
165
+ * of 60,918 classes on the production DB), so a relative row is resolved against
166
+ * the same roots findD365FileOnDisk prefers. Every candidate is checked on disk
167
+ * before it is returned: an index row whose file is gone is exactly the stale
168
+ * state `search` already warns about, and returning its path would turn a clean
169
+ * "not found" into "found it, but could not read it".
170
+ */
171
+ export async function findD365FileViaIndex(index, objectType, objectName, modelName) {
172
+ // Types with no folder of their own are not objects this can read a file for.
173
+ if (!AOT_FOLDER_BY_OBJECT_TYPE[objectType])
174
+ return null;
175
+ let rows;
176
+ try {
177
+ const db = index.getReadDb();
178
+ // parent_name IS NULL keeps methods and fields of the same name out; the
179
+ // (type, name) index carries this lookup, so it costs a seek, not a scan.
180
+ const sql = `SELECT file_path, model FROM symbols
181
+ WHERE type = ? AND name = ? AND parent_name IS NULL` +
182
+ (modelName ? ` AND model = ?` : ``) +
183
+ ` LIMIT 20`;
184
+ const params = modelName ? [objectType, objectName, modelName] : [objectType, objectName];
185
+ rows = db.prepare(sql).all(...params);
186
+ }
187
+ catch {
188
+ return null; // index unavailable (stub context during startup, :memory:) — caller falls back
189
+ }
190
+ if (rows.length === 0)
191
+ return null;
192
+ // Resolved on first need: most rows carry an absolute path (60,336 of 60,918
193
+ // classes on the production DB), and those need no config at all.
194
+ let roots = null;
195
+ const packageRoots = async () => {
196
+ if (roots)
197
+ return roots;
198
+ const configManager = getConfigManager();
199
+ await configManager.ensureLoaded();
200
+ roots = [
201
+ await configManager.getCustomPackagesPath(),
202
+ configManager.getPackagePath() || fallbackPackagePath(),
203
+ await configManager.getMicrosoftPackagesPath(),
204
+ ].filter((r) => !!r);
205
+ return roots;
206
+ };
207
+ for (const row of rows) {
208
+ const candidates = path.isAbsolute(row.file_path)
209
+ ? [row.file_path]
210
+ : (await packageRoots()).map(root => path.join(root, row.file_path));
211
+ for (const candidate of candidates) {
212
+ try {
213
+ await fs.access(candidate);
214
+ return { filePath: candidate, model: row.model };
215
+ }
216
+ catch { /* next candidate */ }
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+ /**
222
+ * The models that hold an object of this type and name, for a "not found" message
223
+ * that can name the answer instead of asking the caller to guess it.
224
+ */
225
+ export function modelsHoldingObject(index, objectType, objectName, limit = 5) {
226
+ try {
227
+ const rows = index.getReadDb().prepare(`SELECT DISTINCT model FROM symbols
228
+ WHERE type = ? AND name = ? AND parent_name IS NULL
229
+ ORDER BY model LIMIT ?`).all(objectType, objectName, limit);
230
+ return rows.map(r => r.model);
231
+ }
232
+ catch {
233
+ return [];
234
+ }
235
+ }
150
236
  /**
151
237
  * The path an object's XML WOULD have, whether or not it exists yet.
152
238
  *
@@ -54,7 +54,7 @@ const UNDERSCORE_EXTENSION = /^[A-Za-z]\w*_Extension$/;
54
54
  * Reinterpret `objectType` when the proposed name is unmistakably an extension.
55
55
  *
56
56
  * Callers reach for the base type — "is this a valid *form* name?" — while proposing
57
- * `AslFinCore_TaxTransReportChangeLog.AslFinSKExtension`. Validated as a plain form
57
+ * `ConCore_TaxTransReportChangeLog.ConSKExtension`. Validated as a plain form
58
58
  * that trips the "non-extension objects must not contain underscores" rule and comes
59
59
  * back as a hard ERROR, which is both wrong and a wasted round trip: run f2e7b71a
60
60
  * asked with `form`, was refused, and asked again with `form-extension` (T56 → T59).
@@ -23,6 +23,31 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
23
23
  * for a tool whose total is unknown. `total` may be omitted for open-ended work.
24
24
  */
25
25
  export type ProgressReporter = (message: string, progress: number, total?: number) => Promise<void>;
26
+ /**
27
+ * How often an in-flight tool call re-announces itself.
28
+ *
29
+ * Long enough that a normal call never sends one, short enough to stay inside the
30
+ * request timeouts clients reset on each notification (VS Code's is ~60 s).
31
+ */
32
+ export declare const PROGRESS_HEARTBEAT_MS = 15000;
33
+ /**
34
+ * Keep saying "still running" until the returned stop() is called.
35
+ *
36
+ * The dispatcher sends one notification when a tool starts, and then nothing for
37
+ * however long it takes — so a call that runs for minutes is indistinguishable from
38
+ * a hung one. On 2026-09-07 a first get_workspace_info ran 337.5 s behind a cold
39
+ * database open: the IDE showed "⚙️ Reading workspace configuration" and no further
40
+ * sign of life for five and a half minutes, and the server log (which mirrors
41
+ * stderr, and startup progress does not reach stderr) held nothing either.
42
+ *
43
+ * A tick says the call is alive and how long it has been running, and — for clients
44
+ * that honour progress notifications — resets their request timeout, the mechanism
45
+ * build_d365fo_project already relies on to survive a long xppc. Elapsed seconds is
46
+ * the progress value because MCP requires it to increase and no total is known.
47
+ * Nothing fires before the first tick, so tools answering in milliseconds send
48
+ * exactly what they sent before.
49
+ */
50
+ export declare function startProgressHeartbeat(report: ProgressReporter, message: string, everyMs?: number): () => void;
26
51
  /** The slice of the SDK's request `extra` that the reporter needs. */
27
52
  export interface ProgressRequestExtra {
28
53
  _meta?: Record<string, unknown>;
@@ -16,6 +16,40 @@
16
16
  * Both are best-effort: a client that rejects or ignores them must never fail
17
17
  * or stall the tool.
18
18
  */
19
+ /**
20
+ * How often an in-flight tool call re-announces itself.
21
+ *
22
+ * Long enough that a normal call never sends one, short enough to stay inside the
23
+ * request timeouts clients reset on each notification (VS Code's is ~60 s).
24
+ */
25
+ export const PROGRESS_HEARTBEAT_MS = 15_000;
26
+ /**
27
+ * Keep saying "still running" until the returned stop() is called.
28
+ *
29
+ * The dispatcher sends one notification when a tool starts, and then nothing for
30
+ * however long it takes — so a call that runs for minutes is indistinguishable from
31
+ * a hung one. On 2026-09-07 a first get_workspace_info ran 337.5 s behind a cold
32
+ * database open: the IDE showed "⚙️ Reading workspace configuration" and no further
33
+ * sign of life for five and a half minutes, and the server log (which mirrors
34
+ * stderr, and startup progress does not reach stderr) held nothing either.
35
+ *
36
+ * A tick says the call is alive and how long it has been running, and — for clients
37
+ * that honour progress notifications — resets their request timeout, the mechanism
38
+ * build_d365fo_project already relies on to survive a long xppc. Elapsed seconds is
39
+ * the progress value because MCP requires it to increase and no total is known.
40
+ * Nothing fires before the first tick, so tools answering in milliseconds send
41
+ * exactly what they sent before.
42
+ */
43
+ export function startProgressHeartbeat(report, message, everyMs = PROGRESS_HEARTBEAT_MS) {
44
+ const startedAt = Date.now();
45
+ const timer = setInterval(() => {
46
+ const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
47
+ void report(`${message} — still running, ${elapsedSec}s`, elapsedSec);
48
+ }, everyMs);
49
+ // Never hold the process open for a heartbeat.
50
+ timer.unref?.();
51
+ return () => clearInterval(timer);
52
+ }
19
53
  /**
20
54
  * Build a reporter bound to one in-flight tool call. Always returns a callable —
21
55
  * when the client offers neither channel the reporter is simply a no-op, so
@@ -184,7 +184,14 @@ export async function buildContextSnapshot(context, opts = {}) {
184
184
  recentObjects = cached.objects;
185
185
  }
186
186
  else {
187
- recentPending = true;
187
+ // 'pending' only until the FIRST scan lands. After that an expired entry is
188
+ // served and refreshed behind the answer, because "call again for the list"
189
+ // is otherwise a promise this cannot keep: the entry lives RECENT_CACHE_MS
190
+ // (30 s), so any re-ask later than that finds it expired and is told to call
191
+ // again — the same line, forever. Half a minute stale was already declared
192
+ // an accurate answer here; several minutes stale still beats no answer.
193
+ recentObjects = cached?.objects ?? [];
194
+ recentPending = !cached;
188
195
  // Not awaited. The result lands in the cache above, so the NEXT call
189
196
  // shows it — the information is deferred, never dropped.
190
197
  if (!recentScanInFlight.has(workspacePath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "d365fo-mcp",
3
- "version": "1.17.2",
3
+ "version": "1.17.3",
4
4
  "description": "MCP Server for X++ Code Completion in D365 Finance & Operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",