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.
@@ -0,0 +1,107 @@
1
+ // Shared runtime context injected into every tool registration.
2
+ // server.js creates one instance and passes it to each tool's register().
3
+
4
+ import { AsyncLocalStorage } from 'async_hooks';
5
+ import { login, SessionExpiredError } from '../hac.js';
6
+ import { getEnvironment, listEnvironments } from '../storage.js';
7
+ import { getIndex, invalidateIndex, fuzzySearch } from '../type-index.js';
8
+ import { flexibleSearch } from '../hac.js';
9
+
10
+ // ─── HAC session manager ──────────────────────────────────────────────────────
11
+ const sessions = new Map(); // envId → session
12
+ const loginLock = new Map(); // envId → Promise<session>
13
+
14
+ async function getSession(env) {
15
+ if (sessions.has(env.id)) return sessions.get(env.id);
16
+ if (loginLock.has(env.id)) return loginLock.get(env.id);
17
+ const promise = login(env.url, env.username, env.password, env.name)
18
+ .then(session => { sessions.set(env.id, session); return session; })
19
+ .finally(() => loginLock.delete(env.id));
20
+ loginLock.set(env.id, promise);
21
+ return promise;
22
+ }
23
+
24
+ function invalidateSession(envId) {
25
+ sessions.delete(envId);
26
+ invalidateIndex(envId);
27
+ }
28
+
29
+ async function withSession(env, fn) {
30
+ const session = await getSession(env);
31
+ try {
32
+ return await fn(session);
33
+ } catch (e) {
34
+ if (e instanceof SessionExpiredError) {
35
+ console.error(`[MCP] Session expired for "${env.name}", re-logging in…`);
36
+ invalidateSession(env.id);
37
+ const fresh = await getSession(env);
38
+ return fn(fresh);
39
+ }
40
+ throw e;
41
+ }
42
+ }
43
+
44
+ // ─── MCP activity log ─────────────────────────────────────────────────────────
45
+ const logClients = new Set();
46
+ const mcpLogBuffer = [];
47
+ const callCtx = new AsyncLocalStorage();
48
+
49
+ function broadcastLog(entry) {
50
+ const ctx = callCtx.getStore();
51
+ const enriched = ctx ? { ...entry, client: ctx.client } : entry;
52
+ mcpLogBuffer.push(enriched);
53
+ if (mcpLogBuffer.length > 50) mcpLogBuffer.shift();
54
+ const data = `data: ${JSON.stringify(enriched)}\n\n`;
55
+ for (const res of logClients) res.write(data);
56
+ }
57
+
58
+ function mcpLogSystem({ client, preview, detail = '' }) {
59
+ broadcastLog({ id: null, tool: null, client, preview, detail, status: 'system', ts: Date.now() });
60
+ }
61
+
62
+ function mcpLogStart({ tool, envName, preview }) {
63
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2);
64
+ broadcastLog({ id, tool, envName, preview, status: 'running', ts: Date.now() });
65
+ return id;
66
+ }
67
+
68
+ function mcpLog({ tool, envName, preview, detail = '', isError = false, runId = null }) {
69
+ broadcastLog({ id: runId, tool, envName, preview, detail, isError, status: 'done', ts: Date.now() });
70
+ console.error(`[MCP] ${tool}${envName ? ' / ' + envName : ''} - ${preview}`);
71
+ }
72
+
73
+ function attachLogClient(res) { logClients.add(res); }
74
+ function detachLogClient(res) { logClients.delete(res); }
75
+ function getMcpLogBuffer() { return mcpLogBuffer; }
76
+
77
+ // ─── Type index ───────────────────────────────────────────────────────────────
78
+ async function getTypeIndex(env) {
79
+ return getIndex(env.id, (query, opts) => withSession(env, s => flexibleSearch(s, query, opts)));
80
+ }
81
+
82
+ // ─── Shared utilities ─────────────────────────────────────────────────────────
83
+ // FlexibleSearch returns booleans as true/false, 1/0, or 'true'/'false' strings
84
+ const isTruthy = v => v === true || v === 'true' || v === 1 || v === '1';
85
+
86
+ // ─── Response helpers ─────────────────────────────────────────────────────────
87
+ function text(t) { return { content: [{ type: 'text', text: t }] }; }
88
+ function error(msg) { return { content: [{ type: 'text', text: `**Error:** ${msg}` }], isError: true }; }
89
+
90
+ export {
91
+ callCtx,
92
+ isTruthy,
93
+ getSession,
94
+ withSession,
95
+ getEnvironment,
96
+ listEnvironments,
97
+ fuzzySearch,
98
+ getTypeIndex,
99
+ mcpLogStart,
100
+ mcpLog,
101
+ attachLogClient,
102
+ detachLogClient,
103
+ getMcpLogBuffer,
104
+ mcpLogSystem,
105
+ text,
106
+ error,
107
+ };
@@ -0,0 +1,161 @@
1
+ import { z } from 'zod';
2
+ import { flexibleSearch } from '../hac.js';
3
+ import { withSession, getEnvironment, getTypeIndex, fuzzySearch, isTruthy, mcpLogStart, mcpLog, text, error } from './context.js';
4
+ import { optionalLooseNumber } from './zodLoose.js';
5
+
6
+ const TOOL = 'flexible_search';
7
+
8
+ function parseFlexSearchError(msg) {
9
+ if (!msg?.includes('cannot search unknown field')) return null;
10
+ const unknownField = msg.match(/TableField\(name='([^']+)'/)?.[1];
11
+ const typeCode = msg.match(/within type (\w+)/)?.[1];
12
+ const parseSection = str => str ? [...str.matchAll(/^\s{6}(\w+)\s*=/gm)].map(m => m[1]) : [];
13
+ const core = parseSection(msg.match(/core fields\s*=\s*\n([\s\S]*?)(?=\n\s{3}\w+ fields)/)?.[1]);
14
+ const unlocalized = parseSection(msg.match(/unlocalized fields\s*=\s*\n([\s\S]*?)(?=\n\s{3}\w+ fields)/)?.[1]);
15
+ const localized = parseSection(msg.match(/localized fields\s*=\s*\n([\s\S]*?)(?=\n\))/)?.[1]);
16
+ const allFields = [...core, ...unlocalized, ...localized];
17
+ if (!typeCode || !allFields.length) return null;
18
+ return { unknownField, typeCode, allFields };
19
+ }
20
+
21
+ function parseUnknownTypeError(msg) {
22
+ if (!msg) return null;
23
+ const m = msg.match(/unknown type[:\s]+'?(\w+)'?/i) || msg.match(/[Tt]he type '(\w+)' is unknown/);
24
+ return m?.[1] ?? null;
25
+ }
26
+
27
+ export async function fetchScalarFields(env, typeCode) {
28
+ try {
29
+ const typeResult = await withSession(env, s => flexibleSearch(s,
30
+ `SELECT {pk} FROM {ComposedType} WHERE {code} = '${typeCode}'`
31
+ ));
32
+ const typePK = typeResult.resultList?.[0]?.[0];
33
+ if (!typePK) return null;
34
+
35
+ const attrResult = await withSession(env, s => flexibleSearch(s,
36
+ `SELECT {qualifier}, {databasecolumn}, {attributetype}, {unique} FROM {AttributeDescriptor} WHERE {enclosingtype} = '${typePK}' ORDER BY {qualifier} ASC`,
37
+ { maxCount: 300 }
38
+ ));
39
+ if (!attrResult.resultList?.length) return null;
40
+
41
+ const scalar = attrResult.resultList.filter(([, dbCol]) => dbCol);
42
+ const attrTypePKs = [...new Set(scalar.map(([,, attrTypePK]) => attrTypePK).filter(Boolean))];
43
+ const refTypes = {};
44
+ if (attrTypePKs.length) {
45
+ const conds = attrTypePKs.map(p => `{pk} = '${p}'`).join(' OR ');
46
+ const r = await withSession(env, s => flexibleSearch(s,
47
+ `SELECT {pk}, {code} FROM {ComposedType} WHERE ${conds}`
48
+ ));
49
+ if (r.resultList) for (const [tpk, tcode] of r.resultList) refTypes[String(tpk)] = tcode;
50
+ }
51
+
52
+ return scalar.map(([q,, attrTypePK, isUnique]) => {
53
+ const refType = attrTypePK ? refTypes[String(attrTypePK)] : null;
54
+ let s = q;
55
+ if (isTruthy(isUnique)) s += ' [unique]';
56
+ if (refType) s += ` → ${refType}`;
57
+ return s;
58
+ }).join(', ');
59
+ } catch (_) { return null; }
60
+ }
61
+
62
+ export const tool = {
63
+ name: TOOL,
64
+ category: 'read',
65
+ description: 'Execute a FlexibleSearch query on a HAC environment. Call list_environments first to get valid IDs. IMPORTANT: The database is MSSQL - do NOT use LIMIT, TOP, or OFFSET in queries; use the maxCount parameter to limit rows instead.',
66
+ inputSchema: {
67
+ environmentId: z.string().describe('Environment ID from list_environments'),
68
+ query: z.string().describe('FlexibleSearch query, e.g. SELECT {pk}, {uid} FROM {User}'),
69
+ maxCount: optionalLooseNumber().describe('Max rows to return (default 200)'),
70
+ locale: z.string().optional().describe('Locale (default en)'),
71
+ dataSource: z.string().optional().describe('Data source (default master)'),
72
+ },
73
+ handler: async ({ environmentId, query, maxCount, locale, dataSource }) => {
74
+ const env = await getEnvironment(environmentId);
75
+ if (!env) {
76
+ mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
77
+ return error(`Environment "${environmentId}" not found.`);
78
+ }
79
+ if (!env.allowFlexSearch) {
80
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'FlexSearch disabled', isError: true });
81
+ return error(`FlexibleSearch is disabled for environment "${env.name}".`);
82
+ }
83
+
84
+ const preview = `${query.slice(0, 60)}${query.length > 60 ? '…' : ''}`;
85
+ const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview });
86
+
87
+ const rowLimitMatch = query.match(/\b(LIMIT|TOP|ROWNUM|FETCH\s+FIRST|ROWS\s+ONLY)\b/i);
88
+ if (rowLimitMatch) {
89
+ const msg = `Do not use row-limiting clauses (LIMIT, TOP, ROWNUM, FETCH FIRST, ROWS ONLY) in FlexibleSearch queries - use the maxCount parameter to limit rows instead.`;
90
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'Query error', detail: msg, isError: true, runId });
91
+ return error(`Query error: ${msg}`);
92
+ }
93
+
94
+ let result;
95
+ try {
96
+ result = await withSession(env, s => flexibleSearch(s, query, { maxCount, locale, dataSource }));
97
+ } catch (e) {
98
+ mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, detail: e.stack || '', isError: true, runId });
99
+ return error(e.message);
100
+ }
101
+
102
+ if (result.exception) {
103
+ const ex = result.exception;
104
+ const msg = ex.message || ex.localizedMessage || JSON.stringify(ex);
105
+ const causeMsg = ex.cause?.message;
106
+ const rawDetail = causeMsg && causeMsg !== msg ? `${msg}\nCaused by: ${causeMsg}` : msg;
107
+
108
+ const parsed = parseFlexSearchError(causeMsg) || parseFlexSearchError(msg);
109
+ if (parsed) {
110
+ const { unknownField, typeCode: parsedTypeCode } = parsed;
111
+ let detail = `Unknown field "{${unknownField}}" on type ${parsedTypeCode}.`;
112
+ const scalarFields = await fetchScalarFields(env, parsedTypeCode);
113
+ if (scalarFields) {
114
+ detail += `\n\nValid scalar fields for ${parsedTypeCode}:\n ${scalarFields}\n\nFor relation/collection fields use get_type_info.`;
115
+ } else {
116
+ detail += `\n\nTip: use get_type_info with typeCode "${parsedTypeCode}" to see valid fields.`;
117
+ }
118
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'Query error', detail, isError: true, runId });
119
+ return error(`Query error: ${detail}`);
120
+ }
121
+
122
+ const unknownType = parseUnknownTypeError(causeMsg) || parseUnknownTypeError(msg);
123
+ if (unknownType) {
124
+ let detail = `Unknown type "${unknownType}".`;
125
+ try {
126
+ const types = await getTypeIndex(env);
127
+ const suggestions = fuzzySearch(unknownType, types, { topN: 5 });
128
+ if (suggestions.length) detail += ` Did you mean: ${suggestions.join(', ')}?`;
129
+ } catch (_) {}
130
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'Query error', detail, isError: true, runId });
131
+ return error(`Query error: ${detail}`);
132
+ }
133
+
134
+
135
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'Query error', detail: rawDetail, isError: true, runId });
136
+ return error(`Query error: ${rawDetail}`);
137
+ }
138
+
139
+ const { headers, resultList, resultCount, executionTime } = result;
140
+ let out = `**${env.name}** - ${resultCount} row(s) in ${executionTime}ms\n\n`;
141
+
142
+ if (resultList?.length) {
143
+ const csvCell = c => {
144
+ if (c === null) return '';
145
+ const s = String(c);
146
+ return (s.includes(',') || s.includes('"') || s.includes('\n')) ? `"${s.replace(/"/g, '""')}"` : s;
147
+ };
148
+ out += (headers || []).map(csvCell).join(',') + '\n';
149
+ for (const row of resultList) out += row.map(csvCell).join(',') + '\n';
150
+ } else {
151
+ out += 'No results.\n';
152
+ }
153
+
154
+ mcpLog({ tool: TOOL, envName: env.name,
155
+ preview: `${resultCount} row(s) in ${executionTime}ms - ${preview}`,
156
+ detail: `Query: ${query}\n\nResult:\n${out}`,
157
+ runId,
158
+ });
159
+ return text(out);
160
+ },
161
+ };
@@ -0,0 +1,188 @@
1
+ import { z } from 'zod';
2
+ import { flexibleSearch } from '../hac.js';
3
+ import { optionalLooseBool } from './zodLoose.js';
4
+ import { withSession, getEnvironment, isTruthy, mcpLogStart, mcpLog, text, error } from './context.js';
5
+
6
+ const TOOL = 'get_type_info';
7
+
8
+ export const tool = {
9
+ name: TOOL,
10
+ category: 'read',
11
+ description: 'Get metadata and queryable fields for a SAP Commerce type. Use this when a FlexibleSearch query fails with unknown field errors to discover the correct field qualifiers. The database is MSSQL - do NOT use LIMIT, TOP, or OFFSET in FlexibleSearch queries; use the maxCount parameter instead.',
12
+ inputSchema: {
13
+ environmentId: z.string().describe('Environment ID from list_environments'),
14
+ typeCode: z.string().optional().describe('Type code to look up, e.g. SolrFacetSearchConfig, Order, Product'),
15
+ type: z.string().optional().describe('Alias for typeCode if the client sends this key instead'),
16
+ includeInherited: optionalLooseBool().describe('Also include attributes inherited from supertypes (default false)'),
17
+ },
18
+ handler: async ({ environmentId, typeCode, type: typeArg, includeInherited }) => {
19
+ const env = await getEnvironment(environmentId);
20
+ if (!env) {
21
+ mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
22
+ return error(`Environment "${environmentId}" not found.`);
23
+ }
24
+
25
+ const resolvedType = typeCode ?? typeArg;
26
+ if (!resolvedType) {
27
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'Missing typeCode', isError: true });
28
+ return error('Provide typeCode (or type) with the Commerce type code.');
29
+ }
30
+
31
+ const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: `Type info: ${resolvedType}` });
32
+
33
+ let typeResult;
34
+ try {
35
+ typeResult = await withSession(env, s => flexibleSearch(s,
36
+ `SELECT {pk}, {code}, {supertype}, {jaloclass}, {inheritancepathstring}, {extensionname}, {catalogitemtype}, {singleton} FROM {ComposedType} WHERE {code} = '${resolvedType}'`
37
+ ));
38
+ } catch (e) {
39
+ mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, isError: true, runId });
40
+ return error(e.message);
41
+ }
42
+
43
+ if (typeResult.exception) {
44
+ const ex = typeResult.exception;
45
+ const msg = ex.message || ex.localizedMessage || JSON.stringify(ex);
46
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'Query error', detail: msg, isError: true, runId });
47
+ return error(msg);
48
+ }
49
+
50
+ if (!typeResult.resultList?.length) {
51
+ mcpLog({ tool: TOOL, envName: env.name, preview: `Type not found: ${resolvedType}`, isError: true, runId });
52
+ return error(`Type "${resolvedType}" not found. Check the type code (case-sensitive).`);
53
+ }
54
+
55
+ const [pk, code, supertypePK, , inheritancePath] = typeResult.resultList[0];
56
+
57
+ const typePKs = includeInherited
58
+ ? inheritancePath.split(',').filter(Boolean)
59
+ : [String(pk)];
60
+
61
+ let ancestorNames = {};
62
+ if (includeInherited && typePKs.length > 1) {
63
+ try {
64
+ const ancestorPKConditions = typePKs.map(p => `{pk} = '${p}'`).join(' OR ');
65
+ const ancestorResult = await withSession(env, s => flexibleSearch(s,
66
+ `SELECT {pk}, {code} FROM {ComposedType} WHERE ${ancestorPKConditions}`
67
+ ));
68
+ if (ancestorResult.resultList) {
69
+ for (const [apk, acode] of ancestorResult.resultList) ancestorNames[String(apk)] = acode;
70
+ }
71
+ } catch (_) {}
72
+ }
73
+ ancestorNames[String(pk)] = code;
74
+
75
+ const allAttrs = [];
76
+ for (const typePK of typePKs) {
77
+ try {
78
+ const attrResult = await withSession(env, s => flexibleSearch(s,
79
+ `SELECT {qualifier}, {databasecolumn}, {enclosingtype}, {attributetype}, {unique} FROM {AttributeDescriptor} WHERE {enclosingtype} = '${typePK}' ORDER BY {qualifier} ASC`,
80
+ { maxCount: 300 }
81
+ ));
82
+ if (attrResult.resultList) {
83
+ for (const row of attrResult.resultList) allAttrs.push(row);
84
+ }
85
+ } catch (_) {}
86
+ }
87
+
88
+ let supertypeName = null;
89
+ if (supertypePK) {
90
+ try {
91
+ const stResult = await withSession(env, s => flexibleSearch(s,
92
+ `SELECT {code} FROM {ComposedType} WHERE {pk} = '${supertypePK}'`
93
+ ));
94
+ supertypeName = stResult.resultList?.[0]?.[0] || null;
95
+ } catch (_) {}
96
+ }
97
+
98
+ const scalar = allAttrs.filter(([, dbCol]) => dbCol);
99
+ const relations = allAttrs.filter(([, dbCol]) => !dbCol);
100
+
101
+ const collTypePKs = [...new Set(relations.map(([,,,attrTypePK]) => attrTypePK).filter(Boolean))];
102
+ const elementTypeMap = {};
103
+ const composedTypeNames = {};
104
+ const collCodeMap = {};
105
+ if (collTypePKs.length) {
106
+ try {
107
+ const pkConditions = collTypePKs.map(p => `{pk} = '${p}'`).join(' OR ');
108
+ const collResult = await withSession(env, s => flexibleSearch(s,
109
+ `SELECT {pk}, {elementtype}, {code} FROM {CollectionType} WHERE ${pkConditions}`
110
+ ));
111
+ if (collResult.resultList) {
112
+ for (const [cpk, eltPK, collCode] of collResult.resultList) {
113
+ elementTypeMap[String(cpk)] = eltPK;
114
+ collCodeMap[String(cpk)] = collCode;
115
+ }
116
+ }
117
+ const eltPKs = [...new Set(Object.values(elementTypeMap).filter(Boolean))];
118
+ if (eltPKs.length) {
119
+ const eltConditions = eltPKs.map(p => `{pk} = '${p}'`).join(' OR ');
120
+ const eltResult = await withSession(env, s => flexibleSearch(s,
121
+ `SELECT {pk}, {code} FROM {ComposedType} WHERE ${eltConditions}`
122
+ ));
123
+ if (eltResult.resultList) {
124
+ for (const [epk, ecode] of eltResult.resultList) composedTypeNames[String(epk)] = ecode;
125
+ }
126
+ }
127
+ } catch (_) {}
128
+ }
129
+
130
+ const relationInfo = {};
131
+ for (const [qualifier,, , attrTypePK] of relations) {
132
+ const attrTypePKStr = String(attrTypePK);
133
+ const eltPK = elementTypeMap[attrTypePKStr];
134
+ const collCode = collCodeMap?.[attrTypePKStr];
135
+ const targetType = eltPK ? (composedTypeNames[String(eltPK)] || null) : null;
136
+ const suffix = `${qualifier}Coll`;
137
+ const linkTable = collCode?.endsWith(suffix) ? collCode.slice(0, -suffix.length) : null;
138
+ relationInfo[qualifier] = { targetType, linkTable };
139
+ }
140
+
141
+ const scalarAttrTypePKs = [...new Set(scalar.map(([,,,attrTypePK]) => attrTypePK).filter(Boolean))];
142
+ const scalarRefTypes = {};
143
+ if (scalarAttrTypePKs.length) {
144
+ try {
145
+ const conds = scalarAttrTypePKs.map(p => `{pk} = '${p}'`).join(' OR ');
146
+ const r = await withSession(env, s => flexibleSearch(s,
147
+ `SELECT {pk}, {code} FROM {ComposedType} WHERE ${conds}`
148
+ ));
149
+ if (r.resultList) for (const [tpk, tcode] of r.resultList) scalarRefTypes[String(tpk)] = tcode;
150
+ } catch (_) {}
151
+ }
152
+
153
+ let out = `Type: ${code}`;
154
+ if (supertypeName) out += ` (extends ${supertypeName})`;
155
+ out += '\n\n';
156
+
157
+ out += `Scalar fields - use directly in SELECT / WHERE / ORDER BY:\n`;
158
+ for (const [q, , encPK, attrTypePK, isUnique] of scalar) {
159
+ const inherited = includeInherited && ancestorNames[String(encPK)] && ancestorNames[String(encPK)] !== code
160
+ ? ` (from ${ancestorNames[String(encPK)]})` : '';
161
+ const refType = attrTypePK ? scalarRefTypes[String(attrTypePK)] : null;
162
+ let line = ` ${q}`;
163
+ if (isTruthy(isUnique)) line += ' [unique]';
164
+ if (refType) line += ` → ${refType}`;
165
+ if (inherited) line += inherited;
166
+ out += line + '\n';
167
+ }
168
+
169
+ out += '\nRelation/collection fields - require JOIN to query:\n';
170
+ for (const [q,,encPK] of relations) {
171
+ const { targetType, linkTable } = relationInfo[q] || {};
172
+ const inherited = includeInherited && ancestorNames[String(encPK)] && ancestorNames[String(encPK)] !== code ? ` (from ${ancestorNames[String(encPK)]})` : '';
173
+ if (targetType && linkTable) {
174
+ const alias = code.charAt(0).toLowerCase();
175
+ const tAlias = targetType.charAt(0).toLowerCase() + '2';
176
+ out += ` ${q}${inherited}: Collection<${targetType}>\n`;
177
+ out += ` JOIN: {${code} AS ${alias} JOIN ${linkTable} AS lnk ON {lnk:source}={${alias}:pk} JOIN ${targetType} AS ${tAlias} ON {lnk:target}={${tAlias}:pk}}\n`;
178
+ } else if (targetType) {
179
+ out += ` ${q}${inherited}: Collection<${targetType}>\n`;
180
+ } else {
181
+ out += ` ${q}${inherited}\n`;
182
+ }
183
+ }
184
+
185
+ mcpLog({ tool: TOOL, envName: env.name, preview: `Type info: ${code} (${allAttrs.length} attrs)`, detail: out, runId });
186
+ return text(out);
187
+ },
188
+ };
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { groovyExecute } from '../hac.js';
3
+ import { optionalLooseBool } from './zodLoose.js';
4
+ import { withSession, getEnvironment, mcpLogStart, mcpLog, text, error } from './context.js';
5
+
6
+ const TOOL = 'groovy_execute';
7
+
8
+ export const tool = {
9
+ name: TOOL,
10
+ category: 'write',
11
+ description: 'Execute a Groovy script on a HAC environment. Call list_environments first to check groovy execution is allowed. IMPORTANT: Before calling this tool, you MUST show the user the script and a clear explanation of what it does and explicitly ask for their confirmation. Only proceed after the user approves and set confirmed_by_user to true. Note: even with commit disabled, scripts can send emails, trigger SMS, or call external APIs.',
12
+ inputSchema: {
13
+ environmentId: z.string().describe('Environment ID from list_environments'),
14
+ script: z.string().describe('Groovy script content'),
15
+ commit: optionalLooseBool().describe('Whether to commit the transaction (default: false)'),
16
+ 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.'),
17
+ },
18
+ handler: async ({ environmentId, script, commit, confirmed_by_user }) => {
19
+ if (!confirmed_by_user) {
20
+ return error('User confirmation required. Show the user the script and what it does, ask for explicit approval, then retry with confirmed_by_user: true.');
21
+ }
22
+ const env = await getEnvironment(environmentId);
23
+ if (!env) {
24
+ mcpLog({ tool: TOOL, envName: environmentId, preview: 'Unknown environment', isError: true });
25
+ return error(`Environment "${environmentId}" not found.`);
26
+ }
27
+ if (!env.allowGroovyExecution) {
28
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'Groovy disabled', isError: true });
29
+ return error(`Groovy execution is disabled for environment "${env.name}".`);
30
+ }
31
+ if (commit && env.allowGroovyCommitMode === false) {
32
+ mcpLog({ tool: TOOL, envName: env.name, preview: 'Groovy commit mode disabled', isError: true });
33
+ return error(`Groovy commit mode is disabled for environment "${env.name}".`);
34
+ }
35
+
36
+ const scriptPreview = script.split('\n')[0].slice(0, 60);
37
+ const runId = mcpLogStart({ tool: TOOL, envName: env.name, preview: `${scriptPreview}…` });
38
+
39
+ let result;
40
+ try {
41
+ result = await withSession(env, s => groovyExecute(s, script, { commit }));
42
+ } catch (e) {
43
+ mcpLog({ tool: TOOL, envName: env.name, preview: `Error: ${e.message}`, detail: e.stack || '', isError: true, runId });
44
+ return error(e.message);
45
+ }
46
+
47
+ const isErr = !!result.stacktraceText;
48
+ let out = `**${env.name}** - ${isErr ? '❌ Error' : '✅ Success'}\n`;
49
+ if (result.executionResult != null) out += `\n**Result:**\n\`\`\`\n${result.executionResult}\n\`\`\``;
50
+ if (result.outputText) out += `\n**Output:**\n\`\`\`\n${result.outputText}\n\`\`\``;
51
+ if (result.stacktraceText) out += `\n**Stacktrace:**\n\`\`\`\n${result.stacktraceText}\n\`\`\``;
52
+
53
+ mcpLog({ tool: TOOL, envName: env.name,
54
+ preview: `${isErr ? '❌' : '✅'} ${scriptPreview}…`,
55
+ detail: `Script:\n${script}\n\nResult: ${result.executionResult}\n\n${result.stacktraceText || ''}`,
56
+ isError: isErr, runId,
57
+ });
58
+ return text(out);
59
+ },
60
+ };