@xh/hoist 82.0.0 → 82.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 82.0.1 - 2026-02-28
4
+
5
+ ### 🐞 Bug Fixes
6
+
7
+ * Fixed a CSS issue causing desktop submenus to clip.
8
+
9
+ ### ⚙️ Technical
10
+
11
+ * Enhanced the MCP server's `hoist-search-symbols` tool to also search public members (properties,
12
+ methods, accessors) of 18 key framework classes. The TypeScript index is now built asynchronously
13
+ after server startup so the first tool call doesn't pay the initialization cost.
14
+
3
15
  ## 82.0.0 - 2026-02-27
4
16
 
5
17
  Note that a server-side upgrade to `hoist-core >= 36.3` is recommended to support new Admin Metrics
@@ -80,12 +80,6 @@
80
80
  0 20px 25px -5px rgba(0, 0, 0, 0.1),
81
81
  0 10px 15px -3px rgba(0, 0, 0, 0.1);
82
82
 
83
- // Clip content children to the popover's rounded corners so square-edged inner
84
- // elements (e.g. scrollable lists) don't poke through at the border-radius.
85
- .bp6-popover-content {
86
- overflow: hidden;
87
- }
88
-
89
83
  // Arrow border matches the popover border; arrow fill matches the popover background.
90
84
  .bp6-popover-arrow-border {
91
85
  fill: var(--xh-popup-border-color);
package/mcp/README.md CHANGED
@@ -85,12 +85,14 @@ curated metadata (title, description, category, search keywords) that cannot be
85
85
  from filenames alone. The metadata is aligned with the `docs/README.md` index tables. The
86
86
  tradeoff is manual maintenance -- see [Maintaining the MCP Server](#maintaining-the-mcp-server).
87
87
 
88
- **Lazy TypeScript initialization with eager indexing.** Parsing hoist-react's ~700 TypeScript files
89
- with ts-morph is expensive. The `Project` is created lazily on first TypeScript tool invocation
90
- (not at server startup), keeping cold start fast. Once created, a lightweight symbol index
91
- (name-to-location map) is built eagerly using AST-level methods (`getClasses()`,
92
- `getInterfaces()`, etc.) rather than full type resolution. Detailed symbol information (signatures,
93
- JSDoc, members) is extracted on-demand from individual source files.
88
+ **Eager async TypeScript initialization.** Parsing hoist-react's ~700 TypeScript files with ts-morph
89
+ is expensive (~2-3s). After the server connects, `beginInitialization()` kicks off index building
90
+ asynchronously so it runs in the background while the client sets up. If a tool call arrives before
91
+ init completes, `ensureInitialized()` awaits the in-flight work. In practice, the index is
92
+ typically ready before the first tool invocation. The symbol and member indexes are built using
93
+ AST-level methods (`getClasses()`, `getInterfaces()`, etc.) rather than full type resolution.
94
+ Detailed symbol information (signatures, JSDoc, full member lists) is extracted on-demand from
95
+ individual source files.
94
96
 
95
97
  **Resources for nouns, tools for verbs.** Following MCP protocol design guidance: resources serve
96
98
  passive, addressable content (individual docs by URI), while tools handle dynamic computation
@@ -214,17 +216,24 @@ Verify the MCP server is running and responsive. Takes no parameters.
214
216
 
215
217
  #### `hoist-search-symbols`
216
218
 
217
- Search for TypeScript classes, interfaces, types, and functions by name.
219
+ Search for TypeScript classes, interfaces, types, and functions by name. Also searches public
220
+ members (properties, methods, accessors) of key framework classes, returning results in two sections:
221
+ matching symbols and matching members with their owning class and role description.
218
222
 
219
223
  | Parameter | Type | Required | Description |
220
224
  |-----------|------|----------|-------------|
221
- | `query` | string | Yes | Symbol name or partial name (e.g. `"GridModel"`, `"Store"`) |
222
- | `kind` | enum | No | Filter: `class`, `interface`, `type`, `function`, `const`, `enum` |
225
+ | `query` | string | Yes | Symbol or member name to search for (e.g. `"GridModel"`, `"lastLoadCompleted"`, `"setSortBy"`) |
226
+ | `kind` | enum | No | Filter symbols by kind: `class`, `interface`, `type`, `function`, `const`, `enum`. Does not affect member results. |
223
227
  | `exported` | boolean | No | Exported symbols only. Default: `true` |
224
- | `limit` | number | No | Max results, 1-50. Default: 20 |
228
+ | `limit` | number | No | Max symbol results, 1-50. Default: 20. Member results have a separate cap of 15. |
225
229
 
226
- **Note:** The TypeScript index is built lazily on first invocation (typically under 5 seconds).
227
- Subsequent calls are fast.
230
+ **Member-indexed classes:** HoistBase, HoistModel, HoistService, XHApi, GridModel, Column, Store,
231
+ StoreRecord, StoreSelectionModel, Field, RecordAction, Cube, CubeField, View, FormModel,
232
+ BaseFieldModel, FieldModel, TabContainerModel. Only public members are indexed (private members and
233
+ those prefixed with `_` are excluded).
234
+
235
+ **Note:** The TypeScript index is built asynchronously after server startup (~2-3s). It is typically
236
+ ready before the first tool call. Subsequent calls are fast in-memory lookups.
228
237
 
229
238
  #### `hoist-get-symbol`
230
239
 
@@ -373,6 +382,26 @@ const TOP_LEVEL_PACKAGES = [
373
382
  ];
374
383
  ```
375
384
 
385
+ ### Member-Indexed Classes
386
+
387
+ **File:** `mcp/data/ts-registry.ts` (constant `MEMBER_INDEXED_CLASSES`)
388
+
389
+ This map lists classes whose public members are indexed for search by member name via
390
+ `hoist-search-symbols`. Each entry maps a class name to a brief role description shown alongside
391
+ member search results (e.g. "base class for all application models").
392
+
393
+ **When to update:**
394
+ - A new key base class is added to the framework and should have its members searchable
395
+ - A member-indexed class is renamed or removed
396
+ - The role description of a class should be clarified
397
+
398
+ **Current value:**
399
+ ```
400
+ HoistBase, HoistModel, HoistService, XHApi, GridModel, Column, Store,
401
+ StoreRecord, StoreSelectionModel, Field, RecordAction, Cube, CubeField,
402
+ View, FormModel, BaseFieldModel, FieldModel, TabContainerModel
403
+ ```
404
+
376
405
  ### Prompt Documentation Section References
377
406
 
378
407
  **Files:** `mcp/prompts/grid.ts`, `mcp/prompts/form.ts`, `mcp/prompts/tabs.ts`
@@ -429,6 +458,7 @@ were replaced, or if `makeObservable` were no longer required.
429
458
  | Add/rename/remove a documentation file | `mcp/data/doc-registry.ts`, `docs/README.md` |
430
459
  | Add upgrade notes for a new major version | `mcp/data/doc-registry.ts`, `docs/README.md` |
431
460
  | Add/rename/remove a top-level package | `mcp/data/ts-registry.ts` |
461
+ | Add/rename/remove a member-indexed class | `mcp/data/ts-registry.ts` (constant `MEMBER_INDEXED_CLASSES`) |
432
462
  | Rename a section header in a component README | Check `mcp/prompts/grid.ts`, `form.ts`, `tabs.ts` |
433
463
  | Rename a key class or remove a key member | Check `mcp/prompts/grid.ts`, `form.ts`, `tabs.ts` |
434
464
  | Change a fundamental coding convention | `mcp/prompts/util.ts` |
@@ -6,12 +6,15 @@
6
6
  * information. This module is consumed by the three TypeScript MCP tools:
7
7
  * hoist-search-symbols, hoist-get-symbol, and hoist-get-members.
8
8
  *
9
- * The ts-morph Project is created lazily (on first invocation, not at server
10
- * startup) to avoid cold start delays. Once created, a lightweight symbol
11
- * index is eagerly built using AST-level methods for fast search without
12
- * per-query AST traversal. Detailed symbol info is extracted on-demand.
9
+ * Initialization is kicked off asynchronously after server startup via
10
+ * `beginInitialization()`, so the index is typically ready before the first
11
+ * tool call arrives. If a tool call arrives before init completes,
12
+ * `ensureInitialized()` awaits the in-flight init. Once created, a
13
+ * lightweight symbol index and parallel member index are built using
14
+ * AST-level methods for fast search without per-query AST traversal.
15
+ * Detailed symbol info is extracted on-demand.
13
16
  */
14
- import {Project, Node, SyntaxKind} from 'ts-morph';
17
+ import {Project, Node, Scope, SyntaxKind} from 'ts-morph';
15
18
  import type {ClassDeclaration, SourceFile} from 'ts-morph';
16
19
  import {resolve} from 'node:path';
17
20
 
@@ -61,12 +64,27 @@ export interface MemberInfo {
61
64
  returnType?: string;
62
65
  }
63
66
 
67
+ /** Lightweight index entry for a class member, enabling search by member name. */
68
+ export interface MemberIndexEntry {
69
+ name: string;
70
+ memberKind: 'property' | 'method' | 'accessor';
71
+ ownerName: string;
72
+ ownerDescription: string;
73
+ filePath: string;
74
+ sourcePackage: string;
75
+ isStatic: boolean;
76
+ type: string;
77
+ jsDoc: string;
78
+ decorators: string[];
79
+ }
80
+
64
81
  //------------------------------------------------------------------
65
82
  // Module state (lazy initialization)
66
83
  //------------------------------------------------------------------
67
84
 
68
85
  let project: Project | null = null;
69
86
  let symbolIndex: Map<string, SymbolEntry[]> | null = null;
87
+ let memberIndex: Map<string, MemberIndexEntry[]> | null = null;
70
88
 
71
89
  //------------------------------------------------------------------
72
90
  // Package derivation
@@ -92,6 +110,43 @@ const TOP_LEVEL_PACKAGES = [
92
110
  'icon'
93
111
  ];
94
112
 
113
+ /**
114
+ * Classes whose public members are indexed for search by member name.
115
+ * Values are brief role descriptions shown in search results to clarify
116
+ * how the class fits into the framework hierarchy.
117
+ */
118
+ const MEMBER_INDEXED_CLASSES = new Map([
119
+ // Core framework base classes
120
+ ['HoistBase', 'base class for all Hoist objects (models, services, stores)'],
121
+ ['HoistModel', 'base class for all application models'],
122
+ ['HoistService', 'base class for all application services'],
123
+ ['XHApi', 'singleton (XH) providing global framework services'],
124
+
125
+ // Grid
126
+ ['GridModel', 'model backing all grid components'],
127
+ ['Column', 'column configuration for grids'],
128
+
129
+ // Data
130
+ ['Store', 'in-memory data store used by grids and other data components'],
131
+ ['StoreRecord', 'individual record within a Store'],
132
+ ['StoreSelectionModel', 'selection state manager for Store, used by grids'],
133
+ ['Field', 'metadata for a data field within a Store or Cube'],
134
+ ['RecordAction', 'reusable action for grid context menus and action columns'],
135
+
136
+ // Cube
137
+ ['Cube', 'multi-dimensional data store with aggregation and views'],
138
+ ['CubeField', 'field with aggregation metadata for use within a Cube'],
139
+ ['View', 'live or snapshot view of aggregated Cube data'],
140
+
141
+ // Form
142
+ ['FormModel', 'model for form state, field values, and validation'],
143
+ ['BaseFieldModel', 'base class for FieldModel — holds value, validation, and dirty tracking'],
144
+ ['FieldModel', 'model for a single form field (extends BaseFieldModel)'],
145
+
146
+ // Tabs
147
+ ['TabContainerModel', 'model for tabbed container with routing and refresh support']
148
+ ]);
149
+
95
150
  /**
96
151
  * Derive the source package from a file's absolute path.
97
152
  * e.g. `/repo/core/HoistBase.ts` maps to `core`,
@@ -134,18 +189,68 @@ function addToIndex(index: Map<string, SymbolEntry[]>, entry: SymbolEntry): void
134
189
  }
135
190
  }
136
191
 
192
+ /** Add a member entry to the member index, keyed by lowercase member name. */
193
+ function addToMemberIndex(index: Map<string, MemberIndexEntry[]>, entry: MemberIndexEntry): void {
194
+ const key = entry.name.toLowerCase();
195
+ const existing = index.get(key);
196
+ if (existing) {
197
+ existing.push(entry);
198
+ } else {
199
+ index.set(key, [entry]);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Check if a member should be excluded from the index as private/internal.
205
+ * Excludes members with the `private` keyword or names starting with `_`.
206
+ */
207
+ function isPrivateMember(member: MemberInfo, cls: ClassDeclaration): boolean {
208
+ if (member.name.startsWith('_')) return true;
209
+
210
+ // Check for explicit `private` keyword on the AST node
211
+ try {
212
+ const node =
213
+ cls.getProperty(member.name) ??
214
+ cls.getGetAccessor(member.name) ??
215
+ cls.getMethod(member.name) ??
216
+ cls.getStaticProperty(member.name) ??
217
+ cls.getStaticMethod(member.name);
218
+ if (node) {
219
+ const scope = (node as unknown as {getScope?: () => string}).getScope?.();
220
+ if (scope === Scope.Private) return true;
221
+ }
222
+ } catch {
223
+ // If we can't determine scope, keep the member (assume public)
224
+ }
225
+
226
+ return false;
227
+ }
228
+
229
+ /** Format a method's parameter list and return type as a compact type string. */
230
+ function formatMethodType(member: MemberInfo): string {
231
+ const params = (member.parameters ?? []).map(p => `${p.name}: ${p.type}`).join(', ');
232
+ const ret = member.returnType ?? 'void';
233
+ return `(${params}) => ${ret}`;
234
+ }
235
+
137
236
  /**
138
237
  * Build the symbol index by scanning all source files using AST-level methods.
238
+ * Also builds a parallel member index for classes in MEMBER_INDEXED_CLASSES.
139
239
  *
140
240
  * Uses getClasses(), getInterfaces(), getTypeAliases(), getFunctions(),
141
241
  * getEnums(), and getVariableStatements() -- NOT getExportedDeclarations(),
142
242
  * which triggers full type binding and is ~1000x slower.
143
243
  */
144
- function buildSymbolIndex(proj: Project): Map<string, SymbolEntry[]> {
244
+ function buildSymbolIndex(proj: Project): {
245
+ symbols: Map<string, SymbolEntry[]>;
246
+ members: Map<string, MemberIndexEntry[]>;
247
+ } {
145
248
  const index = new Map<string, SymbolEntry[]>();
249
+ const mIndex = new Map<string, MemberIndexEntry[]>();
146
250
  const repoRoot = resolveRepoRoot();
147
251
 
148
252
  const counts = {total: 0, exported: 0, byKind: {} as Record<string, number>};
253
+ let memberCount = 0;
149
254
 
150
255
  for (const sourceFile of proj.getSourceFiles()) {
151
256
  const filePath = sourceFile.getFilePath();
@@ -182,6 +287,33 @@ function buildSymbolIndex(proj: Project): Map<string, SymbolEntry[]> {
182
287
  counts.total++;
183
288
  if (entry.isExported) counts.exported++;
184
289
  counts.byKind['class'] = (counts.byKind['class'] || 0) + 1;
290
+
291
+ // Index public members for curated framework classes
292
+ const ownerDescription = MEMBER_INDEXED_CLASSES.get(name);
293
+ if (ownerDescription) {
294
+ try {
295
+ const members = extractClassMembers(sourceFile, name);
296
+ for (const m of members) {
297
+ if (isPrivateMember(m, cls)) continue;
298
+ const mEntry: MemberIndexEntry = {
299
+ name: m.name,
300
+ memberKind: m.kind,
301
+ ownerName: name,
302
+ ownerDescription,
303
+ filePath,
304
+ sourcePackage: pkg,
305
+ isStatic: m.isStatic,
306
+ type: m.kind === 'method' ? formatMethodType(m) : m.type,
307
+ jsDoc: m.jsDoc.split('\n')[0],
308
+ decorators: m.decorators
309
+ };
310
+ addToMemberIndex(mIndex, mEntry);
311
+ memberCount++;
312
+ }
313
+ } catch (e) {
314
+ log.warn(`Failed to index members for ${name}: ${e}`);
315
+ }
316
+ }
185
317
  }
186
318
 
187
319
  // Interfaces
@@ -280,21 +412,22 @@ function buildSymbolIndex(proj: Project): Map<string, SymbolEntry[]> {
280
412
  log.info(
281
413
  `Symbol index built: ${counts.total} total symbols (${counts.exported} exported) -- ${kindSummary}`
282
414
  );
415
+ log.info(
416
+ `Member index built: ${memberCount} public members across ${MEMBER_INDEXED_CLASSES.size} classes`
417
+ );
283
418
 
284
- return index;
419
+ return {symbols: index, members: mIndex};
285
420
  }
286
421
 
287
422
  //------------------------------------------------------------------
288
423
  // Public API
289
424
  //------------------------------------------------------------------
290
425
 
291
- /**
292
- * Ensure the ts-morph Project and symbol index are initialized.
293
- *
294
- * Creates the Project lazily on first call, then eagerly builds the symbol
295
- * index. Subsequent calls return immediately. Safe to call multiple times.
296
- */
297
- export function ensureInitialized(): void {
426
+ /** Promise for in-flight initialization, used to coordinate eager and on-demand init. */
427
+ let initPromise: Promise<void> | null = null;
428
+
429
+ /** Synchronous init heavy lifting, runs on a microtask when kicked off eagerly. */
430
+ function doInitialize(): void {
298
431
  if (project) return;
299
432
 
300
433
  const startMs = Date.now();
@@ -305,7 +438,9 @@ export function ensureInitialized(): void {
305
438
  });
306
439
  project.resolveSourceFileDependencies();
307
440
 
308
- symbolIndex = buildSymbolIndex(project);
441
+ const result = buildSymbolIndex(project);
442
+ symbolIndex = result.symbols;
443
+ memberIndex = result.members;
309
444
 
310
445
  const elapsed = Date.now() - startMs;
311
446
  log.info(`TypeScript registry initialized in ${elapsed}ms`);
@@ -314,17 +449,44 @@ export function ensureInitialized(): void {
314
449
  }
315
450
  }
316
451
 
452
+ /**
453
+ * Begin TypeScript registry initialization in the background.
454
+ *
455
+ * Call this after server startup to warm the index asynchronously, so the
456
+ * first tool invocation doesn't pay the full init cost. Safe to call
457
+ * multiple times — subsequent calls are no-ops.
458
+ */
459
+ export function beginInitialization(): void {
460
+ if (project || initPromise) return;
461
+ initPromise = Promise.resolve().then(() => {
462
+ doInitialize();
463
+ initPromise = null;
464
+ });
465
+ }
466
+
467
+ /**
468
+ * Ensure the ts-morph Project and symbol index are initialized.
469
+ *
470
+ * If {@link beginInitialization} was called, awaits the in-flight init.
471
+ * Otherwise initializes synchronously. Safe to call multiple times.
472
+ */
473
+ export async function ensureInitialized(): Promise<void> {
474
+ if (project) return;
475
+ if (initPromise) return initPromise;
476
+ doInitialize();
477
+ }
478
+
317
479
  /**
318
480
  * Search the symbol index by query string.
319
481
  *
320
482
  * Supports case-insensitive substring matching against symbol names.
321
483
  * Optionally filter by kind and/or export status.
322
484
  */
323
- export function searchSymbols(
485
+ export async function searchSymbols(
324
486
  query: string,
325
487
  options?: {kind?: SymbolKind; exported?: boolean; limit?: number}
326
- ): SymbolEntry[] {
327
- ensureInitialized();
488
+ ): Promise<SymbolEntry[]> {
489
+ await ensureInitialized();
328
490
 
329
491
  const queryLower = query.toLowerCase().trim();
330
492
  if (!queryLower) return [];
@@ -358,6 +520,44 @@ export function searchSymbols(
358
520
  return results.slice(0, limit);
359
521
  }
360
522
 
523
+ /**
524
+ * Search the member index by query string.
525
+ *
526
+ * Supports case-insensitive substring matching against member names.
527
+ * Only searches members of classes in MEMBER_INDEXED_CLASSES.
528
+ */
529
+ export async function searchMembers(
530
+ query: string,
531
+ options?: {limit?: number}
532
+ ): Promise<MemberIndexEntry[]> {
533
+ await ensureInitialized();
534
+
535
+ const queryLower = query.toLowerCase().trim();
536
+ if (!queryLower) return [];
537
+
538
+ const limit = options?.limit ?? 15;
539
+ const results: MemberIndexEntry[] = [];
540
+
541
+ for (const [key, entries] of memberIndex!) {
542
+ if (!key.includes(queryLower)) continue;
543
+ results.push(...entries);
544
+ }
545
+
546
+ // Sort: exact matches first, then alphabetically by member name, then by owner
547
+ results.sort((a, b) => {
548
+ const aExact = a.name.toLowerCase() === queryLower ? 0 : 1;
549
+ const bExact = b.name.toLowerCase() === queryLower ? 0 : 1;
550
+ if (aExact !== bExact) return aExact - bExact;
551
+
552
+ const nameCompare = a.name.localeCompare(b.name);
553
+ if (nameCompare !== 0) return nameCompare;
554
+
555
+ return a.ownerName.localeCompare(b.ownerName);
556
+ });
557
+
558
+ return results.slice(0, limit);
559
+ }
560
+
361
561
  /**
362
562
  * Get detailed information about a specific symbol.
363
563
  *
@@ -365,8 +565,11 @@ export function searchSymbols(
365
565
  * If filePath is provided, filters to that specific file. If multiple matches
366
566
  * and no filePath, returns the first exported match.
367
567
  */
368
- export function getSymbolDetail(name: string, filePath?: string): SymbolDetail | null {
369
- ensureInitialized();
568
+ export async function getSymbolDetail(
569
+ name: string,
570
+ filePath?: string
571
+ ): Promise<SymbolDetail | null> {
572
+ await ensureInitialized();
370
573
 
371
574
  const entry = findIndexEntry(name, filePath);
372
575
  if (!entry) return null;
@@ -384,11 +587,11 @@ export function getSymbolDetail(name: string, filePath?: string): SymbolDetail |
384
587
  *
385
588
  * Returns null for symbol kinds other than class or interface.
386
589
  */
387
- export function getMembers(
590
+ export async function getMembers(
388
591
  name: string,
389
592
  filePath?: string
390
- ): {symbol: SymbolDetail; members: MemberInfo[]} | null {
391
- ensureInitialized();
593
+ ): Promise<{symbol: SymbolDetail; members: MemberInfo[]} | null> {
594
+ await ensureInitialized();
392
595
 
393
596
  const entry = findIndexEntry(name, filePath);
394
597
  if (!entry) return null;
@@ -82,8 +82,8 @@ export async function buildFormPrompt(args: {
82
82
  //------------------------------------------------------------------
83
83
  // Type information
84
84
  //------------------------------------------------------------------
85
- const formModelSummary = formatSymbolSummary('FormModel');
86
- const formModelMembers = formatKeyMembers('FormModel', [
85
+ const formModelSummary = await formatSymbolSummary('FormModel');
86
+ const formModelMembers = await formatKeyMembers('FormModel', [
87
87
  'fields',
88
88
  'values',
89
89
  'isValid',
@@ -92,7 +92,7 @@ export async function buildFormPrompt(args: {
92
92
  'reset',
93
93
  'getData'
94
94
  ]);
95
- const fieldModelSummary = formatSymbolSummary('FieldModel');
95
+ const fieldModelSummary = await formatSymbolSummary('FieldModel');
96
96
 
97
97
  //------------------------------------------------------------------
98
98
  // Build adaptive code template
@@ -66,8 +66,8 @@ export async function buildGridPrompt(args: {
66
66
  //------------------------------------------------------------------
67
67
  // Type information
68
68
  //------------------------------------------------------------------
69
- const gridModelSummary = formatSymbolSummary('GridModel');
70
- const gridModelMembers = formatKeyMembers('GridModel', [
69
+ const gridModelSummary = await formatSymbolSummary('GridModel');
70
+ const gridModelMembers = await formatKeyMembers('GridModel', [
71
71
  'store',
72
72
  'columns',
73
73
  'sortBy',
@@ -76,7 +76,7 @@ export async function buildGridPrompt(args: {
76
76
  'emptyText',
77
77
  'onRowDoubleClicked'
78
78
  ]);
79
- const columnSummary = formatSymbolSummary('Column');
79
+ const columnSummary = await formatSymbolSummary('Column');
80
80
 
81
81
  //------------------------------------------------------------------
82
82
  // Build adaptive code template
@@ -78,8 +78,8 @@ export async function buildTabsPrompt(args: {
78
78
  //------------------------------------------------------------------
79
79
  // Type information
80
80
  //------------------------------------------------------------------
81
- const tabContainerModelSummary = formatSymbolSummary('TabContainerModel');
82
- const tabContainerModelMembers = formatKeyMembers('TabContainerModel', [
81
+ const tabContainerModelSummary = await formatSymbolSummary('TabContainerModel');
82
+ const tabContainerModelMembers = await formatKeyMembers('TabContainerModel', [
83
83
  'tabs',
84
84
  'activeTabId',
85
85
  'route',
@@ -7,7 +7,7 @@
7
7
  * to compose structured, context-rich prompt responses.
8
8
  */
9
9
  import {buildRegistry, loadDocContent, type DocEntry} from '../data/doc-registry.js';
10
- import {ensureInitialized, getSymbolDetail, getMembers} from '../data/ts-registry.js';
10
+ import {getSymbolDetail, getMembers} from '../data/ts-registry.js';
11
11
  import {resolveRepoRoot} from '../util/paths.js';
12
12
 
13
13
  //------------------------------------------------------------------
@@ -88,9 +88,8 @@ export function extractSection(markdown: string, sectionName: string): string {
88
88
  *
89
89
  * @returns A formatted markdown block, or a "not found" message.
90
90
  */
91
- export function formatSymbolSummary(name: string): string {
92
- ensureInitialized();
93
- const detail = getSymbolDetail(name);
91
+ export async function formatSymbolSummary(name: string): Promise<string> {
92
+ const detail = await getSymbolDetail(name);
94
93
  if (!detail) return `(Symbol '${name}' not found)`;
95
94
 
96
95
  const lines: string[] = [
@@ -120,9 +119,11 @@ export function formatSymbolSummary(name: string): string {
120
119
  *
121
120
  * @returns The formatted member list as a string.
122
121
  */
123
- export function formatKeyMembers(symbolName: string, memberNames?: string[]): string {
124
- ensureInitialized();
125
- const result = getMembers(symbolName);
122
+ export async function formatKeyMembers(
123
+ symbolName: string,
124
+ memberNames?: string[]
125
+ ): Promise<string> {
126
+ const result = await getMembers(symbolName);
126
127
  if (!result) return `(Symbol '${symbolName}' not found or has no members)`;
127
128
 
128
129
  let {members} = result;
package/mcp/server.ts CHANGED
@@ -5,6 +5,7 @@ import {registerDocResources} from './resources/docs.js';
5
5
  import {registerDocTools} from './tools/docs.js';
6
6
  import {registerTsTools} from './tools/typescript.js';
7
7
  import {registerPrompts} from './prompts/index.js';
8
+ import {beginInitialization} from './data/ts-registry.js';
8
9
 
9
10
  const server = new McpServer({
10
11
  name: 'hoist-react',
@@ -20,3 +21,7 @@ const transport = new StdioServerTransport();
20
21
  await server.connect(transport);
21
22
 
22
23
  log.info('Server started, awaiting MCP client connection via stdio');
24
+
25
+ // Warm the TypeScript symbol and member indexes in the background so the
26
+ // first tool invocation doesn't pay the full initialization cost.
27
+ beginInitialization();
@@ -9,11 +9,12 @@ import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import {z} from 'zod';
10
10
 
11
11
  import {
12
- ensureInitialized,
13
12
  searchSymbols,
13
+ searchMembers,
14
14
  getSymbolDetail,
15
15
  getMembers,
16
- type MemberInfo
16
+ type MemberInfo,
17
+ type MemberIndexEntry
17
18
  } from '../data/ts-registry.js';
18
19
  import {resolveRepoRoot} from '../util/paths.js';
19
20
 
@@ -58,6 +59,24 @@ function formatMember(member: MemberInfo): string {
58
59
  return lines.join('\n');
59
60
  }
60
61
 
62
+ /**
63
+ * Format a MemberIndexEntry as a readable line for search results.
64
+ * e.g. `1. [accessor] lastLoadCompleted: Date (on HoistModel — base class for all application models)`
65
+ * ` Timestamp of most recent successful load completion`
66
+ */
67
+ function formatMemberIndexEntry(entry: MemberIndexEntry, index: number): string {
68
+ const lines: string[] = [];
69
+ const staticPrefix = entry.isStatic ? 'static ' : '';
70
+ const typeStr = truncateType(entry.type);
71
+ lines.push(
72
+ `${index}. [${entry.memberKind}] ${staticPrefix}${entry.name}: ${typeStr} (on ${entry.ownerName} \u2014 ${entry.ownerDescription})`
73
+ );
74
+ if (entry.jsDoc) {
75
+ lines.push(` ${entry.jsDoc}`);
76
+ }
77
+ return lines.join('\n');
78
+ }
79
+
61
80
  /**
62
81
  * Register all TypeScript symbol exploration tools on the given MCP server.
63
82
  *
@@ -74,12 +93,12 @@ export function registerTsTools(server: McpServer): void {
74
93
  {
75
94
  title: 'Search Hoist TypeScript Symbols',
76
95
  description:
77
- 'Search for TypeScript classes, interfaces, types, and functions across the hoist-react framework by name. Returns matching symbols with their kind, source file, and package. Use this to find symbols before getting detailed information.',
96
+ 'Search for TypeScript classes, interfaces, types, and functions across the hoist-react framework by name. Also searches public members (properties, methods, accessors) of key framework classes like HoistModel, GridModel, Store, and others. Returns matching symbols and members with their kind, source, and context.',
78
97
  inputSchema: z.object({
79
98
  query: z
80
99
  .string()
81
100
  .describe(
82
- 'Symbol name or partial name to search for (e.g. "GridModel", "Store", "Panel")'
101
+ 'Symbol or member name to search for (e.g. "GridModel", "Store", "lastLoadCompleted", "setSortBy")'
83
102
  ),
84
103
  kind: z
85
104
  .enum(['class', 'interface', 'type', 'function', 'const', 'enum'])
@@ -99,26 +118,47 @@ export function registerTsTools(server: McpServer): void {
99
118
  }
100
119
  },
101
120
  async ({query, kind, exported, limit}) => {
102
- ensureInitialized();
103
- const results = searchSymbols(query, {
121
+ const symbolLimit = limit ?? 20;
122
+ const symbolResults = await searchSymbols(query, {
104
123
  kind,
105
124
  exported: exported ?? true,
106
- limit: limit ?? 20
125
+ limit: symbolLimit
107
126
  });
108
127
 
109
- let text: string;
110
- if (results.length === 0) {
111
- text = `No symbols found matching '${query}'. Try a broader search term.`;
112
- } else {
113
- const lines = [`Found ${results.length} symbols matching '${query}':\n`];
114
- results.forEach((result, i) => {
128
+ // Search members with a separate cap; if no symbols match, give
129
+ // members more room so the query is still useful.
130
+ const memberLimit = symbolResults.length === 0 ? symbolLimit : 15;
131
+ const memberResults = await searchMembers(query, {limit: memberLimit});
132
+
133
+ const lines: string[] = [];
134
+
135
+ if (symbolResults.length > 0) {
136
+ lines.push(`Symbols (${symbolResults.length} matches):\n`);
137
+ symbolResults.forEach((result, i) => {
115
138
  lines.push(
116
139
  `${i + 1}. [${result.kind}] ${result.name} (package: ${result.sourcePackage}, file: ${toRelativePath(result.filePath)}, exported: ${result.isExported ? 'yes' : 'no'})`
117
140
  );
118
141
  });
119
- text = lines.join('\n');
120
142
  }
121
143
 
144
+ if (memberResults.length > 0) {
145
+ if (lines.length > 0) lines.push('');
146
+ lines.push(`Members of key classes (${memberResults.length} matches):\n`);
147
+ memberResults.forEach((m, i) => {
148
+ lines.push(formatMemberIndexEntry(m, i + 1));
149
+ });
150
+ }
151
+
152
+ if (lines.length > 0) {
153
+ lines.push('');
154
+ lines.push('Tip: Use hoist-get-members to see all members of a specific class.');
155
+ }
156
+
157
+ const text =
158
+ lines.length > 0
159
+ ? lines.join('\n')
160
+ : `No symbols or members found matching '${query}'. Try a broader search term.`;
161
+
122
162
  return {content: [{type: 'text' as const, text}]};
123
163
  }
124
164
  );
@@ -151,8 +191,7 @@ export function registerTsTools(server: McpServer): void {
151
191
  }
152
192
  },
153
193
  async ({name, filePath}) => {
154
- ensureInitialized();
155
- const detail = getSymbolDetail(name, filePath);
194
+ const detail = await getSymbolDetail(name, filePath);
156
195
 
157
196
  let text: string;
158
197
  if (!detail) {
@@ -225,8 +264,7 @@ export function registerTsTools(server: McpServer): void {
225
264
  }
226
265
  },
227
266
  async ({name, filePath}) => {
228
- ensureInitialized();
229
- const result = getMembers(name, filePath);
267
+ const result = await getMembers(name, filePath);
230
268
 
231
269
  let text: string;
232
270
  if (!result) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "82.0.0",
3
+ "version": "82.0.1",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",