hac-mcp 1.0.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/LICENSE +41 -0
- package/README.md +145 -0
- package/bin/hac-mcp.js +88 -0
- package/hac.js +320 -0
- package/package.json +53 -0
- package/server.js +276 -0
- package/static/app.js +650 -0
- package/static/index.html +211 -0
- package/static/style.css +282 -0
- package/storage.js +54 -0
- package/tools/context.js +107 -0
- package/tools/flexible_search.js +161 -0
- package/tools/get_type_info.js +188 -0
- package/tools/groovy_execute.js +60 -0
- package/tools/impex_import.js +180 -0
- package/tools/index.js +40 -0
- package/tools/list_cronjobs.js +74 -0
- package/tools/list_environments.js +25 -0
- package/tools/media_read.js +86 -0
- package/tools/media_write.js +122 -0
- package/tools/read_property.js +51 -0
- package/tools/resolve_pk.js +84 -0
- package/tools/run_cronjob.js +71 -0
- package/tools/search_type.js +40 -0
- package/tools/zodLoose.js +26 -0
- package/type-index.js +92 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flexibleSearch, impexImport } from '../hac.js';
|
|
3
|
+
import { optionalLooseBool, optionalLooseNumber } from './zodLoose.js';
|
|
4
|
+
import { withSession, getEnvironment, mcpLogStart, mcpLog, text, error } from './context.js';
|
|
5
|
+
import { fetchScalarFields } from './flexible_search.js';
|
|
6
|
+
|
|
7
|
+
const TOOL = 'impex_import';
|
|
8
|
+
|
|
9
|
+
function parseImpexHeaders(script) {
|
|
10
|
+
const result = [];
|
|
11
|
+
for (const line of script.split('\n')) {
|
|
12
|
+
const m = line.trim().match(/^(INSERT_UPDATE|INSERT|UPDATE|REMOVE)\s+(\w+)/);
|
|
13
|
+
if (!m) continue;
|
|
14
|
+
const typeCode = m[2];
|
|
15
|
+
const rest = line.trim().slice(m[0].length);
|
|
16
|
+
const semiIdx = rest.indexOf(';');
|
|
17
|
+
if (semiIdx === -1) continue;
|
|
18
|
+
const cols = rest.slice(semiIdx + 1).split(';')
|
|
19
|
+
.map(c => c.trim().match(/^(\w+)/)?.[1]).filter(Boolean);
|
|
20
|
+
result.push({ typeCode, cols });
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function validateImpexScript(env, script) {
|
|
26
|
+
const headers = parseImpexHeaders(script);
|
|
27
|
+
if (!headers.length) return [];
|
|
28
|
+
const uniqueTypes = [...new Set(headers.map(h => h.typeCode))];
|
|
29
|
+
|
|
30
|
+
const typeChains = {};
|
|
31
|
+
try {
|
|
32
|
+
const conds = uniqueTypes.map(t => `{code} = '${t}'`).join(' OR ');
|
|
33
|
+
const r = await withSession(env, s => flexibleSearch(s,
|
|
34
|
+
`SELECT {pk}, {code}, {inheritancepathstring} FROM {ComposedType} WHERE ${conds}`
|
|
35
|
+
));
|
|
36
|
+
if (r.resultList) {
|
|
37
|
+
for (const [pk, code, inheritancePath] of r.resultList) {
|
|
38
|
+
typeChains[code] = inheritancePath ? inheritancePath.split(',').filter(Boolean) : [String(pk)];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (_) { return []; }
|
|
42
|
+
|
|
43
|
+
const allPKs = [...new Set(Object.values(typeChains).flat())];
|
|
44
|
+
if (!allPKs.length) return [];
|
|
45
|
+
|
|
46
|
+
const mandatoryByTypePK = {};
|
|
47
|
+
const batchSize = 20;
|
|
48
|
+
for (let i = 0; i < allPKs.length; i += batchSize) {
|
|
49
|
+
const batch = allPKs.slice(i, i + batchSize);
|
|
50
|
+
try {
|
|
51
|
+
const encConds = batch.map(p => `{enclosingtype} = '${p}'`).join(' OR ');
|
|
52
|
+
const r = await withSession(env, s => flexibleSearch(s,
|
|
53
|
+
`SELECT {qualifier}, {enclosingtype} FROM {AttributeDescriptor} WHERE (${encConds}) AND {optional} = 0`,
|
|
54
|
+
{ maxCount: 500 }
|
|
55
|
+
));
|
|
56
|
+
if (r.resultList) {
|
|
57
|
+
for (const [qualifier, encPK] of r.resultList) {
|
|
58
|
+
const key = String(encPK);
|
|
59
|
+
if (!mandatoryByTypePK[key]) mandatoryByTypePK[key] = new Set();
|
|
60
|
+
mandatoryByTypePK[key].add(qualifier);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const warnings = [];
|
|
67
|
+
for (const { typeCode, cols } of headers) {
|
|
68
|
+
const chain = typeChains[typeCode];
|
|
69
|
+
if (!chain) continue;
|
|
70
|
+
const allMandatory = [...new Set(chain.flatMap(pk => [...(mandatoryByTypePK[pk] || [])]))];
|
|
71
|
+
const missing = allMandatory.filter(f => !cols.includes(f));
|
|
72
|
+
if (missing.length) warnings.push(`**${typeCode}**: missing mandatory field(s): ${missing.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
return warnings;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatImpexDetails(details) {
|
|
78
|
+
if (!details) return null;
|
|
79
|
+
return details.split('\n').map(line => {
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
if (!trimmed) return line;
|
|
82
|
+
const semiIdx = trimmed.indexOf(';');
|
|
83
|
+
if (semiIdx !== -1 && (trimmed.startsWith(',') || trimmed.length > 200)) {
|
|
84
|
+
const errorPart = trimmed.slice(0, semiIdx).replace(/^,+/, '').trim();
|
|
85
|
+
const dataCols = trimmed.slice(semiIdx + 1).split(';');
|
|
86
|
+
if (errorPart) {
|
|
87
|
+
return `ERROR: ${errorPart}\n (row data: ${dataCols.length} column(s) omitted)`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (trimmed.length > 300) return trimmed.slice(0, 300) + `… [truncated]`;
|
|
91
|
+
return line;
|
|
92
|
+
}).join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const tool = {
|
|
96
|
+
name: TOOL,
|
|
97
|
+
category: 'write',
|
|
98
|
+
description: 'Execute an ImpEx import script on a HAC environment. Call list_environments first to check import is allowed. IMPORTANT: Before calling this tool, you MUST show the user a summary of what data the script will insert, update, or remove and explicitly ask for their confirmation. Only proceed after the user approves and set confirmed_by_user to true.',
|
|
99
|
+
inputSchema: {
|
|
100
|
+
environmentId: z.string().describe('Environment ID from list_environments'),
|
|
101
|
+
script: z.string().optional().describe('ImpEx script content'),
|
|
102
|
+
impexContent: z.string().optional().describe('ImpEx script content (alias for script)'),
|
|
103
|
+
validationEnum: z.enum(['IMPORT_STRICT', 'IMPORT_RELAXED']).optional(),
|
|
104
|
+
maxThreads: optionalLooseNumber(),
|
|
105
|
+
legacyMode: optionalLooseBool(),
|
|
106
|
+
enableCodeExecution: optionalLooseBool(),
|
|
107
|
+
distributedMode: optionalLooseBool(),
|
|
108
|
+
sldEnabled: optionalLooseBool(),
|
|
109
|
+
confirmed_by_user: z.boolean().describe('Must be true - user has explicitly reviewed and approved this script. The server will reject the call if false.'),
|
|
110
|
+
},
|
|
111
|
+
handler: async ({ environmentId, script: scriptArg, impexContent, validationEnum, maxThreads, legacyMode, enableCodeExecution, distributedMode, sldEnabled, confirmed_by_user }) => {
|
|
112
|
+
if (!confirmed_by_user) {
|
|
113
|
+
return error('User confirmation required. Show the user the ImpEx script and what it will insert, update, or remove, ask for explicit approval, then retry with confirmed_by_user: true.');
|
|
114
|
+
}
|
|
115
|
+
const script = scriptArg ?? impexContent;
|
|
116
|
+
if (typeof script !== 'string' || !script.length) {
|
|
117
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Missing script', isError: true });
|
|
118
|
+
return error('Provide script or impexContent with the ImpEx script.');
|
|
119
|
+
}
|
|
120
|
+
const env = await getEnvironment(environmentId);
|
|
121
|
+
if (!env) {
|
|
122
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
|
|
123
|
+
return error(`Environment "${environmentId}" not found.`);
|
|
124
|
+
}
|
|
125
|
+
if (!env.allowImpexImport) {
|
|
126
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'ImpEx disabled', isError: true });
|
|
127
|
+
return error(`ImpEx import is disabled for environment "${env.name}".`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const scriptPreview = script.split('\n')[0].slice(0, 60);
|
|
131
|
+
const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: `${scriptPreview}…` });
|
|
132
|
+
|
|
133
|
+
let validationWarnings = [];
|
|
134
|
+
try {
|
|
135
|
+
validationWarnings = await validateImpexScript(env, script);
|
|
136
|
+
} catch (_) {}
|
|
137
|
+
|
|
138
|
+
if (validationWarnings.length) {
|
|
139
|
+
const warnOut = `**Pre-validation warnings** (import not executed):\n${validationWarnings.map(w => `- ${w}`).join('\n')}\n\nFix the script and retry.`;
|
|
140
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'Pre-validation failed', detail: warnOut, isError: true, runId });
|
|
141
|
+
return error(warnOut);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let result;
|
|
145
|
+
try {
|
|
146
|
+
result = await withSession(env, s => impexImport(s, script, {
|
|
147
|
+
validationEnum, maxThreads, legacyMode, enableCodeExecution, distributedMode, sldEnabled,
|
|
148
|
+
}));
|
|
149
|
+
} catch (e) {
|
|
150
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, detail: e.stack || '', isError: true, runId });
|
|
151
|
+
return error(e.message);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isErr = result.level === 'error';
|
|
155
|
+
const icon = isErr ? '❌' : '✅';
|
|
156
|
+
let out = `**${env.name}** - ${icon} ${result.result || 'Import complete'}\n`;
|
|
157
|
+
if (result.details) out += `\n\`\`\`\n${formatImpexDetails(result.details)}\n\`\`\``;
|
|
158
|
+
|
|
159
|
+
if (isErr && result.details) {
|
|
160
|
+
const unknownAttrTypes = [...new Set(
|
|
161
|
+
[...result.details.matchAll(/unknown attributes \[(\w+)\.\w+\]/g)].map(m => m[1])
|
|
162
|
+
)];
|
|
163
|
+
if (unknownAttrTypes.length) {
|
|
164
|
+
const hints = [];
|
|
165
|
+
for (const typeName of unknownAttrTypes) {
|
|
166
|
+
const fields = await fetchScalarFields(env, typeName);
|
|
167
|
+
if (fields) hints.push(`**${typeName}** valid scalar fields:\n ${fields}`);
|
|
168
|
+
}
|
|
169
|
+
if (hints.length) out += `\n\n**Field hints** (use get_type_info for relation fields):\n${hints.join('\n\n')}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
mcpLog({ tool: TOOL, envName: env.name,
|
|
174
|
+
preview: `${isErr ? '❌' : '✅'} ${result.result || 'Done'} - ${scriptPreview}…`,
|
|
175
|
+
detail: `Script:\n${script}\n\nResult: ${result.result}\n\n${result.details || ''}`,
|
|
176
|
+
isError: isErr, runId,
|
|
177
|
+
});
|
|
178
|
+
return text(out);
|
|
179
|
+
},
|
|
180
|
+
};
|
package/tools/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { callCtx } from './context.js';
|
|
3
|
+
import { tool as listEnvironments } from './list_environments.js';
|
|
4
|
+
import { tool as flexibleSearch } from './flexible_search.js';
|
|
5
|
+
import { tool as searchType } from './search_type.js';
|
|
6
|
+
import { tool as getTypeInfo } from './get_type_info.js';
|
|
7
|
+
import { tool as resolvePk } from './resolve_pk.js';
|
|
8
|
+
import { tool as impexImport } from './impex_import.js';
|
|
9
|
+
import { tool as groovyExecute } from './groovy_execute.js';
|
|
10
|
+
import { tool as readProperty } from './read_property.js';
|
|
11
|
+
import { tool as mediaRead } from './media_read.js';
|
|
12
|
+
import { tool as mediaWrite } from './media_write.js';
|
|
13
|
+
import { tool as runCronjob } from './run_cronjob.js';
|
|
14
|
+
import { tool as listCronjobs } from './list_cronjobs.js';
|
|
15
|
+
|
|
16
|
+
const tools = [
|
|
17
|
+
listEnvironments,
|
|
18
|
+
flexibleSearch,
|
|
19
|
+
searchType,
|
|
20
|
+
getTypeInfo,
|
|
21
|
+
resolvePk,
|
|
22
|
+
impexImport,
|
|
23
|
+
groovyExecute,
|
|
24
|
+
readProperty,
|
|
25
|
+
mediaRead,
|
|
26
|
+
mediaWrite,
|
|
27
|
+
runCronjob,
|
|
28
|
+
listCronjobs,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export { tools };
|
|
32
|
+
|
|
33
|
+
export function registerAllTools(mcp, getClientLabel) {
|
|
34
|
+
for (const { name, description, handler, inputSchema } of tools) {
|
|
35
|
+
mcp.registerTool(name, { description, inputSchema: z.object(inputSchema ?? {}) }, (args, extra) => {
|
|
36
|
+
const client = getClientLabel?.(extra?.sessionId) ?? null;
|
|
37
|
+
return callCtx.run({ client }, () => handler(args, extra));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flexibleSearch } from '../hac.js';
|
|
3
|
+
import { withSession, getEnvironment, mcpLogStart, mcpLog, text, error } from './context.js';
|
|
4
|
+
|
|
5
|
+
const TOOL = 'list_cronjobs';
|
|
6
|
+
|
|
7
|
+
export const tool = {
|
|
8
|
+
name: TOOL,
|
|
9
|
+
category: 'read',
|
|
10
|
+
description: 'List CronJobs on a SAP Commerce environment. Optionally filter by code (partial match). Returns pk, code, type, status, result, and last start/end times.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
environmentId: z.string().describe('Environment ID from list_environments'),
|
|
13
|
+
code: z.string().optional().describe('Filter by code (case-insensitive partial match)'),
|
|
14
|
+
},
|
|
15
|
+
handler: async ({ environmentId, code }) => {
|
|
16
|
+
const env = await getEnvironment(environmentId);
|
|
17
|
+
if (!env) {
|
|
18
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
|
|
19
|
+
return error(`Environment "${environmentId}" not found.`);
|
|
20
|
+
}
|
|
21
|
+
if (!env.allowFlexSearch) {
|
|
22
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'FlexSearch disabled', isError: true });
|
|
23
|
+
return error(`FlexibleSearch is disabled for environment "${env.name}".`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const where = code ? ` WHERE {cj:code} LIKE '%${code.replace(/'/g, "''")}%'` : '';
|
|
27
|
+
const query = `SELECT {cj:pk}, {cj:code}, {t:code}, {s:code}, {r:code}, {cj:startTime}, {cj:endTime} FROM {CronJob AS cj LEFT JOIN EnumerationValue AS s ON {cj:status} = {s:pk} LEFT JOIN EnumerationValue AS r ON {cj:result} = {r:pk} LEFT JOIN ComposedType AS t ON {cj:itemtype} = {t:pk}}${where} ORDER BY {cj:code} ASC`;
|
|
28
|
+
|
|
29
|
+
const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: code ? `Listing CronJobs matching "${code}"` : 'Listing all CronJobs' });
|
|
30
|
+
|
|
31
|
+
let result;
|
|
32
|
+
try {
|
|
33
|
+
result = await withSession(env, s => flexibleSearch(s, query, { maxCount: 500 }));
|
|
34
|
+
} catch (e) {
|
|
35
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, detail: e.stack || '', isError: true, runId });
|
|
36
|
+
return error(e.message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (result.exception) {
|
|
40
|
+
const msg = result.exception.message || JSON.stringify(result.exception);
|
|
41
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'Query error', detail: msg, isError: true, runId });
|
|
42
|
+
return error(`Query error: ${msg}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let { resultList, resultCount, executionTime } = result;
|
|
46
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `✅ ${resultCount} CronJob(s) in ${executionTime}ms`, runId });
|
|
47
|
+
|
|
48
|
+
if (code && resultList?.length) {
|
|
49
|
+
const q = code.toLowerCase();
|
|
50
|
+
const score = (row) => {
|
|
51
|
+
const c = (row[1] || '').toLowerCase();
|
|
52
|
+
if (c === q) return 0;
|
|
53
|
+
if (c.startsWith(q)) return 1;
|
|
54
|
+
return 2;
|
|
55
|
+
};
|
|
56
|
+
resultList = [...resultList].sort((a, b) => score(a) - score(b) || a[1].localeCompare(b[1]));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let out = `**${env.name}** - ${resultCount} CronJob(s) in ${executionTime}ms\n\n`;
|
|
60
|
+
if (resultList?.length) {
|
|
61
|
+
out += 'pk,code,type,status,result,startTime,endTime\n';
|
|
62
|
+
const csvCell = c => {
|
|
63
|
+
if (c === null) return '';
|
|
64
|
+
const s = String(c);
|
|
65
|
+
return (s.includes(',') || s.includes('"') || s.includes('\n')) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
66
|
+
};
|
|
67
|
+
for (const row of resultList) out += row.map(csvCell).join(',') + '\n';
|
|
68
|
+
} else {
|
|
69
|
+
out += 'No CronJobs found.\n';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return text(out);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { listEnvironments, mcpLogStart, mcpLog, text } from './context.js';
|
|
2
|
+
|
|
3
|
+
const TOOL = 'list_environments';
|
|
4
|
+
|
|
5
|
+
export const tool = {
|
|
6
|
+
name: TOOL,
|
|
7
|
+
category: 'utility',
|
|
8
|
+
description: 'List all configured HAC environments with their names, descriptions, and allowed operations.',
|
|
9
|
+
handler: async () => {
|
|
10
|
+
const runId = mcpLogStart({ tool: TOOL, envName: '', preview: 'Listing environments…' });
|
|
11
|
+
const envs = await listEnvironments();
|
|
12
|
+
if (!envs.length) {
|
|
13
|
+
mcpLog({ tool: TOOL, envName: '', preview: 'No environments configured', detail: 'No environments found.', runId });
|
|
14
|
+
return text('No environments configured. Add one via the management UI.');
|
|
15
|
+
}
|
|
16
|
+
const lines = envs.map(e =>
|
|
17
|
+
`- **${e.name}** (id: \`${e.id}\`)\n` +
|
|
18
|
+
` ${e.description || 'No description'}\n` +
|
|
19
|
+
` DB: ${e.dbType || 'unknown'} FlexSearch: ${e.allowFlexSearch ? '✅' : '❌'} ImpEx Import: ${e.allowImpexImport ? '✅' : '❌'} Groovy: ${e.allowGroovyExecution ? '✅' : '❌'} Read Property: ${e.allowReadProperty !== false ? '✅' : '❌'}`
|
|
20
|
+
);
|
|
21
|
+
const out = `## HAC Environments\n\n${lines.join('\n\n')}`;
|
|
22
|
+
mcpLog({ tool: TOOL, envName: '', preview: `${envs.length} environment(s)`, detail: out, runId });
|
|
23
|
+
return text(out);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { groovyExecute } from '../hac.js';
|
|
3
|
+
import { withSession, getEnvironment, mcpLogStart, mcpLog, text, error } from './context.js';
|
|
4
|
+
|
|
5
|
+
const TOOL = 'media_read';
|
|
6
|
+
|
|
7
|
+
const SCRIPT = (pk) => `
|
|
8
|
+
import de.hybris.platform.core.PK
|
|
9
|
+
import de.hybris.platform.servicelayer.media.MediaService
|
|
10
|
+
|
|
11
|
+
def mediaService = spring.getBean('mediaService')
|
|
12
|
+
def modelService = spring.getBean('modelService')
|
|
13
|
+
|
|
14
|
+
def media = modelService.get(PK.fromLong(${pk}L))
|
|
15
|
+
if (media.mime && media.mime != 'text/plain') {
|
|
16
|
+
throw new RuntimeException("media_read only supports text/plain media, but this media has mime: \${media.mime}")
|
|
17
|
+
}
|
|
18
|
+
def stream = mediaService.getStreamFromMedia(media)
|
|
19
|
+
def bytes = stream.bytes
|
|
20
|
+
stream.close()
|
|
21
|
+
bytes.encodeBase64().toString()
|
|
22
|
+
`.trim();
|
|
23
|
+
|
|
24
|
+
function decodeTextBytes(bytes) {
|
|
25
|
+
// UTF-16LE with real BOM (FF FE)
|
|
26
|
+
if (bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xFE) {
|
|
27
|
+
return bytes.slice(2).toString('utf16le');
|
|
28
|
+
}
|
|
29
|
+
// UTF-16LE with mangled BOM (FF FE encoded as UTF-8 replacement chars: EF BF BD EF BF BD)
|
|
30
|
+
if (bytes.length >= 8 &&
|
|
31
|
+
bytes[0] === 0xEF && bytes[1] === 0xBF && bytes[2] === 0xBD &&
|
|
32
|
+
bytes[3] === 0xEF && bytes[4] === 0xBF && bytes[5] === 0xBD &&
|
|
33
|
+
bytes[7] === 0x00) {
|
|
34
|
+
return bytes.slice(6).toString('utf16le');
|
|
35
|
+
}
|
|
36
|
+
// UTF-8 with BOM (EF BB BF)
|
|
37
|
+
if (bytes.length >= 3 && bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) {
|
|
38
|
+
return bytes.slice(3).toString('utf-8');
|
|
39
|
+
}
|
|
40
|
+
return bytes.toString('utf-8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const tool = {
|
|
44
|
+
name: TOOL,
|
|
45
|
+
category: 'read',
|
|
46
|
+
description: 'Read the text content of a SAP Commerce MediaModel by its PK. Only supports text/plain media.',
|
|
47
|
+
inputSchema: {
|
|
48
|
+
environmentId: z.string().describe('Environment ID from list_environments'),
|
|
49
|
+
mediaPk: z.string().describe('PK of the MediaModel to read'),
|
|
50
|
+
},
|
|
51
|
+
handler: async ({ environmentId, mediaPk }) => {
|
|
52
|
+
const env = await getEnvironment(environmentId);
|
|
53
|
+
if (!env) {
|
|
54
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
|
|
55
|
+
return error(`Environment "${environmentId}" not found.`);
|
|
56
|
+
}
|
|
57
|
+
if (!env.allowGroovyExecution) {
|
|
58
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'Groovy disabled', isError: true });
|
|
59
|
+
return error(`Groovy execution is disabled for environment "${env.name}".`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: `Reading media PK ${mediaPk}` });
|
|
63
|
+
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
result = await withSession(env, s => groovyExecute(s, SCRIPT(mediaPk)));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, detail: e.stack || '', isError: true, runId });
|
|
69
|
+
return error(e.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (result.stacktraceText) {
|
|
73
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `❌ PK ${mediaPk}`, detail: result.stacktraceText, isError: true, runId });
|
|
74
|
+
let out = `**${env.name}** - ❌ Error reading media PK ${mediaPk}\n`;
|
|
75
|
+
out += `\n**Stacktrace:**\n\`\`\`\n${result.stacktraceText}\n\`\`\``;
|
|
76
|
+
return { content: [{ type: 'text', text: out }], isError: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const base64 = (result.executionResult || '').trim();
|
|
80
|
+
const content = decodeTextBytes(Buffer.from(base64, 'base64'));
|
|
81
|
+
|
|
82
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `✅ PK ${mediaPk} (${content.length} chars)`, runId });
|
|
83
|
+
|
|
84
|
+
return text(`**${env.name}** - ✅ Media PK ${mediaPk}\n\n**Content:**\n\`\`\`\n${content}\n\`\`\``);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { groovyExecute } from '../hac.js';
|
|
3
|
+
import { withSession, getEnvironment, mcpLogStart, mcpLog, text, error } from './context.js';
|
|
4
|
+
|
|
5
|
+
const TOOL = 'media_write';
|
|
6
|
+
|
|
7
|
+
const SCRIPT_OVERWRITE = (mediaPk, base64, realFileName) => `
|
|
8
|
+
import de.hybris.platform.core.PK
|
|
9
|
+
|
|
10
|
+
def mediaService = spring.getBean('mediaService')
|
|
11
|
+
def modelService = spring.getBean('modelService')
|
|
12
|
+
|
|
13
|
+
def media = modelService.get(PK.fromLong(${mediaPk}L))
|
|
14
|
+
def bytes = "${base64}".decodeBase64()
|
|
15
|
+
mediaService.setStreamForMedia(media, new java.io.ByteArrayInputStream(bytes), ${realFileName ? `"${realFileName}"` : 'media.realFileName ?: media.code'}, "text/plain")
|
|
16
|
+
"OK - wrote \${bytes.length} bytes to media \${media.pk}"
|
|
17
|
+
`.trim();
|
|
18
|
+
|
|
19
|
+
const SCRIPT_CREATE = (targetPk, targetField, base64, realFileName, mediaCode, catalogVersionPk) => `
|
|
20
|
+
import de.hybris.platform.core.PK
|
|
21
|
+
|
|
22
|
+
def mediaService = spring.getBean('mediaService')
|
|
23
|
+
def modelService = spring.getBean('modelService')
|
|
24
|
+
def typeService = spring.getBean('typeService')
|
|
25
|
+
|
|
26
|
+
def target = modelService.get(PK.fromLong(${targetPk}L))
|
|
27
|
+
def typecode = target.itemtype
|
|
28
|
+
def attrDesc = typeService.getAttributeDescriptor(typecode, "${targetField}")
|
|
29
|
+
def mediaTypecode = attrDesc.attributeType.code
|
|
30
|
+
|
|
31
|
+
def media = modelService.create(mediaTypecode)
|
|
32
|
+
media.code = ${mediaCode ? `"${mediaCode}"` : `"media_" + System.currentTimeMillis()`}
|
|
33
|
+
|
|
34
|
+
// assign catalog version only for catalog-aware media types (where catalogVersion is mandatory)
|
|
35
|
+
def needsCatalogVersion = false
|
|
36
|
+
try {
|
|
37
|
+
def cvAttr = typeService.getAttributeDescriptor(typeService.getComposedTypeForCode(mediaTypecode), 'catalogVersion')
|
|
38
|
+
needsCatalogVersion = !cvAttr.optional
|
|
39
|
+
} catch (Exception e) { /* no catalogVersion attribute */ }
|
|
40
|
+
if (needsCatalogVersion) {
|
|
41
|
+
${catalogVersionPk
|
|
42
|
+
? `media.catalogVersion = modelService.get(PK.fromLong(${catalogVersionPk}L))`
|
|
43
|
+
: `if (target.hasProperty('catalogVersion') && target.catalogVersion != null) {
|
|
44
|
+
media.catalogVersion = target.catalogVersion
|
|
45
|
+
} else {
|
|
46
|
+
throw new RuntimeException("Media type '\${mediaTypecode}' requires a catalog version but none could be inferred from the target item. Provide catalogVersionPk explicitly.")
|
|
47
|
+
}`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
modelService.save(media)
|
|
52
|
+
|
|
53
|
+
def bytes = "${base64}".decodeBase64()
|
|
54
|
+
mediaService.setStreamForMedia(media, new java.io.ByteArrayInputStream(bytes), ${realFileName ? `"${realFileName}"` : `media.code + ".txt"`}, "text/plain")
|
|
55
|
+
|
|
56
|
+
target."${targetField}" = media
|
|
57
|
+
modelService.save(target)
|
|
58
|
+
|
|
59
|
+
"OK - created media \${media.pk} (\${mediaTypecode}, code=\${media.code}), wrote \${bytes.length} bytes, assigned to \${typecode}.\${target.pk}.${targetField}"
|
|
60
|
+
`.trim();
|
|
61
|
+
|
|
62
|
+
export const tool = {
|
|
63
|
+
name: TOOL,
|
|
64
|
+
category: 'write',
|
|
65
|
+
description: 'Write text/plain content to a SAP Commerce MediaModel. If mediaPk is provided, overwrites the existing media stream. Otherwise creates a new media by inspecting the target item\'s field type, sets the stream, and assigns it back to the field. IMPORTANT: Before calling this tool, show the user what will be written and ask for confirmation. Set confirmed_by_user to true only after the user approves.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
environmentId: z.string().describe('Environment ID from list_environments'),
|
|
68
|
+
content: z.string().describe('Text content to write (UTF-8)'),
|
|
69
|
+
confirmed_by_user: z.boolean().describe('Must be true - user has explicitly reviewed what will be written and approved. The server will reject the call if false.'),
|
|
70
|
+
mediaPk: z.string().optional().describe('PK of an existing MediaModel to overwrite. If provided, targetPk/targetField are ignored.'),
|
|
71
|
+
targetPk: z.string().optional().describe('PK of the item that owns the media field. Required when mediaPk is not provided.'),
|
|
72
|
+
targetField: z.string().optional().describe('Attribute name on the target item (e.g. "content"). Used to determine media type and assign after creation.'),
|
|
73
|
+
realFileName: z.string().optional().describe('Filename for the media (e.g. "import.txt")'),
|
|
74
|
+
mediaCode: z.string().optional().describe('Code for the new media item. Auto-generated if not provided.'),
|
|
75
|
+
catalogVersionPk: z.string().optional().describe('PK of the CatalogVersion to assign to the new media. Only needed for catalog-aware media types. If absent, inferred from the target item.'),
|
|
76
|
+
},
|
|
77
|
+
handler: async ({ environmentId, content: contentStr, mediaPk, targetPk, targetField, realFileName, mediaCode, catalogVersionPk, confirmed_by_user }) => {
|
|
78
|
+
if (!confirmed_by_user) {
|
|
79
|
+
return error('User confirmation required. Show the user what content will be written and to which media, ask for explicit approval, then retry with confirmed_by_user: true.');
|
|
80
|
+
}
|
|
81
|
+
const env = await getEnvironment(environmentId);
|
|
82
|
+
if (!env) {
|
|
83
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
|
|
84
|
+
return error(`Environment "${environmentId}" not found.`);
|
|
85
|
+
}
|
|
86
|
+
if (!env.allowGroovyExecution) {
|
|
87
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'Groovy disabled', isError: true });
|
|
88
|
+
return error(`Groovy execution is disabled for environment "${env.name}".`);
|
|
89
|
+
}
|
|
90
|
+
if (env.allowGroovyCommitMode === false) {
|
|
91
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'Groovy commit mode disabled', isError: true });
|
|
92
|
+
return error(`Groovy commit mode is disabled for environment "${env.name}".`);
|
|
93
|
+
}
|
|
94
|
+
if (!mediaPk && (!targetPk || !targetField)) {
|
|
95
|
+
return error('Either mediaPk or both targetPk and targetField must be provided.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const base64 = Buffer.from(contentStr, 'utf-8').toString('base64');
|
|
99
|
+
const preview = mediaPk ? `Overwriting media PK ${mediaPk}` : `Creating media for ${targetField} on PK ${targetPk}`;
|
|
100
|
+
const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview });
|
|
101
|
+
|
|
102
|
+
const script = mediaPk
|
|
103
|
+
? SCRIPT_OVERWRITE(mediaPk, base64, realFileName)
|
|
104
|
+
: SCRIPT_CREATE(targetPk, targetField, base64, realFileName, mediaCode, catalogVersionPk);
|
|
105
|
+
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = await withSession(env, s => groovyExecute(s, script, { commit: true }));
|
|
109
|
+
} catch (e) {
|
|
110
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, detail: e.stack || '', isError: true, runId });
|
|
111
|
+
return error(e.message);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const isErr = !!result.stacktraceText;
|
|
115
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `${isErr ? '❌' : '✅'} ${preview}`, detail: result.stacktraceText || result.executionResult || '', isError: isErr, runId });
|
|
116
|
+
|
|
117
|
+
let out = `**${env.name}** - ${isErr ? '❌ Error' : '✅ Success'}\n`;
|
|
118
|
+
if (result.executionResult) out += `\n**Result:**\n\`\`\`\n${result.executionResult}\n\`\`\``;
|
|
119
|
+
if (result.stacktraceText) out += `\n**Stacktrace:**\n\`\`\`\n${result.stacktraceText}\n\`\`\``;
|
|
120
|
+
return isErr ? { content: [{ type: 'text', text: out }], isError: true } : text(out);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readProperties } from '../hac.js';
|
|
3
|
+
import { withSession, getEnvironment, mcpLogStart, mcpLog, text, error } from './context.js';
|
|
4
|
+
|
|
5
|
+
const TOOL = 'read_property';
|
|
6
|
+
|
|
7
|
+
export const tool = {
|
|
8
|
+
name: TOOL,
|
|
9
|
+
category: 'read',
|
|
10
|
+
description: 'Search HAC configuration properties by key or value. Returns matching key-value pairs from the platform configuration page.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
environmentId: z.string().describe('Environment ID from list_environments'),
|
|
13
|
+
search: z.string().describe('Search term - matches against property keys and values (case-insensitive substring)'),
|
|
14
|
+
},
|
|
15
|
+
handler: async ({ environmentId, search }) => {
|
|
16
|
+
const env = await getEnvironment(environmentId);
|
|
17
|
+
if (!env) {
|
|
18
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
|
|
19
|
+
return error(`Environment "${environmentId}" not found.`);
|
|
20
|
+
}
|
|
21
|
+
if (env.allowReadProperty === false) {
|
|
22
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'Read property disabled', isError: true });
|
|
23
|
+
return error(`Read property is disabled for environment "${env.name}".`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: `Searching "${search}"…` });
|
|
27
|
+
|
|
28
|
+
let properties;
|
|
29
|
+
try {
|
|
30
|
+
properties = await withSession(env, s => readProperties(s));
|
|
31
|
+
} catch (e) {
|
|
32
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, isError: true, runId });
|
|
33
|
+
return error(e.message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const term = search.toLowerCase();
|
|
37
|
+
const matches = Object.entries(properties).filter(
|
|
38
|
+
([k, v]) => k.toLowerCase().includes(term) || v.toLowerCase().includes(term)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!matches.length) {
|
|
42
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `No properties matching "${search}"`, runId });
|
|
43
|
+
return text(`No properties found matching "${search}".`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lines = matches.map(([k, v]) => `${k} = ${v}`).join('\n');
|
|
47
|
+
const out = `**${env.name}** - ${matches.length} property match(es) for "${search}":\n\n\`\`\`\n${lines}\n\`\`\``;
|
|
48
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `${matches.length} match(es) for "${search}"`, detail: out, runId });
|
|
49
|
+
return text(out);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flexibleSearch, pkAnalyze } from '../hac.js';
|
|
3
|
+
import { withSession, getEnvironment, isTruthy, mcpLogStart, mcpLog, text, error } from './context.js';
|
|
4
|
+
|
|
5
|
+
const TOOL = 'resolve_pk';
|
|
6
|
+
|
|
7
|
+
export const tool = {
|
|
8
|
+
name: TOOL,
|
|
9
|
+
category: 'read',
|
|
10
|
+
description: 'Resolve a SAP Commerce PK to its type code and unique field values. Use this when a FlexibleSearch result contains an opaque PK and you need to know what item it refers to.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
environmentId: z.string().describe('Environment ID from list_environments'),
|
|
13
|
+
pk: z.string().describe('The PK value to resolve'),
|
|
14
|
+
},
|
|
15
|
+
handler: async ({ environmentId, pk }) => {
|
|
16
|
+
const env = await getEnvironment(environmentId);
|
|
17
|
+
if (!env) {
|
|
18
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
|
|
19
|
+
return error(`Environment "${environmentId}" not found.`);
|
|
20
|
+
}
|
|
21
|
+
if (!env.allowFlexSearch) {
|
|
22
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'FlexSearch disabled', isError: true });
|
|
23
|
+
return error(`FlexibleSearch is disabled for environment "${env.name}".`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: `Resolving PK ${pk}…` });
|
|
27
|
+
|
|
28
|
+
let analysis;
|
|
29
|
+
try {
|
|
30
|
+
analysis = await withSession(env, s => pkAnalyze(s, pk));
|
|
31
|
+
} catch (e) {
|
|
32
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, isError: true, runId });
|
|
33
|
+
return error(`PK analysis failed: ${e.message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (analysis.possibleException) {
|
|
37
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Invalid PK ${pk}`, detail: analysis.possibleException, isError: true, runId });
|
|
38
|
+
return error(`Invalid PK ${pk}: ${analysis.possibleException}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const typeCode = analysis.pkComposedTypeCode;
|
|
42
|
+
let out = `PK ${pk} → **${typeCode}** (typeCode: ${analysis.pkTypeCode})\nCreated: ${analysis.pkCreationDate}\n`;
|
|
43
|
+
|
|
44
|
+
if (typeCode && typeCode !== 'Item') {
|
|
45
|
+
let uniqueFields = [];
|
|
46
|
+
try {
|
|
47
|
+
const typePKResult = await withSession(env, s => flexibleSearch(s,
|
|
48
|
+
`SELECT {pk} FROM {ComposedType} WHERE {code} = '${typeCode}'`
|
|
49
|
+
));
|
|
50
|
+
const typePK = typePKResult.resultList?.[0]?.[0];
|
|
51
|
+
if (typePK) {
|
|
52
|
+
const attrResult = await withSession(env, s => flexibleSearch(s,
|
|
53
|
+
`SELECT {qualifier}, {unique} FROM {AttributeDescriptor} WHERE {enclosingtype} = '${typePK}'`,
|
|
54
|
+
{ maxCount: 200 }
|
|
55
|
+
));
|
|
56
|
+
uniqueFields = (attrResult.resultList || [])
|
|
57
|
+
.filter(([, isUnique]) => isTruthy(isUnique))
|
|
58
|
+
.map(([q]) => q);
|
|
59
|
+
}
|
|
60
|
+
} catch (_) {}
|
|
61
|
+
|
|
62
|
+
const fieldsToFetch = uniqueFields.length ? uniqueFields : ['pk'];
|
|
63
|
+
try {
|
|
64
|
+
const selectFields = fieldsToFetch.map(f => `{${f}}`).join(', ');
|
|
65
|
+
const itemResult = await withSession(env, s => flexibleSearch(s,
|
|
66
|
+
`SELECT ${selectFields} FROM {${typeCode}} WHERE {pk} = '${pk}'`
|
|
67
|
+
));
|
|
68
|
+
if (itemResult.resultList?.[0]) {
|
|
69
|
+
out += '\nUnique fields:\n';
|
|
70
|
+
for (const [i, f] of fieldsToFetch.entries()) {
|
|
71
|
+
out += ` ${f}: ${itemResult.resultList[0][i] ?? 'null'}\n`;
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
out += '\n(Item not found - may have been deleted)\n';
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
out += `\n(Could not fetch item details: ${e.message})\n`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `PK ${pk} → ${typeCode}`, detail: out, runId });
|
|
82
|
+
return text(out);
|
|
83
|
+
},
|
|
84
|
+
};
|