magector 2.4.1 → 2.5.0

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/README.md CHANGED
@@ -415,7 +415,8 @@ All search tools return structured JSON:
415
415
  |------|-------------|
416
416
  | `magento_find_config` | Find XML configuration (di.xml, events.xml, routes.xml, system.xml, webapi.xml, module.xml, layout) |
417
417
  | `magento_find_template` | Find PHTML template files for frontend or admin rendering |
418
- | `magento_find_plugin` | Find interceptor plugins (before/after/around methods) and di.xml declarations |
418
+ | `magento_find_plugin` | Find interceptor plugins (before/after/around methods) and di.xml declarations. Resolves plugin PHP files and extracts interceptor method signatures **(v2.5)** |
419
+ | `magento_find_fieldset` | Find fieldset.xml definitions controlling data copy between entities (order→quote, quote→order). Shows fields per aspect (to_order, to_edit) **(v2.5)** |
419
420
  | `magento_find_observer` | Find event observers and events.xml declarations |
420
421
  | `magento_find_preference` | Find DI preference overrides -- which class implements an interface |
421
422
  | `magento_find_controller` | Find MVC controllers by frontend or admin route path |
@@ -429,7 +430,8 @@ All search tools return structured JSON:
429
430
 
430
431
  | Tool | Description |
431
432
  |------|-------------|
432
- | `magento_trace_flow` | Trace execution flow from an entry point (route, API, GraphQL, event, cron) -- maps controller → plugins → observers → templates in one call |
433
+ | `magento_trace_flow` | Trace execution flow from an entry point (route, API, GraphQL, event, cron) -- maps controller → plugins → observers → templates with code snippets **(v2.5)** |
434
+ | `magento_trace_shipping_chain` | Trace the complete shipping rate chain: carriers → collectRates plugins → rate modifiers → totals collectors → fieldset mappings **(v2.5)** |
433
435
  | `magento_trace_dependency` | Trace DI graph for a class/interface -- preferences, plugins, virtualTypes, argument overrides (parses all di.xml, no index needed) |
434
436
  | `magento_find_event_flow` | Trace complete event chain: dispatchers → observers → handler PHP classes (parses events.xml + vector search) |
435
437
  | `magento_find_event_dispatchers` | Find all PHP locations where a specific event is dispatched -- exact grep matching with method context and surrounding code **(v2.3)** |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
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.4.1",
37
- "@magector/cli-linux-x64": "2.4.1",
38
- "@magector/cli-linux-arm64": "2.4.1",
39
- "@magector/cli-win32-x64": "2.4.1"
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"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -965,6 +965,228 @@ function normalizeResult(r) {
965
965
  * @param {Object} boosts - e.g. { fileType: 'xml', pathContains: 'di.xml', isPlugin: true }
966
966
  * @param {number} weight - boost multiplier (default 0.3 = 30% score bump per match)
967
967
  */
968
+ /**
969
+ * Extract before/after/around plugin method signatures from a PHP file.
970
+ * Returns array of { name, type, targetMethod, signature } objects.
971
+ */
972
+ function extractPluginMethods(filePath) {
973
+ const absPath = filePath.startsWith('/') ? filePath : path.join(config.magentoRoot, filePath);
974
+ let content;
975
+ try { content = readFileSync(absPath, 'utf-8'); } catch { return []; }
976
+ const methods = [];
977
+ const methodRegex = /^\s*public\s+function\s+((?:before|after|around)([A-Z]\w*))\s*\(([^)]*)\)/gm;
978
+ let match;
979
+ while ((match = methodRegex.exec(content)) !== null) {
980
+ const name = match[1];
981
+ const targetMethod = match[2].charAt(0).toLowerCase() + match[2].slice(1);
982
+ let type = 'around';
983
+ if (name.startsWith('before')) type = 'before';
984
+ else if (name.startsWith('after')) type = 'after';
985
+ methods.push({ name, type, targetMethod, signature: match[0].trim() });
986
+ }
987
+ return methods;
988
+ }
989
+
990
+ /**
991
+ * Read a short code snippet around a method definition from a PHP file.
992
+ * Returns up to `maxLines` lines starting from the method signature.
993
+ */
994
+ function readMethodSnippet(filePath, methodName, maxLines = 15) {
995
+ const absPath = filePath.startsWith('/') ? filePath : path.join(config.magentoRoot, filePath);
996
+ let content;
997
+ try { content = readFileSync(absPath, 'utf-8'); } catch { return null; }
998
+ const lines = content.split('\n');
999
+ for (let i = 0; i < lines.length; i++) {
1000
+ if (lines[i].includes(`function ${methodName}(`)) {
1001
+ return lines.slice(i, i + maxLines).join('\n');
1002
+ }
1003
+ }
1004
+ return null;
1005
+ }
1006
+
1007
+ /**
1008
+ * Parse all fieldset.xml files in the Magento root.
1009
+ * Returns array of { file, scope, fieldset, fields: [{ field, aspect }] }.
1010
+ */
1011
+ async function parseFieldsetXml(filterFieldset, filterAspect) {
1012
+ const root = config.magentoRoot;
1013
+ const fieldsetFiles = await glob('**/etc/fieldset.xml', { cwd: root, absolute: true, nodir: true });
1014
+ const results = [];
1015
+ for (const fsFile of fieldsetFiles) {
1016
+ let content;
1017
+ try { content = readFileSync(fsFile, 'utf-8'); } catch { continue; }
1018
+ const relPath = fsFile.replace(root + '/', '');
1019
+ // Parse <scope id="..."> blocks
1020
+ const scopeRegex = /<scope\s+id="([^"]+)">([\s\S]*?)<\/scope>/g;
1021
+ let scopeMatch;
1022
+ while ((scopeMatch = scopeRegex.exec(content)) !== null) {
1023
+ const scopeName = scopeMatch[1];
1024
+ const scopeBlock = scopeMatch[2];
1025
+ const innerFsRegex = /<fieldset\s+id="([^"]+)">([\s\S]*?)<\/fieldset>/g;
1026
+ let innerMatch;
1027
+ while ((innerMatch = innerFsRegex.exec(scopeBlock)) !== null) {
1028
+ const innerFieldsetId = innerMatch[1];
1029
+ if (filterFieldset && !innerFieldsetId.toLowerCase().includes(filterFieldset.toLowerCase()) && !scopeName.toLowerCase().includes(filterFieldset.toLowerCase())) continue;
1030
+ const innerBlock = innerMatch[2];
1031
+ const fieldRegex = /<field\s+name="([^"]+)">([\s\S]*?)<\/field>/g;
1032
+ let fieldMatch;
1033
+ const fields = [];
1034
+ while ((fieldMatch = fieldRegex.exec(innerBlock)) !== null) {
1035
+ const fieldName = fieldMatch[1];
1036
+ const fieldBlock = fieldMatch[2];
1037
+ const aspectRegex = /<aspect\s+name="([^"]+)"\s*(?:\/>|>[^<]*<\/aspect>)/g;
1038
+ let aspectMatch;
1039
+ while ((aspectMatch = aspectRegex.exec(fieldBlock)) !== null) {
1040
+ if (filterAspect && !aspectMatch[1].toLowerCase().includes(filterAspect.toLowerCase())) continue;
1041
+ fields.push({ field: fieldName, aspect: aspectMatch[1] });
1042
+ }
1043
+ }
1044
+ if (fields.length > 0) {
1045
+ results.push({ file: relPath, scope: scopeName, fieldset: innerFieldsetId, fields });
1046
+ }
1047
+ }
1048
+ }
1049
+ // Also handle flat fieldset format: <fieldset id="..."> without scope wrapper
1050
+ const flatFsRegex = /<fieldset\s+id="([^"]+)">([\s\S]*?)<\/fieldset>/g;
1051
+ let flatMatch;
1052
+ while ((flatMatch = flatFsRegex.exec(content)) !== null) {
1053
+ const fieldsetId = flatMatch[1];
1054
+ if (filterFieldset && !fieldsetId.toLowerCase().includes(filterFieldset.toLowerCase())) continue;
1055
+ const block = flatMatch[2];
1056
+ if (block.includes('<fieldset')) continue; // skip nested (handled above)
1057
+ const fieldRegex = /<field\s+name="([^"]+)">([\s\S]*?)<\/field>/g;
1058
+ let fieldMatch;
1059
+ const fields = [];
1060
+ while ((fieldMatch = fieldRegex.exec(block)) !== null) {
1061
+ const fieldName = fieldMatch[1];
1062
+ const fieldBlock = fieldMatch[2];
1063
+ const aspectRegex = /<aspect\s+name="([^"]+)"\s*(?:\/>|>[^<]*<\/aspect>)/g;
1064
+ let aspectMatch;
1065
+ while ((aspectMatch = aspectRegex.exec(fieldBlock)) !== null) {
1066
+ if (filterAspect && !aspectMatch[1].toLowerCase().includes(filterAspect.toLowerCase())) continue;
1067
+ fields.push({ field: fieldName, aspect: aspectMatch[1] });
1068
+ }
1069
+ }
1070
+ if (fields.length > 0) {
1071
+ results.push({ file: relPath, scope: null, fieldset: fieldsetId, fields });
1072
+ }
1073
+ }
1074
+ }
1075
+ return results;
1076
+ }
1077
+
1078
+ /**
1079
+ * Resolve a PHP class name to a file path by converting namespace to path.
1080
+ */
1081
+ function findClassFile(root, className) {
1082
+ if (!className) return '';
1083
+ const parts = className.replace(/\\\\/g, '\\').split('\\');
1084
+ if (parts.length < 3) return '';
1085
+ const vendor = parts[0].toLowerCase();
1086
+ const moduleParts = parts[1].replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '').split('-');
1087
+ const candidates = [
1088
+ path.join(root, 'vendor', vendor, parts.slice(1).join('/').replace(/\\/g, '/') + '.php'),
1089
+ path.join(root, 'vendor', vendor, moduleParts.join('-'), parts.slice(2).join('/') + '.php'),
1090
+ path.join(root, 'app/code', parts.join('/') + '.php'),
1091
+ ];
1092
+ for (const candidate of candidates) {
1093
+ if (existsSync(candidate)) return candidate;
1094
+ }
1095
+ // Glob fallback — search for the class filename
1096
+ const fileName = parts[parts.length - 1] + '.php';
1097
+ try {
1098
+ const matches = glob.sync(`**/${fileName}`, { cwd: root, absolute: true, nodir: true, ignore: ['**/Test/**', '**/test/**'] });
1099
+ for (const m of matches) {
1100
+ const content = readFileSync(m, 'utf-8').slice(0, 500);
1101
+ if (content.includes(parts[parts.length - 1])) return m;
1102
+ }
1103
+ } catch {}
1104
+ return '';
1105
+ }
1106
+
1107
+ /**
1108
+ * Trace the shipping rate modification chain:
1109
+ * carriers → plugins on collectRates → ShippingRateModifier pool → totals collector
1110
+ */
1111
+ async function traceShippingChain(carrierOrMethod) {
1112
+ const root = config.magentoRoot;
1113
+ const chain = { carriers: [], collectRatesPlugins: [], rateModifiers: [], totalsCollectors: [], fieldsets: [] };
1114
+
1115
+ // 1. Find carrier classes
1116
+ const carrierQuery = carrierOrMethod
1117
+ ? `carrier shipping ${carrierOrMethod} collectRates`
1118
+ : 'carrier shipping collectRates AbstractCarrier';
1119
+ const carrierRaw = await safeSearch(carrierQuery, 30);
1120
+ const carriers = carrierRaw.map(normalizeResult).filter(r =>
1121
+ r.path?.includes('/Carrier/') || r.path?.includes('/Model/Carrier')
1122
+ );
1123
+ chain.carriers = carriers.slice(0, 10).map(r => ({
1124
+ path: r.path, className: r.className || null, methods: r.methods || []
1125
+ }));
1126
+
1127
+ // 2. Find plugins on AbstractCarrierInterface::collectRates
1128
+ const diFiles = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
1129
+ for (const diFile of diFiles) {
1130
+ let content;
1131
+ try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
1132
+ const carrierTargets = ['AbstractCarrier', 'CarrierInterface', 'AbstractCarrierInterface', 'AbstractCarrierOnline'];
1133
+ for (const target of carrierTargets) {
1134
+ if (!content.includes(target)) continue;
1135
+ const typeBlockRegex = /<type\s+name="([^"]*)"[^>]*>([\s\S]*?)<\/type>/g;
1136
+ let tm;
1137
+ while ((tm = typeBlockRegex.exec(content)) !== null) {
1138
+ if (!tm[1].includes(target)) continue;
1139
+ const block = tm[2];
1140
+ const pluginRegex = /<plugin\s+([^/>]*)\/?>/g;
1141
+ let pm;
1142
+ while ((pm = pluginRegex.exec(block)) !== null) {
1143
+ const attrs = {};
1144
+ const attrRe = /(\w+)="([^"]*)"/g;
1145
+ let am;
1146
+ while ((am = attrRe.exec(pm[1])) !== null) attrs[am[1]] = am[2];
1147
+ let area = 'global';
1148
+ const relPath = diFile.replace(root + '/', '');
1149
+ if (relPath.includes('/etc/adminhtml/')) area = 'adminhtml';
1150
+ else if (relPath.includes('/etc/frontend/')) area = 'frontend';
1151
+ const pluginFile = attrs.type ? findClassFile(root, attrs.type) : '';
1152
+ const pluginMethods = pluginFile ? extractPluginMethods(pluginFile) : [];
1153
+ chain.collectRatesPlugins.push({
1154
+ target: tm[1], pluginName: attrs.name || '', pluginClass: attrs.type || '',
1155
+ disabled: attrs.disabled === 'true', sortOrder: attrs.sortOrder || null,
1156
+ area, file: relPath, methods: pluginMethods
1157
+ });
1158
+ }
1159
+ }
1160
+ }
1161
+ }
1162
+
1163
+ // 3. Find ShippingRateModifier implementations
1164
+ const modifierRaw = await safeSearch('ShippingRateModifier shipping rate modifier', 20);
1165
+ const modifiers = modifierRaw.map(normalizeResult).filter(r =>
1166
+ r.path?.includes('Modifier') || r.path?.includes('modifier') ||
1167
+ r.className?.includes('Modifier')
1168
+ );
1169
+ chain.rateModifiers = modifiers.slice(0, 10).map(r => ({
1170
+ path: r.path, className: r.className || null, methods: r.methods || []
1171
+ }));
1172
+
1173
+ // 4. Find shipping totals collectors
1174
+ const totalsRaw = await safeSearch('shipping total collector quote address', 20);
1175
+ const totals = totalsRaw.map(normalizeResult).filter(r =>
1176
+ r.path?.includes('/Total/') || r.path?.includes('Shipping') ||
1177
+ r.className?.includes('Shipping')
1178
+ );
1179
+ chain.totalsCollectors = totals.slice(0, 5).map(r => ({
1180
+ path: r.path, className: r.className || null, methods: r.methods || []
1181
+ }));
1182
+
1183
+ // 5. Find relevant fieldsets
1184
+ const fieldsets = await parseFieldsetXml('sales_convert', null);
1185
+ chain.fieldsets = fieldsets;
1186
+
1187
+ return chain;
1188
+ }
1189
+
968
1190
  function rerank(results, boosts = {}, weight = 0.3) {
969
1191
  if (!boosts || Object.keys(boosts).length === 0) return results;
970
1192
 
@@ -1027,6 +1249,9 @@ async function traceRoute(entryPoint, depth) {
1027
1249
  const best = ranked[0];
1028
1250
  if (best) {
1029
1251
  trace.controller = { path: best.path, className: best.className || null, methods: best.methods || [] };
1252
+ // Add code snippet for the controller's execute() method
1253
+ const execSnippet = readMethodSnippet(best.path, 'execute');
1254
+ if (execSnippet) trace.controller.codeSnippet = execSnippet;
1030
1255
  }
1031
1256
 
1032
1257
  const routeConfigs = routeRaw.map(normalizeResult).filter(r => r.path?.includes('routes.xml'));
@@ -1039,7 +1264,13 @@ async function traceRoute(entryPoint, depth) {
1039
1264
  const pluginRaw = await safeSearch(`plugin interceptor ${best.className}`, 20);
1040
1265
  const plugins = pluginRaw.map(normalizeResult).filter(r => r.isPlugin || r.path?.includes('/Plugin/') || r.path?.includes('di.xml'));
1041
1266
  if (plugins.length > 0) {
1042
- trace.plugins = plugins.slice(0, 10).map(r => ({ path: r.path, className: r.className || null, methods: r.methods || [] }));
1267
+ trace.plugins = plugins.slice(0, 10).map(r => {
1268
+ const entry = { path: r.path, className: r.className || null, methods: r.methods || [] };
1269
+ if (r.path && !r.path.endsWith('.xml')) {
1270
+ entry.pluginMethods = extractPluginMethods(r.path);
1271
+ }
1272
+ return entry;
1273
+ });
1043
1274
  }
1044
1275
  }
1045
1276
 
@@ -1075,6 +1306,13 @@ async function traceRoute(entryPoint, depth) {
1075
1306
  if (templates.length > 0) {
1076
1307
  trace.templates = templates.slice(0, 10).map(r => ({ path: r.path }));
1077
1308
  }
1309
+
1310
+ // Fieldset discovery — look for fieldsets related to the route domain
1311
+ const domain = parts[0]; // e.g., "sales", "checkout", "catalog"
1312
+ const fieldsets = await parseFieldsetXml(domain, null);
1313
+ if (fieldsets.length > 0) {
1314
+ trace.fieldsets = fieldsets;
1315
+ }
1078
1316
  }
1079
1317
 
1080
1318
  return trace;
@@ -1103,6 +1341,12 @@ async function traceApi(entryPoint, depth) {
1103
1341
  const svcs = svcRaw.map(normalizeResult).filter(r => r.className?.includes(serviceShortName));
1104
1342
  if (svcs.length > 0) {
1105
1343
  trace.serviceClass = { path: svcs[0].path, className: svcs[0].className || serviceClassName, methods: svcs[0].methods || [] };
1344
+ // Try to add code snippet for the main service method
1345
+ const methodMatch = (webapis[0]?.searchText || '').match(/method="([^"]+)"/);
1346
+ if (methodMatch) {
1347
+ const svcSnippet = readMethodSnippet(svcs[0].path, methodMatch[1]);
1348
+ if (svcSnippet) trace.serviceClass.codeSnippet = svcSnippet;
1349
+ }
1106
1350
  }
1107
1351
  }
1108
1352
 
@@ -1168,7 +1412,15 @@ async function traceEvent(entryPoint, depth) {
1168
1412
  const obsRaw = await safeSearch(`event ${entryPoint} observer`, 30);
1169
1413
  const observers = obsRaw.map(normalizeResult).filter(r => r.isObserver || r.path?.includes('/Observer/') || r.path?.includes('events.xml'));
1170
1414
  if (observers.length > 0) {
1171
- trace.observers = observers.slice(0, 15).map(r => ({ eventName: entryPoint, path: r.path, className: r.className || null }));
1415
+ trace.observers = observers.slice(0, 15).map(r => {
1416
+ const entry = { eventName: entryPoint, path: r.path, className: r.className || null };
1417
+ // Add execute() snippet for observer PHP classes
1418
+ if (r.path && r.path.endsWith('.php')) {
1419
+ const snippet = readMethodSnippet(r.path, 'execute');
1420
+ if (snippet) entry.codeSnippet = snippet;
1421
+ }
1422
+ return entry;
1423
+ });
1172
1424
  }
1173
1425
 
1174
1426
  if (depth === 'deep') {
@@ -1180,6 +1432,13 @@ async function traceEvent(entryPoint, depth) {
1180
1432
  if (origins.length > 0) {
1181
1433
  trace.origin = { path: origins[0].path, className: origins[0].className || null, methods: origins[0].methods || [] };
1182
1434
  }
1435
+
1436
+ // Fieldset discovery — look for fieldsets related to the event domain
1437
+ const domain = entryPoint.split('_').slice(0, 2).join('_'); // e.g., "sales_convert", "sales_order"
1438
+ const fieldsets = await parseFieldsetXml(domain, null);
1439
+ if (fieldsets.length > 0) {
1440
+ trace.fieldsets = fieldsets;
1441
+ }
1183
1442
  }
1184
1443
 
1185
1444
  return trace;
@@ -1202,6 +1461,8 @@ async function traceCron(entryPoint, depth) {
1202
1461
  const handlers = handlerRaw.map(normalizeResult).filter(r => r.path?.includes('/Cron/'));
1203
1462
  if (handlers.length > 0) {
1204
1463
  trace.handler = { path: handlers[0].path, className: handlers[0].className || null, methods: handlers[0].methods || [] };
1464
+ const cronSnippet = readMethodSnippet(handlers[0].path, 'execute');
1465
+ if (cronSnippet) trace.handler.codeSnippet = cronSnippet;
1205
1466
  }
1206
1467
 
1207
1468
  if (depth === 'deep' && handlers[0]?.className) {
@@ -1307,6 +1568,32 @@ function formatSearchResults(results) {
1307
1568
  : r.searchText;
1308
1569
  }
1309
1570
 
1571
+ // 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'))) {
1573
+ if (r.methodName) {
1574
+ const preview = readMethodSnippet(r.path, r.methodName, 10);
1575
+ if (preview) entry.codePreview = preview;
1576
+ } else if (r.className) {
1577
+ // Show class declaration + first few lines
1578
+ const classShort = r.className.split('\\').pop();
1579
+ const preview = readMethodSnippet(r.path, classShort ? `class ${classShort}` : null, 10);
1580
+ if (!preview) {
1581
+ // Fallback: read file header (namespace + class line)
1582
+ const absP = r.path.startsWith('/') ? r.path : path.join(config.magentoRoot, r.path);
1583
+ try {
1584
+ const content = readFileSync(absP, 'utf-8');
1585
+ const lines = content.split('\n');
1586
+ const classLine = lines.findIndex(l => /^\s*(abstract\s+|final\s+)?class\s+/.test(l));
1587
+ if (classLine >= 0) {
1588
+ entry.codePreview = lines.slice(Math.max(0, classLine - 2), classLine + 8).join('\n');
1589
+ }
1590
+ } catch {}
1591
+ } else {
1592
+ entry.codePreview = preview;
1593
+ }
1594
+ }
1595
+ }
1596
+
1310
1597
  return entry;
1311
1598
  });
1312
1599
 
@@ -3199,6 +3486,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3199
3486
  }
3200
3487
  }
3201
3488
  },
3489
+ {
3490
+ name: 'magento_find_fieldset',
3491
+ description: 'Find fieldset.xml definitions that control how data is copied between Magento entities (e.g., order→quote, quote→order). Shows which fields are copied for each aspect (to_order, to_edit, to_quote). Essential for understanding data conversion flows like reorder, order edit, and checkout.',
3492
+ inputSchema: {
3493
+ type: 'object',
3494
+ properties: {
3495
+ fieldset: {
3496
+ type: 'string',
3497
+ description: 'Fieldset name or partial match. Examples: "sales_copy_order", "sales_convert_quote", "sales_convert_order", "customer_account"'
3498
+ },
3499
+ aspect: {
3500
+ type: 'string',
3501
+ description: 'Aspect name filter. Examples: "to_order", "to_edit", "to_quote", "to_customer"'
3502
+ }
3503
+ }
3504
+ }
3505
+ },
3506
+ {
3507
+ name: 'magento_trace_shipping_chain',
3508
+ description: 'Trace the complete shipping rate calculation chain: carrier classes → plugins on collectRates() → ShippingRateModifier pool → totals collectors → fieldset copy mappings. Use this to understand how shipping prices are calculated, modified, and propagated during checkout, reorder, or order edit.',
3509
+ inputSchema: {
3510
+ type: 'object',
3511
+ properties: {
3512
+ carrier: {
3513
+ type: 'string',
3514
+ description: 'Optional carrier or shipping method to focus on. Examples: "flatrate", "freeshipping", "tablerate", "innoship", "home_delivery", "pickup"'
3515
+ }
3516
+ }
3517
+ }
3518
+ },
3202
3519
  {
3203
3520
  name: 'magento_trace_flow',
3204
3521
  description: 'Trace Magento execution flow from an entry point (route, API endpoint, GraphQL mutation, event, or cron job). Chains multiple searches to map controller → plugins → observers → templates for a given request path. Use this to understand how a request is processed end-to-end.',
@@ -3696,6 +4013,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3696
4013
  }
3697
4014
  }
3698
4015
 
4016
+ // Resolve plugin methods for DI registrations
4017
+ const fpRoot = args.targetClass ? config.magentoRoot : null;
4018
+ for (const reg of diRegistrations) {
4019
+ if (reg.pluginClass && fpRoot) {
4020
+ const pluginFile = findClassFile(fpRoot, reg.pluginClass);
4021
+ if (pluginFile) {
4022
+ reg.methods = extractPluginMethods(pluginFile);
4023
+ reg.resolvedFile = pluginFile.replace(fpRoot + '/', '');
4024
+ }
4025
+ }
4026
+ }
4027
+
3699
4028
  let text = formatSearchResults(enrichedResults);
3700
4029
  if (diRegistrations.length > 0) {
3701
4030
  text += `\n\n### DI Plugin Registrations for ${args.targetClass} (${diRegistrations.length})\n`;
@@ -3703,6 +4032,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3703
4032
  const disabledTag = reg.disabled ? ' **[DISABLED]**' : '';
3704
4033
  const sortTag = reg.sortOrder ? ` (sortOrder: ${reg.sortOrder})` : '';
3705
4034
  text += `- **${reg.pluginName}** → \`${reg.pluginClass}\` [${reg.area}]${sortTag}${disabledTag} (${reg.file})\n`;
4035
+ if (reg.resolvedFile) {
4036
+ text += ` PHP: \`${reg.resolvedFile}\`\n`;
4037
+ }
4038
+ if (reg.methods?.length > 0) {
4039
+ for (const m of reg.methods) {
4040
+ text += ` - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\`\n`;
4041
+ }
4042
+ }
3706
4043
  }
3707
4044
  }
3708
4045
 
@@ -4222,6 +4559,93 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4222
4559
  }
4223
4560
  }
4224
4561
 
4562
+ case 'magento_find_fieldset': {
4563
+ const fieldsets = await parseFieldsetXml(args.fieldset || null, args.aspect || null);
4564
+ if (fieldsets.length === 0) {
4565
+ return { content: [{ type: 'text', text: 'No fieldset.xml definitions found matching the criteria.' }] };
4566
+ }
4567
+ let text = `## Fieldset Definitions (${fieldsets.length} matches)\n\n`;
4568
+ for (const fs of fieldsets) {
4569
+ text += `### ${fs.scope ? `${fs.scope} / ` : ''}${fs.fieldset}\n`;
4570
+ text += `File: \`${fs.file}\`\n`;
4571
+ // Group fields by aspect
4572
+ const byAspect = {};
4573
+ for (const f of fs.fields) {
4574
+ if (!byAspect[f.aspect]) byAspect[f.aspect] = [];
4575
+ byAspect[f.aspect].push(f.field);
4576
+ }
4577
+ for (const [aspect, fields] of Object.entries(byAspect)) {
4578
+ text += `- **${aspect}**: ${fields.map(f => `\`${f}\``).join(', ')}\n`;
4579
+ }
4580
+ text += '\n';
4581
+ }
4582
+ return { content: [{ type: 'text', text }] };
4583
+ }
4584
+
4585
+ case 'magento_trace_shipping_chain': {
4586
+ const chain = await traceShippingChain(args.carrier || null);
4587
+ let text = `## Shipping Rate Chain\n\n`;
4588
+
4589
+ if (chain.carriers.length > 0) {
4590
+ text += `### Carriers (${chain.carriers.length})\n`;
4591
+ for (const c of chain.carriers) {
4592
+ text += `- \`${c.className || c.path}\` (${c.path})\n`;
4593
+ if (c.methods?.length > 0) text += ` Methods: ${c.methods.join(', ')}\n`;
4594
+ }
4595
+ text += '\n';
4596
+ }
4597
+
4598
+ if (chain.collectRatesPlugins.length > 0) {
4599
+ text += `### Plugins on collectRates (${chain.collectRatesPlugins.length})\n`;
4600
+ for (const p of chain.collectRatesPlugins) {
4601
+ const disabledTag = p.disabled ? ' **[DISABLED]**' : '';
4602
+ const sortTag = p.sortOrder ? ` (sortOrder: ${p.sortOrder})` : '';
4603
+ text += `- **${p.pluginName}** → \`${p.pluginClass}\` [${p.area}]${sortTag}${disabledTag}\n`;
4604
+ text += ` Target: \`${p.target}\` | DI: \`${p.file}\`\n`;
4605
+ if (p.methods?.length > 0) {
4606
+ for (const m of p.methods) {
4607
+ text += ` - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\`\n`;
4608
+ }
4609
+ }
4610
+ }
4611
+ text += '\n';
4612
+ }
4613
+
4614
+ if (chain.rateModifiers.length > 0) {
4615
+ text += `### Shipping Rate Modifiers (${chain.rateModifiers.length})\n`;
4616
+ for (const m of chain.rateModifiers) {
4617
+ text += `- \`${m.className || m.path}\` (${m.path})\n`;
4618
+ }
4619
+ text += '\n';
4620
+ }
4621
+
4622
+ if (chain.totalsCollectors.length > 0) {
4623
+ text += `### Totals Collectors (${chain.totalsCollectors.length})\n`;
4624
+ for (const t of chain.totalsCollectors) {
4625
+ text += `- \`${t.className || t.path}\` (${t.path})\n`;
4626
+ }
4627
+ text += '\n';
4628
+ }
4629
+
4630
+ if (chain.fieldsets.length > 0) {
4631
+ text += `### Related Fieldsets (${chain.fieldsets.length})\n`;
4632
+ for (const fs of chain.fieldsets) {
4633
+ const byAspect = {};
4634
+ for (const f of fs.fields) {
4635
+ if (!byAspect[f.aspect]) byAspect[f.aspect] = [];
4636
+ byAspect[f.aspect].push(f.field);
4637
+ }
4638
+ text += `- **${fs.scope || fs.fieldset}** (${fs.file})\n`;
4639
+ for (const [aspect, fields] of Object.entries(byAspect)) {
4640
+ text += ` - ${aspect}: ${fields.map(f => `\`${f}\``).join(', ')}\n`;
4641
+ }
4642
+ }
4643
+ text += '\n';
4644
+ }
4645
+
4646
+ return { content: [{ type: 'text', text }] };
4647
+ }
4648
+
4225
4649
  case 'magento_trace_flow': {
4226
4650
  const entryPoint = args.entryPoint;
4227
4651
  const entryType = args.entryType || 'auto';