magector 2.5.2 → 2.6.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/package.json +5 -5
- package/src/mcp-server.js +167 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
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.
|
|
37
|
-
"@magector/cli-linux-x64": "2.
|
|
38
|
-
"@magector/cli-linux-arm64": "2.
|
|
39
|
-
"@magector/cli-win32-x64": "2.
|
|
36
|
+
"@magector/cli-darwin-arm64": "2.6.1",
|
|
37
|
+
"@magector/cli-linux-x64": "2.6.1",
|
|
38
|
+
"@magector/cli-linux-arm64": "2.6.1",
|
|
39
|
+
"@magector/cli-win32-x64": "2.6.1"
|
|
40
40
|
},
|
|
41
41
|
"keywords": [
|
|
42
42
|
"magento",
|
package/src/mcp-server.js
CHANGED
|
@@ -202,7 +202,16 @@ function releasePrimaryLock() {
|
|
|
202
202
|
* Write the serve process PID to disk so future instances can clean up orphans.
|
|
203
203
|
*/
|
|
204
204
|
function writePidFile(pid) {
|
|
205
|
-
try { writeFileSync(PID_PATH,
|
|
205
|
+
try { writeFileSync(PID_PATH, `${pid}\n${__pkg.version}`); } catch {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getServePidVersion() {
|
|
209
|
+
try {
|
|
210
|
+
if (!existsSync(PID_PATH)) return null;
|
|
211
|
+
const content = readFileSync(PID_PATH, 'utf-8').trim();
|
|
212
|
+
const lines = content.split('\n');
|
|
213
|
+
return lines[1] || null;
|
|
214
|
+
} catch { return null; }
|
|
206
215
|
}
|
|
207
216
|
|
|
208
217
|
function removePidFile() {
|
|
@@ -809,20 +818,29 @@ async function rustSearchAsync(query, limit = 10) {
|
|
|
809
818
|
if (queryFn) {
|
|
810
819
|
try {
|
|
811
820
|
const resp = await queryFn('search', { query, limit });
|
|
812
|
-
if (resp.ok && Array.isArray(resp.data)) {
|
|
821
|
+
if (resp.ok && Array.isArray(resp.data) && resp.data.length > 0) {
|
|
813
822
|
cacheSet(cacheKey, resp.data);
|
|
814
823
|
return resp.data;
|
|
815
824
|
}
|
|
825
|
+
// Serve returned empty results — fall through to execFileSync
|
|
826
|
+
// This catches stale serve processes with wrong/empty index
|
|
827
|
+
if (resp.ok && Array.isArray(resp.data) && resp.data.length === 0) {
|
|
828
|
+
logToFile('WARN', `Serve returned 0 results for "${query}" — trying execFileSync fallback`);
|
|
829
|
+
}
|
|
816
830
|
} catch (err) {
|
|
817
831
|
logToFile('WARN', `Serve query failed, falling back to execFileSync: ${err.message}`);
|
|
818
832
|
}
|
|
819
833
|
}
|
|
820
834
|
|
|
821
|
-
// Fallback: cold-start execFileSync
|
|
835
|
+
// Fallback: cold-start execFileSync (always works if CLI works)
|
|
822
836
|
logToFile('INFO', `Using execFileSync fallback for search: "${query}"`);
|
|
823
837
|
try {
|
|
824
838
|
const result = rustSearchSync(query, limit);
|
|
825
|
-
|
|
839
|
+
const arr = Array.isArray(result) ? result : [];
|
|
840
|
+
if (arr.length > 0) {
|
|
841
|
+
cacheSet(cacheKey, arr);
|
|
842
|
+
}
|
|
843
|
+
return arr;
|
|
826
844
|
} catch (err) {
|
|
827
845
|
logToFile('WARN', `execFileSync fallback failed: ${err.message}`);
|
|
828
846
|
return [];
|
|
@@ -1596,8 +1614,12 @@ function formatSearchResults(results) {
|
|
|
1596
1614
|
if (r.isBlock) badges.push('block');
|
|
1597
1615
|
if (badges.length > 0) entry.badges = badges;
|
|
1598
1616
|
|
|
1617
|
+
// Only include verbose content (snippet, codePreview) for top-ranked results
|
|
1618
|
+
// to reduce token consumption — lower-ranked results just show metadata
|
|
1619
|
+
const isTopRanked = i < 3;
|
|
1620
|
+
|
|
1599
1621
|
// Snippet — first 300 chars of indexed content for quick assessment
|
|
1600
|
-
if (r.searchText) {
|
|
1622
|
+
if (isTopRanked && r.searchText) {
|
|
1601
1623
|
entry.snippet = r.searchText.length > 300
|
|
1602
1624
|
? r.searchText.slice(0, 300) + '...'
|
|
1603
1625
|
: r.searchText;
|
|
@@ -1608,7 +1630,7 @@ function formatSearchResults(results) {
|
|
|
1608
1630
|
entry.codePreview = r.fullMethodBody;
|
|
1609
1631
|
}
|
|
1610
1632
|
// Code preview — read actual source lines for PHP files with known class/method
|
|
1611
|
-
else if (r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
|
|
1633
|
+
else if (isTopRanked && r.path && (r.path.endsWith('.php') || r.path.endsWith('.phtml'))) {
|
|
1612
1634
|
if (r.methodName) {
|
|
1613
1635
|
const preview = readMethodSnippet(r.path, r.methodName, 10);
|
|
1614
1636
|
if (preview) entry.codePreview = preview;
|
|
@@ -1639,6 +1661,47 @@ function formatSearchResults(results) {
|
|
|
1639
1661
|
return JSON.stringify({ results: formatted, count: formatted.length });
|
|
1640
1662
|
}
|
|
1641
1663
|
|
|
1664
|
+
// ─── DI XML Session Cache ─────────────────────────────────────
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Session-level cache for parsed di.xml file contents.
|
|
1668
|
+
* Avoids re-reading and re-globbing di.xml files across multiple tool calls
|
|
1669
|
+
* (findDiWiring, traceDependency, magento_find_plugin all scan di.xml).
|
|
1670
|
+
*/
|
|
1671
|
+
const diXmlCache = {
|
|
1672
|
+
/** @type {Map<string, string>} path → file content */
|
|
1673
|
+
files: new Map(),
|
|
1674
|
+
/** @type {string[]|null} cached list of all di.xml absolute paths */
|
|
1675
|
+
paths: null,
|
|
1676
|
+
/** @type {string|null} root used for caching (invalidate if root changes) */
|
|
1677
|
+
root: null
|
|
1678
|
+
};
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* Get all di.xml file paths and their contents, using session cache.
|
|
1682
|
+
* @param {string} root - Magento root path
|
|
1683
|
+
* @returns {Promise<Array<{absPath: string, relPath: string, content: string}>>}
|
|
1684
|
+
*/
|
|
1685
|
+
async function getDiXmlFiles(root) {
|
|
1686
|
+
if (diXmlCache.root !== root || !diXmlCache.paths) {
|
|
1687
|
+
diXmlCache.root = root;
|
|
1688
|
+
diXmlCache.paths = await glob('**/etc/**/di.xml', { cwd: root, absolute: true, nodir: true });
|
|
1689
|
+
diXmlCache.files.clear();
|
|
1690
|
+
}
|
|
1691
|
+
const results = [];
|
|
1692
|
+
for (const absPath of diXmlCache.paths) {
|
|
1693
|
+
let content = diXmlCache.files.get(absPath);
|
|
1694
|
+
if (content === undefined) {
|
|
1695
|
+
try { content = readFileSync(absPath, 'utf-8'); } catch { content = null; }
|
|
1696
|
+
diXmlCache.files.set(absPath, content);
|
|
1697
|
+
}
|
|
1698
|
+
if (content !== null) {
|
|
1699
|
+
results.push({ absPath, relPath: absPath.replace(root + '/', ''), content });
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return results;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1642
1705
|
// ─── DI Dependency Tracing ─────────────────────────────────────
|
|
1643
1706
|
|
|
1644
1707
|
/**
|
|
@@ -1647,7 +1710,7 @@ function formatSearchResults(results) {
|
|
|
1647
1710
|
*/
|
|
1648
1711
|
async function traceDependency(className, direction = 'both') {
|
|
1649
1712
|
const root = config.magentoRoot;
|
|
1650
|
-
const diFiles = await
|
|
1713
|
+
const diFiles = await getDiXmlFiles(root);
|
|
1651
1714
|
const classLower = className.toLowerCase();
|
|
1652
1715
|
const classShort = className.split('\\').pop().toLowerCase();
|
|
1653
1716
|
|
|
@@ -1660,13 +1723,7 @@ async function traceDependency(className, direction = 'both') {
|
|
|
1660
1723
|
totalDiFiles: diFiles.length
|
|
1661
1724
|
};
|
|
1662
1725
|
|
|
1663
|
-
for (const
|
|
1664
|
-
let content;
|
|
1665
|
-
try {
|
|
1666
|
-
content = readFileSync(diFile, 'utf-8');
|
|
1667
|
-
} catch { continue; }
|
|
1668
|
-
|
|
1669
|
-
const relativePath = diFile.replace(root + '/', '');
|
|
1726
|
+
for (const { content, relPath: relativePath } of diFiles) {
|
|
1670
1727
|
|
|
1671
1728
|
if (direction === 'resolve' || direction === 'both') {
|
|
1672
1729
|
// Find preferences: <preference for="ClassName" type="Implementation"/>
|
|
@@ -2618,13 +2675,10 @@ async function findDiWiring(className) {
|
|
|
2618
2675
|
totalDiFiles: 0
|
|
2619
2676
|
};
|
|
2620
2677
|
|
|
2621
|
-
const diFiles = await
|
|
2678
|
+
const diFiles = await getDiXmlFiles(root);
|
|
2622
2679
|
result.totalDiFiles = diFiles.length;
|
|
2623
2680
|
|
|
2624
|
-
for (const
|
|
2625
|
-
let content;
|
|
2626
|
-
try { content = readFileSync(diFile, 'utf-8'); } catch { continue; }
|
|
2627
|
-
const relativePath = diFile.replace(root + '/', '');
|
|
2681
|
+
for (const { content, relPath: relativePath } of diFiles) {
|
|
2628
2682
|
const contentLower = content.toLowerCase();
|
|
2629
2683
|
|
|
2630
2684
|
// Quick pre-filter: skip files that don't contain the short name at all
|
|
@@ -4011,7 +4065,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4011
4065
|
return {
|
|
4012
4066
|
content: [{
|
|
4013
4067
|
type: 'text',
|
|
4014
|
-
text: formatSearchResults(results.slice(0, args.limit ||
|
|
4068
|
+
text: formatSearchResults(results.slice(0, args.limit || 5))
|
|
4015
4069
|
}]
|
|
4016
4070
|
};
|
|
4017
4071
|
}
|
|
@@ -4028,7 +4082,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4028
4082
|
return {
|
|
4029
4083
|
content: [{
|
|
4030
4084
|
type: 'text',
|
|
4031
|
-
text: formatSearchResults(results.slice(0,
|
|
4085
|
+
text: formatSearchResults(results.slice(0, 3))
|
|
4032
4086
|
}]
|
|
4033
4087
|
};
|
|
4034
4088
|
}
|
|
@@ -4058,7 +4112,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4058
4112
|
return { ...r, score: (r.score || 0) + bonus };
|
|
4059
4113
|
}).sort((a, b) => b.score - a.score);
|
|
4060
4114
|
// Attach full method body to each result for complete understanding
|
|
4061
|
-
const sliced = results.slice(0,
|
|
4115
|
+
const sliced = results.slice(0, 5);
|
|
4062
4116
|
for (const r of sliced) {
|
|
4063
4117
|
if (r.path && r.path.endsWith('.php')) {
|
|
4064
4118
|
const body = readFullMethodBody(r.path, args.methodName);
|
|
@@ -4166,24 +4220,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4166
4220
|
return { ...r, diArea };
|
|
4167
4221
|
});
|
|
4168
4222
|
|
|
4169
|
-
// If targetClass provided, also scan di.xml for explicit registrations
|
|
4223
|
+
// If targetClass provided, also scan di.xml for explicit registrations (using session cache)
|
|
4170
4224
|
let diRegistrations = [];
|
|
4171
4225
|
if (args.targetClass) {
|
|
4172
4226
|
const fpRoot = config.magentoRoot;
|
|
4173
|
-
const diFiles = await
|
|
4227
|
+
const diFiles = await getDiXmlFiles(fpRoot);
|
|
4174
4228
|
// Normalize target class for matching (both \ and \\)
|
|
4175
4229
|
const normalizedTarget = args.targetClass.replace(/\\\\/g, '\\');
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
if (!content.includes(normalizedTarget)) continue;
|
|
4180
|
-
const relPath = diFile.replace(fpRoot + '/', '');
|
|
4230
|
+
const isFqcn = normalizedTarget.includes('\\');
|
|
4231
|
+
const shortTarget = normalizedTarget.split('\\').pop().toLowerCase();
|
|
4232
|
+
for (const { content, relPath } of diFiles) {
|
|
4233
|
+
if (!content.includes(isFqcn ? normalizedTarget : args.targetClass)) continue;
|
|
4181
4234
|
// Find plugin registrations for this target
|
|
4182
4235
|
const typeBlockRegex = /<type\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/type>/g;
|
|
4183
4236
|
let tm;
|
|
4184
4237
|
while ((tm = typeBlockRegex.exec(content)) !== null) {
|
|
4185
4238
|
const typeName = tm[1].replace(/\\\\/g, '\\');
|
|
4186
|
-
if
|
|
4239
|
+
// FQCN: exact match. Short name: match if type ends with the short name
|
|
4240
|
+
const typeMatches = isFqcn
|
|
4241
|
+
? typeName === normalizedTarget
|
|
4242
|
+
: typeName.split('\\').pop().toLowerCase() === shortTarget;
|
|
4243
|
+
if (!typeMatches) continue;
|
|
4187
4244
|
const block = tm[2];
|
|
4188
4245
|
const pluginRegex = /<plugin\s+([^/>]*)\/?>/g;
|
|
4189
4246
|
let pm;
|
|
@@ -4212,14 +4269,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4212
4269
|
}
|
|
4213
4270
|
}
|
|
4214
4271
|
|
|
4215
|
-
// Resolve plugin methods for DI registrations
|
|
4216
|
-
const
|
|
4272
|
+
// Resolve plugin methods + method bodies for DI registrations
|
|
4273
|
+
const fpRoot2 = args.targetClass ? config.magentoRoot : null;
|
|
4217
4274
|
for (const reg of diRegistrations) {
|
|
4218
|
-
if (reg.pluginClass &&
|
|
4219
|
-
const pluginFile = findClassFile(
|
|
4275
|
+
if (reg.pluginClass && fpRoot2) {
|
|
4276
|
+
const pluginFile = findClassFile(fpRoot2, reg.pluginClass);
|
|
4220
4277
|
if (pluginFile) {
|
|
4221
4278
|
reg.methods = extractPluginMethods(pluginFile);
|
|
4222
|
-
reg.resolvedFile = pluginFile.replace(
|
|
4279
|
+
reg.resolvedFile = pluginFile.replace(fpRoot2 + '/', '');
|
|
4280
|
+
// Read full method bodies so the agent sees actual code without follow-up calls
|
|
4281
|
+
for (const m of reg.methods) {
|
|
4282
|
+
const body = readFullMethodBody(pluginFile, m.name);
|
|
4283
|
+
if (body) m.body = body;
|
|
4284
|
+
}
|
|
4223
4285
|
}
|
|
4224
4286
|
}
|
|
4225
4287
|
}
|
|
@@ -4237,6 +4299,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4237
4299
|
if (reg.methods?.length > 0) {
|
|
4238
4300
|
for (const m of reg.methods) {
|
|
4239
4301
|
text += ` - \`${m.type}\` **${m.targetMethod}** → \`${m.name}()\`\n`;
|
|
4302
|
+
if (m.body) {
|
|
4303
|
+
const indentedBody = m.body.split('\n').join('\n ');
|
|
4304
|
+
text += ' ' + '```php\n ' + indentedBody + '\n ' + '```\n';
|
|
4305
|
+
}
|
|
4240
4306
|
}
|
|
4241
4307
|
}
|
|
4242
4308
|
}
|
|
@@ -4246,19 +4312,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4246
4312
|
}
|
|
4247
4313
|
|
|
4248
4314
|
case 'magento_find_observer': {
|
|
4249
|
-
|
|
4250
|
-
const
|
|
4251
|
-
let
|
|
4252
|
-
|
|
4253
|
-
)
|
|
4254
|
-
|
|
4315
|
+
// Primary: parse events.xml for exact event name match (structural, not semantic)
|
|
4316
|
+
const eventFlow = await traceEventFlow(args.eventName);
|
|
4317
|
+
let text = '';
|
|
4318
|
+
|
|
4319
|
+
if (eventFlow.observers.length > 0) {
|
|
4320
|
+
text += `### Observers for \`${args.eventName}\` (${eventFlow.observers.length})\n\n`;
|
|
4321
|
+
for (const obs of eventFlow.observers) {
|
|
4322
|
+
text += `- **${obs.name}** → \`${obs.instance}::${obs.method}()\` (${obs.file})\n`;
|
|
4323
|
+
}
|
|
4324
|
+
if (eventFlow.observerDetails.length > 0) {
|
|
4325
|
+
text += `\n### Observer PHP Files\n`;
|
|
4326
|
+
for (const det of eventFlow.observerDetails) {
|
|
4327
|
+
text += `- \`${det.instance}\` → ${det.path}\n`;
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4255
4331
|
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4332
|
+
// Fallback: semantic search if events.xml parsing found nothing
|
|
4333
|
+
if (eventFlow.observers.length === 0) {
|
|
4334
|
+
const query = `event ${args.eventName} observer`;
|
|
4335
|
+
const raw = await rustSearchAsync(query, 30);
|
|
4336
|
+
let results = raw.map(normalizeResult).filter(r =>
|
|
4337
|
+
r.isObserver || r.path?.includes('/Observer/') || r.path?.includes('events.xml')
|
|
4338
|
+
);
|
|
4339
|
+
results = rerank(results, { isObserver: true, pathContains: ['events.xml', '/Observer/'] });
|
|
4340
|
+
text = formatSearchResults(results.slice(0, 15));
|
|
4341
|
+
}
|
|
4342
|
+
|
|
4343
|
+
return { content: [{ type: 'text', text }] };
|
|
4262
4344
|
}
|
|
4263
4345
|
|
|
4264
4346
|
case 'magento_find_preference': {
|
|
@@ -5491,6 +5573,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
5491
5573
|
text = formatSearchResults(res.slice(0, 5));
|
|
5492
5574
|
break;
|
|
5493
5575
|
}
|
|
5576
|
+
case 'magento_module_structure': {
|
|
5577
|
+
const raw = await rustSearchAsync(a.moduleName, 200);
|
|
5578
|
+
const modulePath = a.moduleName.replace('_', '/') + '/';
|
|
5579
|
+
const parts = a.moduleName.split('_');
|
|
5580
|
+
const vendorPath = parts.length === 2 ? `module-${parts[1].toLowerCase()}/` : '';
|
|
5581
|
+
const res = raw.map(normalizeResult).filter(r => {
|
|
5582
|
+
const p = r.path || '';
|
|
5583
|
+
const mod = r.module || '';
|
|
5584
|
+
return mod === a.moduleName || p.includes(modulePath) || (vendorPath && p.toLowerCase().includes(vendorPath));
|
|
5585
|
+
});
|
|
5586
|
+
text = `Module: ${a.moduleName} (${res.length} files)\n`;
|
|
5587
|
+
const cats = { controllers: '/Controller/', models: '/Model/', plugins: '/Plugin/', observers: '/Observer/', api: '/Api/' };
|
|
5588
|
+
for (const [cat, pattern] of Object.entries(cats)) {
|
|
5589
|
+
const matches = res.filter(r => r.path?.includes(pattern));
|
|
5590
|
+
if (matches.length > 0) {
|
|
5591
|
+
text += `${cat}: ${matches.length} (${matches.slice(0, 3).map(r => r.className || r.path?.split('/').pop()).join(', ')})\n`;
|
|
5592
|
+
}
|
|
5593
|
+
}
|
|
5594
|
+
break;
|
|
5595
|
+
}
|
|
5596
|
+
case 'magento_find_observer': {
|
|
5597
|
+
const flow = await traceEventFlow(a.eventName);
|
|
5598
|
+
text = `Observers: ${flow.observers.length}\n`;
|
|
5599
|
+
for (const o of flow.observers.slice(0, 10)) {
|
|
5600
|
+
text += `- ${o.name}: ${o.instance}::${o.method}() (${o.file})\n`;
|
|
5601
|
+
}
|
|
5602
|
+
break;
|
|
5603
|
+
}
|
|
5494
5604
|
default:
|
|
5495
5605
|
text = `Unsupported batch tool: ${q.tool}`;
|
|
5496
5606
|
}
|
|
@@ -5583,6 +5693,16 @@ async function main() {
|
|
|
5583
5693
|
try {
|
|
5584
5694
|
let role = 'secondary';
|
|
5585
5695
|
|
|
5696
|
+
// Kill stale serve process if version mismatch (e.g., user upgraded Magector)
|
|
5697
|
+
const staleVersion = getServePidVersion();
|
|
5698
|
+
if (staleVersion && staleVersion !== __pkg.version) {
|
|
5699
|
+
logToFile('WARN', `Serve process version mismatch: ${staleVersion} vs ${__pkg.version} — killing stale process`);
|
|
5700
|
+
console.error(`Killing stale serve process (version ${staleVersion}, current ${__pkg.version})`);
|
|
5701
|
+
killStaleServeProcess();
|
|
5702
|
+
// Remove stale socket so we don't connect to it
|
|
5703
|
+
try { if (existsSync(SOCK_PATH)) unlinkSync(SOCK_PATH); } catch {}
|
|
5704
|
+
}
|
|
5705
|
+
|
|
5586
5706
|
const connected = await tryConnectSocket();
|
|
5587
5707
|
if (connected) {
|
|
5588
5708
|
logToFile('INFO', 'Joined existing serve process via socket (secondary)');
|