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,71 @@
|
|
|
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 = 'run_cronjob';
|
|
6
|
+
|
|
7
|
+
const SCRIPT = (cronJobPk) => `
|
|
8
|
+
import de.hybris.platform.core.PK
|
|
9
|
+
|
|
10
|
+
def cronJobService = spring.getBean('cronJobService')
|
|
11
|
+
def modelService = spring.getBean('modelService')
|
|
12
|
+
|
|
13
|
+
def cronJob = modelService.get(PK.fromLong(${cronJobPk}L))
|
|
14
|
+
cronJobService.performCronJob(cronJob, true)
|
|
15
|
+
|
|
16
|
+
modelService.refresh(cronJob)
|
|
17
|
+
def status = cronJob.status?.code
|
|
18
|
+
def result = cronJob.result?.code
|
|
19
|
+
def startTime = cronJob.startTime
|
|
20
|
+
def endTime = cronJob.endTime
|
|
21
|
+
"status=\${status}, result=\${result}, startTime=\${startTime}, endTime=\${endTime}"
|
|
22
|
+
`.trim();
|
|
23
|
+
|
|
24
|
+
export const tool = {
|
|
25
|
+
name: TOOL,
|
|
26
|
+
category: 'write',
|
|
27
|
+
description: 'Run a SAP Commerce CronJob synchronously by its PK and wait for it to finish. Returns the final status and result. IMPORTANT: Before calling this tool, you MUST tell the user which CronJob will run and what it does (e.g. sending emails, syncing data, triggering workflows) and explicitly ask for their confirmation. Only proceed after the user approves and set confirmed_by_user to true.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
environmentId: z.string().describe('Environment ID from list_environments'),
|
|
30
|
+
cronJobPk: z.string().describe('PK of the CronJob to run'),
|
|
31
|
+
confirmed_by_user: z.boolean().describe('Must be true - user has explicitly reviewed which CronJob will run and approved. The server will reject the call if false.'),
|
|
32
|
+
},
|
|
33
|
+
handler: async ({ environmentId, cronJobPk, confirmed_by_user }) => {
|
|
34
|
+
if (!confirmed_by_user) {
|
|
35
|
+
return error('User confirmation required. Tell the user which CronJob will run and what it does, ask for explicit approval, then retry with confirmed_by_user: true.');
|
|
36
|
+
}
|
|
37
|
+
const env = await getEnvironment(environmentId);
|
|
38
|
+
if (!env) {
|
|
39
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
|
|
40
|
+
return error(`Environment "${environmentId}" not found.`);
|
|
41
|
+
}
|
|
42
|
+
if (!env.allowGroovyExecution) {
|
|
43
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'Groovy disabled', isError: true });
|
|
44
|
+
return error(`Groovy execution is disabled for environment "${env.name}".`);
|
|
45
|
+
}
|
|
46
|
+
if (env.allowGroovyCommitMode === false) {
|
|
47
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: 'Groovy commit mode disabled', isError: true });
|
|
48
|
+
return error(`Groovy commit mode is disabled for environment "${env.name}".`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: `Running CronJob PK ${cronJobPk}` });
|
|
52
|
+
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
result = await withSession(env, s => groovyExecute(s, SCRIPT(cronJobPk), { commit: true }));
|
|
56
|
+
} catch (e) {
|
|
57
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, detail: e.stack || '', isError: true, runId });
|
|
58
|
+
return error(e.message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isErr = !!result.stacktraceText;
|
|
62
|
+
const preview = isErr ? `❌ CronJob PK ${cronJobPk}` : `✅ ${result.executionResult}`;
|
|
63
|
+
mcpLog({ tool: TOOL, envName: env.name, preview, detail: result.stacktraceText || '', isError: isErr, runId });
|
|
64
|
+
|
|
65
|
+
let out = `**${env.name}** - ${isErr ? '❌ Error' : '✅ CronJob finished'}\n`;
|
|
66
|
+
if (result.executionResult) out += `\n**Result:**\n\`\`\`\n${result.executionResult}\n\`\`\``;
|
|
67
|
+
if (result.outputText) out += `\n**Output:**\n\`\`\`\n${result.outputText}\n\`\`\``;
|
|
68
|
+
if (result.stacktraceText) out += `\n**Stacktrace:**\n\`\`\`\n${result.stacktraceText}\n\`\`\``;
|
|
69
|
+
return isErr ? { content: [{ type: 'text', text: out }], isError: true } : text(out);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getEnvironment, getTypeIndex, fuzzySearch, mcpLogStart, mcpLog, text, error } from './context.js';
|
|
3
|
+
|
|
4
|
+
const TOOL = 'search_type';
|
|
5
|
+
|
|
6
|
+
export const tool = {
|
|
7
|
+
name: TOOL,
|
|
8
|
+
category: 'read',
|
|
9
|
+
description: 'Search for SAP Commerce type names by fuzzy match. Use this before get_type_info when you are unsure of the exact type code.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
environmentId: z.string().describe('Environment ID from list_environments'),
|
|
12
|
+
query: z.string().describe('Type name to search for - fuzzy, e.g. "InboundProductLogs", "Solr", "Order"'),
|
|
13
|
+
},
|
|
14
|
+
handler: async ({ environmentId, query }) => {
|
|
15
|
+
const env = await getEnvironment(environmentId);
|
|
16
|
+
if (!env) {
|
|
17
|
+
mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
|
|
18
|
+
return error(`Environment "${environmentId}" not found.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: `Searching "${query}"…` });
|
|
22
|
+
|
|
23
|
+
let types;
|
|
24
|
+
try {
|
|
25
|
+
types = await getTypeIndex(env);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, isError: true, runId });
|
|
28
|
+
return error(`Failed to load type index: ${e.message}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const matches = fuzzySearch(query, types, { topN: 20 });
|
|
32
|
+
if (!matches.length) {
|
|
33
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `No types for "${query}"`, runId });
|
|
34
|
+
return text(`No types found matching "${query}".`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
mcpLog({ tool: TOOL, envName: env.name, preview: `${matches.length} type(s) for "${query}"`, detail: matches.join('\n'), runId });
|
|
38
|
+
return text(`Types matching "${query}":\n${matches.join('\n')}`);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export function optionalLooseNumber() {
|
|
4
|
+
return z.preprocess((val) => {
|
|
5
|
+
if (val === undefined || val === null) return undefined;
|
|
6
|
+
if (typeof val === 'number') return val;
|
|
7
|
+
if (typeof val === 'string' && val.trim() !== '') {
|
|
8
|
+
const n = Number(val);
|
|
9
|
+
if (!isNaN(n)) return n;
|
|
10
|
+
}
|
|
11
|
+
return val;
|
|
12
|
+
}, z.number().optional());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function optionalLooseBool() {
|
|
16
|
+
return z.preprocess((val) => {
|
|
17
|
+
if (val === undefined || val === null) return undefined;
|
|
18
|
+
if (typeof val === 'boolean') return val;
|
|
19
|
+
if (typeof val === 'string') {
|
|
20
|
+
const t = val.trim().toLowerCase();
|
|
21
|
+
if (t === 'true' || t === '1' || t === 'yes') return true;
|
|
22
|
+
if (t === 'false' || t === '0' || t === 'no') return false;
|
|
23
|
+
}
|
|
24
|
+
return val;
|
|
25
|
+
}, z.boolean().optional());
|
|
26
|
+
}
|
package/type-index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ─── Per-environment ComposedType index with trigram fuzzy search ─────────────
|
|
2
|
+
//
|
|
3
|
+
// Trigram similarity (PostgreSQL-style):
|
|
4
|
+
// score = |intersection(trigrams(a), trigrams(b))| / |union(trigrams(a), trigrams(b))|
|
|
5
|
+
// Plus a bonus for exact substring containment so "Order" still ranks OrderEntry high.
|
|
6
|
+
|
|
7
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
8
|
+
|
|
9
|
+
// envId → { types: string[], fetchedAt: number, promise?: Promise }
|
|
10
|
+
const cache = new Map();
|
|
11
|
+
|
|
12
|
+
// ─── Trigram helpers ──────────────────────────────────────────────────────────
|
|
13
|
+
function trigrams(str) {
|
|
14
|
+
// Pad with two spaces on each side (standard PostgreSQL padding)
|
|
15
|
+
const s = ` ${str.toLowerCase()} `;
|
|
16
|
+
const set = new Set();
|
|
17
|
+
for (let i = 0; i < s.length - 2; i++) set.add(s.slice(i, i + 3));
|
|
18
|
+
return set;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function trigramSimilarity(a, b) {
|
|
22
|
+
const ta = trigrams(a);
|
|
23
|
+
const tb = trigrams(b);
|
|
24
|
+
let inter = 0;
|
|
25
|
+
for (const t of ta) if (tb.has(t)) inter++;
|
|
26
|
+
const union = ta.size + tb.size - inter;
|
|
27
|
+
return union === 0 ? 0 : inter / union;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Scoring: trigram + substring bonus ──────────────────────────────────────
|
|
31
|
+
function score(query, candidate) {
|
|
32
|
+
const q = query.toLowerCase();
|
|
33
|
+
const c = candidate.toLowerCase();
|
|
34
|
+
let s = trigramSimilarity(q, c);
|
|
35
|
+
// Exact substring match bonus (like LIKE '%q%' but boosted)
|
|
36
|
+
if (c.includes(q)) s += 0.3;
|
|
37
|
+
// Prefix match bonus
|
|
38
|
+
if (c.startsWith(q)) s += 0.2;
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Populate (or refresh) the index for an environment.
|
|
46
|
+
* flexSearchFn: (query, opts?) => Promise<{ resultList, exception }>
|
|
47
|
+
*/
|
|
48
|
+
export async function refreshIndex(envId, flexSearchFn) {
|
|
49
|
+
const result = await flexSearchFn(
|
|
50
|
+
`SELECT {code} FROM {ComposedType} ORDER BY {code} ASC`,
|
|
51
|
+
{ maxCount: 5000 }
|
|
52
|
+
);
|
|
53
|
+
if (result.exception) throw new Error(result.exception.message || JSON.stringify(result.exception));
|
|
54
|
+
const types = (result.resultList || []).map(([code]) => code);
|
|
55
|
+
cache.set(envId, { types, fetchedAt: Date.now() });
|
|
56
|
+
return types;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the cached index, fetching if missing or stale.
|
|
61
|
+
* flexSearchFn: bound to the env's session
|
|
62
|
+
*/
|
|
63
|
+
export async function getIndex(envId, flexSearchFn) {
|
|
64
|
+
const entry = cache.get(envId);
|
|
65
|
+
if (entry && !entry.promise && Date.now() - entry.fetchedAt < CACHE_TTL_MS) {
|
|
66
|
+
return entry.types;
|
|
67
|
+
}
|
|
68
|
+
// Deduplicate concurrent fetches
|
|
69
|
+
if (entry?.promise) return entry.promise;
|
|
70
|
+
const promise = refreshIndex(envId, flexSearchFn)
|
|
71
|
+
.finally(() => { if (cache.get(envId)?.promise) delete cache.get(envId).promise; });
|
|
72
|
+
cache.set(envId, { ...(entry || { types: [], fetchedAt: 0 }), promise });
|
|
73
|
+
return promise;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Invalidate an env's cache (call after schema changes or on demand). */
|
|
77
|
+
export function invalidateIndex(envId) {
|
|
78
|
+
cache.delete(envId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fuzzy search the index.
|
|
83
|
+
* Returns up to `topN` results with score > threshold, sorted by score desc.
|
|
84
|
+
*/
|
|
85
|
+
export function fuzzySearch(query, types, { topN = 20, threshold = 0.1 } = {}) {
|
|
86
|
+
const scored = types
|
|
87
|
+
.map(t => ({ type: t, score: score(query, t) }))
|
|
88
|
+
.filter(x => x.score > threshold)
|
|
89
|
+
.sort((a, b) => b.score - a.score)
|
|
90
|
+
.slice(0, topN);
|
|
91
|
+
return scored.map(x => x.type);
|
|
92
|
+
}
|