magector 2.5.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +5 -5
  2. package/src/mcp-server.js +359 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "Semantic code search for Magento 2 — index, search, MCP server",
5
5
  "type": "module",
6
6
  "main": "src/mcp-server.js",
@@ -33,10 +33,10 @@
33
33
  "ruvector": "^0.1.96"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@magector/cli-darwin-arm64": "2.5.0",
37
- "@magector/cli-linux-x64": "2.5.0",
38
- "@magector/cli-linux-arm64": "2.5.0",
39
- "@magector/cli-win32-x64": "2.5.0"
36
+ "@magector/cli-darwin-arm64": "2.5.2",
37
+ "@magector/cli-linux-x64": "2.5.2",
38
+ "@magector/cli-linux-arm64": "2.5.2",
39
+ "@magector/cli-win32-x64": "2.5.2"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -1004,6 +1004,41 @@ function readMethodSnippet(filePath, methodName, maxLines = 15) {
1004
1004
  return null;
1005
1005
  }
1006
1006
 
1007
+ /**
1008
+ * Read the FULL method body using brace-counting to find the closing brace.
1009
+ * Unlike readMethodSnippet (fixed N lines), this extracts the complete method
1010
+ * regardless of length, up to a safety limit.
1011
+ * @param {string} filePath - absolute or relative PHP file path
1012
+ * @param {string} methodName - method name to find
1013
+ * @param {number} maxLines - safety limit to prevent reading huge methods (default: 60)
1014
+ * @returns {string|null} complete method source or null if not found
1015
+ */
1016
+ function readFullMethodBody(filePath, methodName, maxLines = 60) {
1017
+ const absPath = filePath.startsWith('/') ? filePath : path.join(config.magentoRoot, filePath);
1018
+ let content;
1019
+ try { content = readFileSync(absPath, 'utf-8'); } catch { return null; }
1020
+ const lines = content.split('\n');
1021
+ for (let i = 0; i < lines.length; i++) {
1022
+ if (lines[i].includes(`function ${methodName}(`)) {
1023
+ // Use brace counting to find the complete method body
1024
+ let braceCount = 0;
1025
+ let started = false;
1026
+ for (let j = i; j < lines.length && j < i + maxLines; j++) {
1027
+ for (const ch of lines[j]) {
1028
+ if (ch === '{') { braceCount++; started = true; }
1029
+ if (ch === '}') braceCount--;
1030
+ }
1031
+ if (started && braceCount <= 0) {
1032
+ return lines.slice(i, j + 1).join('\n');
1033
+ }
1034
+ }
1035
+ // Safety: if closing brace not found within maxLines, return what we have
1036
+ return lines.slice(i, Math.min(i + maxLines, lines.length)).join('\n');
1037
+ }
1038
+ }
1039
+ return null;
1040
+ }
1041
+
1007
1042
  /**
1008
1043
  * Parse all fieldset.xml files in the Magento root.
1009
1044
  * Returns array of { file, scope, fieldset, fields: [{ field, aspect }] }.
@@ -1568,8 +1603,12 @@ function formatSearchResults(results) {
1568
1603
  : r.searchText;
1569
1604
  }
1570
1605
 
1606
+ // Full method body — attached by magento_find_method for complete understanding
1607
+ if (r.fullMethodBody) {
1608
+ entry.codePreview = r.fullMethodBody;
1609
+ }
1571
1610
  // Code preview — read actual source lines for PHP files with known class/method
1572
- if (r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
1611
+ else if (r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
1573
1612
  if (r.methodName) {
1574
1613
  const preview = readMethodSnippet(r.path, r.methodName, 10);
1575
1614
  if (preview) entry.codePreview = preview;
@@ -2176,6 +2215,7 @@ async function analyzeImpact(className) {
2176
2215
  diXmlReferences: [],
2177
2216
  instantiations: [],
2178
2217
  typeHints: [],
2218
+ runtimeCallers: [],
2179
2219
  total: 0
2180
2220
  };
2181
2221
 
@@ -2194,6 +2234,7 @@ async function analyzeImpact(className) {
2194
2234
  ];
2195
2235
 
2196
2236
  // Check PHP files for direct references
2237
+ const escapedShort = shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2197
2238
  for (const r of relatedPaths.slice(0, 40)) {
2198
2239
  const absPath = path.join(root, r.path);
2199
2240
  if (!existsSync(absPath) || !r.path.endsWith('.php')) continue;
@@ -2208,13 +2249,38 @@ async function analyzeImpact(className) {
2208
2249
  references.instantiations.push({ file: r.path, className: r.className });
2209
2250
  }
2210
2251
  if (content.includes(`@var ${shortName}`) || content.includes(`@param ${shortName}`) ||
2211
- content.match(new RegExp(`:\\s*${shortName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`))) {
2252
+ content.match(new RegExp(`:\\s*${escapedShort}\\b`))) {
2212
2253
  references.typeHints.push({ file: r.path, className: r.className });
2213
2254
  }
2255
+
2256
+ // Runtime callers: find $this->property->method() where property is typed as this class
2257
+ if (hasUse) {
2258
+ const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
2259
+ if (ctorMatch) {
2260
+ // Find constructor params typed as the target class
2261
+ const paramRegex = new RegExp(`(?:${escapedShort}|${className.replace(/\\/g, '\\\\')})\\s+\\$(\\w+)`, 'g');
2262
+ let pm;
2263
+ while ((pm = paramRegex.exec(ctorMatch[1])) !== null) {
2264
+ const propName = pm[1];
2265
+ // Find all calls to this property in the file
2266
+ const callRegex = new RegExp(`\\$this->${propName}->(\\w+)\\s*\\(`, 'g');
2267
+ let cm;
2268
+ while ((cm = callRegex.exec(content)) !== null) {
2269
+ references.runtimeCallers.push({
2270
+ file: r.path,
2271
+ callerClass: r.className,
2272
+ property: propName,
2273
+ calledMethod: cm[1]
2274
+ });
2275
+ }
2276
+ }
2277
+ }
2278
+ }
2214
2279
  }
2215
2280
 
2216
2281
  references.total = references.useStatements.length + references.diXmlReferences.length +
2217
- references.instantiations.length + references.typeHints.length;
2282
+ references.instantiations.length + references.typeHints.length +
2283
+ references.runtimeCallers.length;
2218
2284
 
2219
2285
  return references;
2220
2286
  }
@@ -2526,6 +2592,21 @@ async function findDiWiring(className) {
2526
2592
  const root = config.magentoRoot;
2527
2593
  const shortName = className.split('\\').pop();
2528
2594
  const shortLower = shortName.toLowerCase();
2595
+ // When a FQCN is provided (contains \), use it for precise matching
2596
+ const hasFqcn = className.includes('\\');
2597
+ // Normalize FQCN for XML matching: di.xml uses backslash-escaped names
2598
+ const fqcnNormalized = hasFqcn ? className.replace(/\\\\/g, '\\') : null;
2599
+ const fqcnLower = fqcnNormalized ? fqcnNormalized.toLowerCase() : null;
2600
+
2601
+ // Helper: check if a di.xml class name matches the requested class.
2602
+ // When FQCN is available, require full namespace match; otherwise fall back to short name.
2603
+ function matchesClass(xmlClassName) {
2604
+ const xmlLower = xmlClassName.toLowerCase().replace(/\\\\/g, '\\');
2605
+ if (fqcnLower) {
2606
+ return xmlLower === fqcnLower || xmlLower.endsWith('\\' + fqcnLower);
2607
+ }
2608
+ return xmlLower.includes(shortLower);
2609
+ }
2529
2610
 
2530
2611
  const result = {
2531
2612
  className,
@@ -2546,15 +2627,14 @@ async function findDiWiring(className) {
2546
2627
  const relativePath = diFile.replace(root + '/', '');
2547
2628
  const contentLower = content.toLowerCase();
2548
2629
 
2630
+ // Quick pre-filter: skip files that don't contain the short name at all
2549
2631
  if (!contentLower.includes(shortLower)) continue;
2550
2632
 
2551
2633
  // 1. Preferences where this class is the "for" or "type"
2552
2634
  const prefRegex = /<preference\s+for="([^"]+)"\s+type="([^"]+)"\s*\/?>/g;
2553
2635
  let m;
2554
2636
  while ((m = prefRegex.exec(content)) !== null) {
2555
- const forLower = m[1].toLowerCase();
2556
- const typeLower = m[2].toLowerCase();
2557
- if (forLower.includes(shortLower) || typeLower.includes(shortLower)) {
2637
+ if (matchesClass(m[1]) || matchesClass(m[2])) {
2558
2638
  result.preferences.push({ for: m[1], type: m[2], file: relativePath });
2559
2639
  }
2560
2640
  }
@@ -2564,10 +2644,9 @@ async function findDiWiring(className) {
2564
2644
  let typeMatch;
2565
2645
  while ((typeMatch = typeBlockRegex.exec(content)) !== null) {
2566
2646
  const typeName = typeMatch[1];
2567
- const typeNameLower = typeName.toLowerCase();
2568
2647
  const typeBlock = typeMatch[2];
2569
2648
 
2570
- if (!typeNameLower.includes(shortLower)) continue;
2649
+ if (!matchesClass(typeName)) continue;
2571
2650
 
2572
2651
  // Plugins
2573
2652
  const pluginRegex = /<plugin\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*/g;
@@ -2614,7 +2693,7 @@ async function findDiWiring(className) {
2614
2693
  const vtRegex = /<virtualType\s+name="([^"]+)"[^>]*type="([^"]+)"[^>]*(?:\/>|>([\s\S]*?)<\/virtualType>)/g;
2615
2694
  while ((m = vtRegex.exec(content)) !== null) {
2616
2695
  const vtType = m[2];
2617
- if (!vtType.toLowerCase().includes(shortLower)) continue;
2696
+ if (!matchesClass(vtType)) continue;
2618
2697
  const vtEntry = { name: m[1], type: vtType, file: relativePath };
2619
2698
  if (m[3]) {
2620
2699
  const argRegex = /<argument\s+name="([^"]+)"[^>]*xsi:type="([^"]+)"[^>]*>([^<]*)<\/argument>/g;
@@ -2630,6 +2709,7 @@ async function findDiWiring(className) {
2630
2709
  }
2631
2710
 
2632
2711
  // 4. Constructor arguments from PHP class
2712
+ // When FQCN is provided, verify namespace matches to avoid class name collisions
2633
2713
  const phpFiles = await glob(`**/${shortName}.php`, { cwd: root, absolute: true, nodir: true });
2634
2714
  for (const phpFile of phpFiles) {
2635
2715
  let content;
@@ -2637,6 +2717,17 @@ async function findDiWiring(className) {
2637
2717
  const classMatch = content.match(/(?:class|abstract\s+class)\s+(\w+)/);
2638
2718
  if (!classMatch || classMatch[1] !== shortName) continue;
2639
2719
 
2720
+ // FQCN namespace verification: when a full class name was provided,
2721
+ // check that the PHP file's namespace matches to avoid collisions
2722
+ // (e.g., two different ViewPlugin classes in different modules)
2723
+ if (fqcnNormalized) {
2724
+ const nsMatch = content.match(/namespace\s+([\w\\]+)\s*;/);
2725
+ if (nsMatch) {
2726
+ const fileFqcn = (nsMatch[1] + '\\' + shortName).toLowerCase();
2727
+ if (fileFqcn !== fqcnLower) continue; // Wrong class, skip
2728
+ }
2729
+ }
2730
+
2640
2731
  const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
2641
2732
  if (ctorMatch) {
2642
2733
  const paramRegex = /(?:([\w\\]+)\s+)?(\$\w+)/g;
@@ -2645,6 +2736,7 @@ async function findDiWiring(className) {
2645
2736
  result.constructorArguments.push({ typeHint: pm[1] || null, variable: pm[2] });
2646
2737
  }
2647
2738
  }
2739
+ result.constructorSourceFile = phpFile.replace(root + '/', '');
2648
2740
  break;
2649
2741
  }
2650
2742
 
@@ -2904,6 +2996,25 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
2904
2996
  };
2905
2997
 
2906
2998
  const classFileMap = new Map();
2999
+ const parentClassCache = new Map();
3000
+
3001
+ // Resolve parent class from extends declaration in PHP file content
3002
+ function resolveParentFromContent(content) {
3003
+ const extendsMatch = content.match(/class\s+\w+\s+extends\s+([\w\\]+)/);
3004
+ if (!extendsMatch) return null;
3005
+ const parent = extendsMatch[1];
3006
+ // If it's a short name, resolve using use statements
3007
+ if (!parent.includes('\\')) {
3008
+ const useMatch = content.match(new RegExp(`use\\s+([\\w\\\\]+\\\\${parent})\\s*;`));
3009
+ if (useMatch) return useMatch[1];
3010
+ // Check namespace-relative
3011
+ const nsMatch = content.match(/namespace\s+([\w\\]+)/);
3012
+ if (nsMatch) return `${nsMatch[1]}\\${parent}`;
3013
+ return parent;
3014
+ }
3015
+ // Leading backslash = fully qualified
3016
+ return parent.replace(/^\\/, '');
3017
+ }
2907
3018
 
2908
3019
  async function resolveClassFile(className) {
2909
3020
  const shortName = className.split('\\').pop();
@@ -3009,14 +3120,46 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
3009
3120
  const relativePath = filePath.replace(root + '/', '');
3010
3121
 
3011
3122
  // Extract method body (brace counting)
3123
+ const escapedMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3012
3124
  const methodRegex = new RegExp(
3013
- `function\\s+${methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\([^)]*\\)[^{]*\\{`
3125
+ `function\\s+${escapedMethod}\\s*\\([^)]*\\)[^{]*\\{`
3014
3126
  );
3015
- const methodStart = content.search(methodRegex);
3127
+ let methodStart = content.search(methodRegex);
3128
+
3129
+ // If method not found in this class, walk up the inheritance chain
3130
+ let resolvedClass = className;
3131
+ let resolvedContent = content;
3132
+ let resolvedFilePath = filePath;
3016
3133
  if (methodStart === -1) {
3017
- result.chain.push({ depth, class: className, method: methodName, file: relativePath, status: 'method_not_found' });
3018
- return;
3134
+ let currentContent = content;
3135
+ let found = false;
3136
+ const visited = new Set([className]);
3137
+ for (let i = 0; i < 10; i++) { // max 10 parent levels
3138
+ const parentFqcn = resolveParentFromContent(currentContent);
3139
+ if (!parentFqcn || visited.has(parentFqcn)) break;
3140
+ visited.add(parentFqcn);
3141
+ const parentFile = await resolveClassFile(parentFqcn);
3142
+ if (!parentFile) break;
3143
+ let parentContent;
3144
+ try { parentContent = readFileSync(parentFile, 'utf-8'); } catch { break; }
3145
+ const parentMethodStart = parentContent.search(methodRegex);
3146
+ if (parentMethodStart !== -1) {
3147
+ resolvedClass = parentFqcn;
3148
+ resolvedContent = parentContent;
3149
+ resolvedFilePath = parentFile;
3150
+ methodStart = parentMethodStart;
3151
+ found = true;
3152
+ break;
3153
+ }
3154
+ currentContent = parentContent;
3155
+ }
3156
+ if (!found) {
3157
+ result.chain.push({ depth, class: className, method: methodName, file: relativePath, status: 'method_not_found' });
3158
+ return;
3159
+ }
3019
3160
  }
3161
+ content = resolvedContent;
3162
+ const resolvedRelPath = resolvedFilePath.replace(root + '/', '');
3020
3163
 
3021
3164
  let braceCount = 0;
3022
3165
  let bodyStart = content.indexOf('{', methodStart);
@@ -3028,8 +3171,11 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
3028
3171
  }
3029
3172
  const methodBody = content.slice(bodyStart, bodyEnd + 1);
3030
3173
 
3174
+ // Show where method was actually found if inherited
3175
+ const inheritedFrom = (resolvedClass !== className) ? resolvedClass : null;
3031
3176
  const chainEntry = {
3032
3177
  depth, class: className, method: methodName, file: relativePath,
3178
+ ...(inheritedFrom ? { inheritedFrom, resolvedFile: resolvedRelPath } : {}),
3033
3179
  calls: [], dispatches: []
3034
3180
  };
3035
3181
 
@@ -3049,13 +3195,20 @@ async function traceCallChain(startClass, startMethod, maxDepth = 3) {
3049
3195
  while ((dc = depCallRegex.exec(methodBody)) !== null) {
3050
3196
  const property = dc[1];
3051
3197
  const calledMethod = dc[2];
3052
- // Resolve property type from constructor
3053
- const ctorMatch = content.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
3198
+ // Resolve property type from constructor — check the original class first, then the resolved (parent) class
3054
3199
  let resolvedType = null;
3055
- if (ctorMatch) {
3056
- const paramRegex = new RegExp(`([\\w\\\\]+)\\s+\\$${property}\\b`);
3057
- const pm = ctorMatch[1].match(paramRegex);
3058
- if (pm) resolvedType = pm[1];
3200
+ let originalContent = content;
3201
+ if (resolvedClass !== className) {
3202
+ try { originalContent = readFileSync(filePath, 'utf-8'); } catch { originalContent = content; }
3203
+ }
3204
+ const contentSources = (resolvedClass !== className) ? [originalContent, content] : [content];
3205
+ for (const src of contentSources) {
3206
+ const ctorMatch = src.match(/function\s+__construct\s*\(([\s\S]*?)\)\s*[{:]/);
3207
+ if (ctorMatch) {
3208
+ const paramRegex = new RegExp(`([\\w\\\\]+)\\s+\\$${property}\\b`);
3209
+ const pm = ctorMatch[1].match(paramRegex);
3210
+ if (pm) { resolvedType = pm[1]; break; }
3211
+ }
3059
3212
  }
3060
3213
  chainEntry.calls.push({ type: 'dependency', property, method: calledMethod, typeHint: resolvedType || null });
3061
3214
  }
@@ -3144,6 +3297,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3144
3297
  description: 'Enable query expansion with Magento domain synonyms for better recall (default: true)',
3145
3298
  default: true
3146
3299
  },
3300
+ precise: {
3301
+ type: 'boolean',
3302
+ description: 'Precise mode for debugging: disables query expansion AND applies strict post-filtering — only returns results where the file content contains at least one query keyword. Use for specific debugging queries like "gift card subtotal infinite loop". Default: false.',
3303
+ default: false
3304
+ },
3147
3305
  },
3148
3306
  required: ['query']
3149
3307
  }
@@ -3759,6 +3917,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3759
3917
  required: ['eventName']
3760
3918
  }
3761
3919
  },
3920
+ {
3921
+ name: 'magento_batch',
3922
+ description: 'Execute multiple Magector tool calls in a single request to reduce MCP round-trip overhead. Each query runs in parallel and returns combined results. Use this when you need 2+ independent lookups (e.g., find a class AND its plugins AND its observers in one call instead of three).',
3923
+ inputSchema: {
3924
+ type: 'object',
3925
+ properties: {
3926
+ queries: {
3927
+ type: 'array',
3928
+ description: 'Array of tool calls to execute. Each entry has a "tool" name and "args" object.',
3929
+ items: {
3930
+ type: 'object',
3931
+ properties: {
3932
+ tool: { type: 'string', description: 'Magector tool name (e.g., "magento_find_class", "magento_find_plugin")' },
3933
+ args: { type: 'object', description: 'Arguments for the tool call' }
3934
+ },
3935
+ required: ['tool', 'args']
3936
+ },
3937
+ maxItems: 10
3938
+ }
3939
+ },
3940
+ required: ['queries']
3941
+ }
3942
+ },
3762
3943
  ]
3763
3944
  }));
3764
3945
 
@@ -3805,12 +3986,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3805
3986
  try {
3806
3987
  switch (name) {
3807
3988
  case 'magento_search': {
3808
- const searchQuery = args.expand !== false ? expandQuery(args.query) : args.query;
3809
- const raw = await rustSearchAsync(searchQuery, Math.max(args.limit || 10, 30));
3989
+ const precise = args.precise === true;
3990
+ const searchQuery = (args.expand !== false && !precise) ? expandQuery(args.query) : args.query;
3991
+ const raw = await rustSearchAsync(searchQuery, Math.max(args.limit || 10, precise ? 60 : 30));
3810
3992
  const arr = Array.isArray(raw) ? raw : [];
3811
3993
  let results = arr.map(normalizeResult);
3812
3994
  // Hybrid BM25 rerank for better exact-match handling
3813
3995
  results = hybridRerank(results, args.query);
3996
+ // Precise mode: strict post-filter — result must contain at least one significant query keyword
3997
+ if (precise) {
3998
+ const queryTerms = args.query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
3999
+ results = results.filter(r => {
4000
+ const haystack = [r.searchText, r.className, r.methodName, r.path, ...(r.methods || [])]
4001
+ .filter(Boolean).join(' ').toLowerCase();
4002
+ return queryTerms.some(term => haystack.includes(term));
4003
+ });
4004
+ }
3814
4005
  // Apply module filter if specified
3815
4006
  if (args.moduleFilter) {
3816
4007
  results = filterByModule(results, args.moduleFilter);
@@ -3866,10 +4057,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3866
4057
  if (r.methodName?.toLowerCase() === methodLower) bonus += 0.3;
3867
4058
  return { ...r, score: (r.score || 0) + bonus };
3868
4059
  }).sort((a, b) => b.score - a.score);
4060
+ // Attach full method body to each result for complete understanding
4061
+ const sliced = results.slice(0, 10);
4062
+ for (const r of sliced) {
4063
+ if (r.path && r.path.endsWith('.php')) {
4064
+ const body = readFullMethodBody(r.path, args.methodName);
4065
+ if (body) r.fullMethodBody = body;
4066
+ }
4067
+ }
3869
4068
  return {
3870
4069
  content: [{
3871
4070
  type: 'text',
3872
- text: formatSearchResults(results.slice(0, 10))
4071
+ text: formatSearchResults(sliced)
3873
4072
  }]
3874
4073
  };
3875
4074
  }
@@ -4850,6 +5049,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4850
5049
  text += '\n';
4851
5050
  }
4852
5051
 
5052
+ if (impact.runtimeCallers.length > 0) {
5053
+ text += `### Runtime Callers (${impact.runtimeCallers.length})\n`;
5054
+ text += '_Classes that inject this class and call its methods at runtime:_\n';
5055
+ // Group by caller class for readability
5056
+ const grouped = new Map();
5057
+ for (const c of impact.runtimeCallers) {
5058
+ const key = c.callerClass || c.file;
5059
+ if (!grouped.has(key)) grouped.set(key, { file: c.file, methods: new Set() });
5060
+ grouped.get(key).methods.add(`$this->${c.property}->${c.calledMethod}()`);
5061
+ }
5062
+ for (const [cls, info] of grouped) {
5063
+ text += `- **\`${cls}\`** (\`${info.file}\`)\n`;
5064
+ for (const m of info.methods) {
5065
+ text += ` - \`${m}\`\n`;
5066
+ }
5067
+ }
5068
+ text += '\n';
5069
+ }
5070
+
4853
5071
  if (impact.total === 0) {
4854
5072
  text += '_No references found. The class may be referenced under a different name or alias._\n';
4855
5073
  }
@@ -5057,6 +5275,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5057
5275
  const status = entry.status ? ` [${entry.status}]` : '';
5058
5276
  text += `${indent}- **${entry.class}::${entry.method}**${status}`;
5059
5277
  if (entry.file) text += ` (${entry.file})`;
5278
+ if (entry.inheritedFrom) text += `\n${indent} _inherited from_ \`${entry.inheritedFrom}\` (${entry.resolvedFile})`;
5060
5279
  text += '\n';
5061
5280
 
5062
5281
  if (entry.calls) {
@@ -5170,6 +5389,124 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5170
5389
  return { content: [{ type: 'text', text }] };
5171
5390
  }
5172
5391
 
5392
+ case 'magento_batch': {
5393
+ const queries = args.queries || [];
5394
+ if (queries.length === 0) {
5395
+ return { content: [{ type: 'text', text: 'No queries provided.' }] };
5396
+ }
5397
+ if (queries.length > 10) {
5398
+ return { content: [{ type: 'text', text: 'Maximum 10 queries per batch.' }], isError: true };
5399
+ }
5400
+ // Run batch queries in parallel using existing standalone functions
5401
+ const batchResults = await Promise.all(queries.map(async (q, idx) => {
5402
+ try {
5403
+ const a = q.args || {};
5404
+ let text = '';
5405
+ switch (q.tool) {
5406
+ case 'magento_find_class': {
5407
+ const ns = a.namespace || '';
5408
+ const qr = `${a.className} ${ns}`.trim();
5409
+ const raw = await rustSearchAsync(qr, 30);
5410
+ const cl = a.className.toLowerCase();
5411
+ const res = raw.map(normalizeResult).filter(r =>
5412
+ r.className?.toLowerCase().includes(cl) || r.path?.toLowerCase().includes(cl.replace(/\\/g, '/'))
5413
+ );
5414
+ text = formatSearchResults(res.slice(0, 5));
5415
+ break;
5416
+ }
5417
+ case 'magento_find_plugin': {
5418
+ const qr = `plugin interceptor ${a.targetClass || ''} ${a.targetMethod || ''}`.trim();
5419
+ const raw = await rustSearchAsync(qr, 30);
5420
+ const res = raw.map(normalizeResult).filter(r =>
5421
+ r.magentoType === 'plugin' || r.path?.toLowerCase().includes('plugin')
5422
+ );
5423
+ text = formatSearchResults(res.slice(0, 10));
5424
+ break;
5425
+ }
5426
+ case 'magento_find_observer': {
5427
+ const flow = await traceEventFlow(a.eventName);
5428
+ text = `Observers: ${flow.observers.length}\n`;
5429
+ for (const o of flow.observers.slice(0, 10)) {
5430
+ text += `- ${o.name}: ${o.instance}::${o.method} (${o.file})\n`;
5431
+ }
5432
+ break;
5433
+ }
5434
+ case 'magento_trace_dependency': {
5435
+ const dep = await traceDependency(a.className, a.direction || 'both');
5436
+ text = `Preferences: ${dep.preferences.length}, Plugins: ${dep.plugins.length}, VirtualTypes: ${dep.virtualTypes.length}, Args: ${dep.argumentOverrides.length}\n`;
5437
+ for (const p of dep.plugins.slice(0, 5)) text += `- plugin: ${p.pluginName} → ${p.pluginClass}\n`;
5438
+ for (const p of dep.preferences.slice(0, 5)) text += `- pref: ${p.for} → ${p.type}\n`;
5439
+ break;
5440
+ }
5441
+ case 'magento_impact_analysis': {
5442
+ const imp = await analyzeImpact(a.className);
5443
+ text = `Total: ${imp.total} (use: ${imp.useStatements.length}, di: ${imp.diXmlReferences.length}, new: ${imp.instantiations.length}, type: ${imp.typeHints.length}, runtime: ${imp.runtimeCallers.length})\n`;
5444
+ for (const c of imp.runtimeCallers.slice(0, 5)) text += `- ${c.callerClass}: $this->${c.property}->${c.calledMethod}()\n`;
5445
+ break;
5446
+ }
5447
+ case 'magento_search': {
5448
+ const precise = a.precise === true;
5449
+ const sq = (a.expand !== false && !precise) ? expandQuery(a.query) : a.query;
5450
+ const raw = await rustSearchAsync(sq, 30);
5451
+ let res = raw.map(normalizeResult);
5452
+ res = hybridRerank(res, a.query);
5453
+ text = formatSearchResults(res.slice(0, a.limit || 5));
5454
+ break;
5455
+ }
5456
+ case 'magento_find_callers': {
5457
+ const callers = await findCallers(a.methodName, a.className);
5458
+ text = `Callers: ${callers.callers?.length || 0}\n`;
5459
+ for (const c of (callers.callers || []).slice(0, 10)) {
5460
+ text += `- ${c.class}::${c.method} (${c.path}:${c.line})\n`;
5461
+ }
5462
+ break;
5463
+ }
5464
+ case 'magento_find_event_flow': {
5465
+ const flow = await traceEventFlow(a.eventName);
5466
+ text = `Dispatchers: ${flow.dispatchers.length}, Observers: ${flow.observers.length}\n`;
5467
+ for (const o of flow.observers.slice(0, 10)) text += `- ${o.name}: ${o.instance} (${o.file})\n`;
5468
+ break;
5469
+ }
5470
+ case 'magento_find_di_wiring': {
5471
+ const wiring = await findDiWiring(a.className);
5472
+ text = `Prefs: ${wiring.preferences.length}, Plugins: ${wiring.plugins.length}, VTs: ${wiring.virtualTypes.length}, Ctor: ${wiring.constructorArguments.length}\n`;
5473
+ for (const p of wiring.plugins.slice(0, 5)) text += `- plugin: ${p.pluginName} → ${p.pluginClass}\n`;
5474
+ for (const c of wiring.constructorArguments.slice(0, 10)) text += `- ctor: ${c.typeHint || '?'} ${c.variable}\n`;
5475
+ if (wiring.constructorSourceFile) text += `Source: ${wiring.constructorSourceFile}\n`;
5476
+ break;
5477
+ }
5478
+ case 'magento_find_method': {
5479
+ const qr = `method ${a.methodName} function ${a.className || ''}`.trim();
5480
+ const raw = await rustSearchAsync(qr, 50);
5481
+ const ml = a.methodName.toLowerCase();
5482
+ let res = raw.map(normalizeResult).filter(r =>
5483
+ r.methodName?.toLowerCase() === ml || r.methods?.some(m => m.toLowerCase() === ml)
5484
+ );
5485
+ for (const r of res.slice(0, 5)) {
5486
+ if (r.path?.endsWith('.php')) {
5487
+ const body = readFullMethodBody(r.path, a.methodName);
5488
+ if (body) r.fullMethodBody = body;
5489
+ }
5490
+ }
5491
+ text = formatSearchResults(res.slice(0, 5));
5492
+ break;
5493
+ }
5494
+ default:
5495
+ text = `Unsupported batch tool: ${q.tool}`;
5496
+ }
5497
+ return { idx, tool: q.tool, text };
5498
+ } catch (err) {
5499
+ return { idx, tool: q.tool, text: `Error: ${err.message}` };
5500
+ }
5501
+ }));
5502
+
5503
+ let text = `## Batch Results (${batchResults.length} queries)\n\n`;
5504
+ for (const br of batchResults) {
5505
+ text += `---\n### [${br.idx + 1}] ${br.tool}\n\n${br.text}\n\n`;
5506
+ }
5507
+ return { content: [{ type: 'text', text }] };
5508
+ }
5509
+
5173
5510
  default:
5174
5511
  return {
5175
5512
  content: [{