isc-transforms-mcp 1.0.20 → 1.0.22

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "sailpoint.isc.transforms.concat.schema.json",
4
4
  "title": "SailPoint ISC Transform Schema - concat",
5
- "description": "Strict schema derived from the SailPoint official Concatenation operation documentation. Concatenates an array of values (static strings or nested transform objects) into a single string output.",
5
+ "description": "Strict schema for the SailPoint ISC concat (Concatenation) transform. Joins an ordered array of values (static strings or nested transform outputs) into a single combined string. Key behaviors: (1) Items are joined in order with NO automatic separator — spaces, hyphens, or other delimiters must be added as explicit string entries in the array. (2) Each array entry can be a static string literal or a nested transform object whose output string is used. (3) A single-entry values array is valid JSON but semantically pointless — use the nested transform directly in that case.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
@@ -12,16 +12,18 @@
12
12
  ],
13
13
  "properties": {
14
14
  "type": {
15
- "const": "concat"
15
+ "const": "concat",
16
+ "description": "Transform operation type. Must be exactly 'concat'."
16
17
  },
17
18
  "name": {
18
19
  "type": "string",
19
- "minLength": 1
20
+ "minLength": 1,
21
+ "description": "Display name for this transform, shown in UI dropdowns and identity profile mappings."
20
22
  },
21
23
  "requiresPeriodicRefresh": {
22
24
  "type": "boolean",
23
25
  "default": false,
24
- "description": "Whether the transform logic should be reevaluated nightly as part of identity refresh. Default false."
26
+ "description": "If true, re-evaluates this transform during the nightly identity refresh cycle. Default is false."
25
27
  },
26
28
  "attributes": {
27
29
  "type": "object",
@@ -29,15 +31,17 @@
29
31
  "required": [
30
32
  "values"
31
33
  ],
34
+ "description": "Required container for concat operation attributes. The only valid attribute is 'values'.",
32
35
  "properties": {
33
36
  "values": {
34
37
  "type": "array",
35
- "minItems": 2,
36
- "description": "Array of items to join. Each item must be a static string or a nested transform object.",
38
+ "minItems": 1,
39
+ "description": "Required. Ordered array of items to join into a single output string. Items are concatenated in sequence with no automatic separator. Rules: (1) Each entry must be a static string literal or a nested transform object {type, attributes} whose output is used. (2) To include spaces, hyphens, or any other separator, add them as explicit string entries between the value entries. (3) A single-entry array is valid but pointless — the nested transform can be used directly. Examples: [firstName, ' ', lastName] → 'Jane Doe'; [jobTitle, ' - ', jobCode] → 'Engineer - ENG001'.",
37
40
  "items": {
38
- "oneOf": [
41
+ "anyOf": [
39
42
  {
40
- "type": "string"
43
+ "type": "string",
44
+ "description": "Static string literal included as-is in the output (e.g., ' ', ' - ', ' (Contractor)')."
41
45
  },
42
46
  {
43
47
  "$ref": "#/$defs/NestedTransform"
@@ -51,7 +55,7 @@
51
55
  "$defs": {
52
56
  "NestedTransform": {
53
57
  "type": "object",
54
- "description": "Nested transform object used inside other transforms (docs examples often omit 'name' on nested transforms). Operation-specific validation should be applied by the referenced operation schema.",
58
+ "description": "A nested transform object used as one element in the values array. Its output string is inserted at the corresponding position in the concatenated result. The 'name' field is optional in nested transforms per SailPoint docs.",
55
59
  "additionalProperties": false,
56
60
  "required": [
57
61
  "type",
@@ -59,18 +63,22 @@
59
63
  ],
60
64
  "properties": {
61
65
  "id": {
62
- "type": "string"
66
+ "type": "string",
67
+ "description": "Optional ID when referencing an existing saved transform."
63
68
  },
64
69
  "name": {
65
70
  "type": "string",
66
- "minLength": 1
71
+ "minLength": 1,
72
+ "description": "Optional display name for the nested transform."
67
73
  },
68
74
  "type": {
69
75
  "type": "string",
70
- "minLength": 1
76
+ "minLength": 1,
77
+ "description": "The operation type of the nested transform (e.g., 'accountAttribute', 'identityAttribute', 'static', 'lower', 'trim')."
71
78
  },
72
79
  "requiresPeriodicRefresh": {
73
- "type": "boolean"
80
+ "type": "boolean",
81
+ "description": "Whether this nested transform re-evaluates during nightly refresh."
74
82
  },
75
83
  "attributes": {
76
84
  "type": "object",
@@ -82,8 +90,54 @@
82
90
  },
83
91
  "examples": [
84
92
  {
93
+ "name": "Full Name from HR Source",
94
+ "type": "concat",
95
+ "attributes": {
96
+ "values": [
97
+ {
98
+ "type": "accountAttribute",
99
+ "attributes": {
100
+ "sourceName": "HR Source",
101
+ "attributeName": "FirstName"
102
+ }
103
+ },
104
+ " ",
105
+ {
106
+ "type": "accountAttribute",
107
+ "attributes": {
108
+ "sourceName": "HR Source",
109
+ "attributeName": "LastName"
110
+ }
111
+ }
112
+ ]
113
+ }
114
+ },
115
+ {
116
+ "name": "Job Title with Code",
117
+ "type": "concat",
118
+ "attributes": {
119
+ "values": [
120
+ {
121
+ "type": "accountAttribute",
122
+ "attributes": {
123
+ "sourceName": "HR Source",
124
+ "attributeName": "JobTitle"
125
+ }
126
+ },
127
+ " - ",
128
+ {
129
+ "type": "accountAttribute",
130
+ "attributes": {
131
+ "sourceName": "HR Source",
132
+ "attributeName": "JobCode"
133
+ }
134
+ }
135
+ ]
136
+ }
137
+ },
138
+ {
139
+ "name": "Contractor Display Name",
85
140
  "type": "concat",
86
- "name": "Test Concat Transform",
87
141
  "attributes": {
88
142
  "values": [
89
143
  {
@@ -104,6 +158,38 @@
104
158
  " (Contractor)"
105
159
  ]
106
160
  }
161
+ },
162
+ {
163
+ "name": "Lowercase Email from Identity Attributes",
164
+ "type": "concat",
165
+ "attributes": {
166
+ "values": [
167
+ {
168
+ "type": "lower",
169
+ "attributes": {
170
+ "input": {
171
+ "type": "identityAttribute",
172
+ "attributes": {
173
+ "name": "firstname"
174
+ }
175
+ }
176
+ }
177
+ },
178
+ ".",
179
+ {
180
+ "type": "lower",
181
+ "attributes": {
182
+ "input": {
183
+ "type": "identityAttribute",
184
+ "attributes": {
185
+ "name": "lastname"
186
+ }
187
+ }
188
+ }
189
+ },
190
+ "@example.com"
191
+ ]
192
+ }
107
193
  }
108
194
  ]
109
195
  }
@@ -18,7 +18,7 @@ const ALLOWED_ATTRS = {
18
18
  ]),
19
19
  base64Decode: new Set(["input"]),
20
20
  base64Encode: new Set(["input"]),
21
- concat: new Set(["values", "input"]),
21
+ concat: new Set(["values"]),
22
22
  conditional: "open", // dynamic variable keys allowed per docs
23
23
  dateCompare: new Set(["firstDate", "secondDate", "operator", "positiveCondition", "negativeCondition"]),
24
24
  dateFormat: new Set(["input", "inputFormat", "outputFormat"]),
@@ -129,7 +129,8 @@ function checkRequired(spec, attrs) {
129
129
  continue;
130
130
  }
131
131
  const val = attrs?.[req];
132
- if (val === undefined || val === null || (typeof val === "string" && val.length === 0)) {
132
+ // Empty string "" is intentional for fields like negativeCondition, positiveCondition, static.value
133
+ if (val === undefined || val === null) {
133
134
  push(msgs, "error", `Missing required attribute: ${req}.`, `attributes.${req}`);
134
135
  }
135
136
  }
@@ -276,25 +277,30 @@ function lintConditional(attrs) {
276
277
  return msgs;
277
278
  }
278
279
  // --- 2. Forbidden operators (using any of these throws IllegalArgumentException at runtime) ---
280
+ // If a forbidden operator is found, that is the root cause. Skip rules 3 and 4 to avoid
281
+ // duplicate errors (they would fire as consequences of the forbidden operator, not separate issues).
279
282
  const forbidden = /(!=|==|>=|<=|>|<|\bne\b|\bgt\b|\blt\b|\bge\b|\ble\b)/i;
280
- if (forbidden.test(expr)) {
281
- push(msgs, "error", `Unsupported operator in expression '${expr}'. ` +
282
- "Only 'eq' is supported using !=, ==, >, <, ne, gt, lt, ge, le throws IllegalArgumentException at runtime.", "attributes.expression");
283
+ const forbiddenOpFound = forbidden.test(expr);
284
+ if (forbiddenOpFound) {
285
+ push(msgs, "error", `Unsupported operator in expression: '${expr}'. Only 'eq' comparator is supported ` +
286
+ "(e.g., '$var eq value'). Using !=, ==, >, <, ne, gt, lt, ge, le throws IllegalArgumentException at runtime.", "attributes.expression");
283
287
  }
284
- // --- 3. Must contain exactly one 'eq' ---
288
+ // --- 3. Must contain exactly one 'eq' (skip if forbidden operator already diagnosed) ---
285
289
  const eqMatches = expr.match(/\beq\b/gi) ?? [];
286
- if (eqMatches.length === 0) {
287
- push(msgs, "error", `Conditional expression must use the 'eq' comparator: '<ValueA> eq <ValueB>'. Got: '${expr}'.`, "attributes.expression");
288
- }
289
- else if (eqMatches.length > 1) {
290
- push(msgs, "error", `Expression must contain exactly one 'eq'. Found ${eqMatches.length} occurrences in: '${expr}'. ` +
291
- "Nest multiple conditions using separate conditional transforms if needed.", "attributes.expression");
290
+ if (!forbiddenOpFound) {
291
+ if (eqMatches.length === 0) {
292
+ push(msgs, "error", `Conditional expression must use the 'eq' comparator: '<ValueA> eq <ValueB>'. Got: '${expr}'.`, "attributes.expression");
293
+ }
294
+ else if (eqMatches.length > 1) {
295
+ push(msgs, "error", `Expression must contain exactly one 'eq'. Found ${eqMatches.length} occurrences in: '${expr}'. ` +
296
+ "Nest multiple conditions using separate conditional transforms if needed.", "attributes.expression");
297
+ }
292
298
  }
293
- // --- 4. Both sides of 'eq' must be non-empty ---
299
+ // --- 4. Both sides of 'eq' must be non-empty (skip if forbidden operator already diagnosed) ---
294
300
  const parts = expr.split(/\beq\b/i);
295
301
  const valueA = parts[0]?.trim() ?? "";
296
302
  const valueB = parts[1]?.trim() ?? "";
297
- if (parts.length !== 2 || valueA.length === 0 || valueB.length === 0) {
303
+ if (!forbiddenOpFound && (parts.length !== 2 || valueA.length === 0 || valueB.length === 0)) {
298
304
  push(msgs, "error", `Expression must follow '<ValueA> eq <ValueB>' with non-empty values on both sides. Got: '${expr}'.`, "attributes.expression");
299
305
  }
300
306
  // --- 5. Case-sensitivity info for literal operands ---
@@ -839,6 +845,13 @@ function lintDateFormat(attrs) {
839
845
  "(expected tokens like y, M, d, H, h, m, s, S, Z). " +
840
846
  `Valid named formats: ${Array.from(NAMED_FORMATS).join(", ")}.`, `attributes.${field}`);
841
847
  }
848
+ // Warn on uppercase Y (week-year) — a very common Java SimpleDateFormat mistake.
849
+ // YYYY = ISO 8601 week-based year (e.g. 2023-12-30 could become 2024); yyyy = calendar year.
850
+ if (/Y/.test(t) && !/^[A-Z][A-Z0-9_]+$/.test(t)) {
851
+ push(msgs, "warn", `${field} '${t}' contains uppercase 'Y' which is the ISO 8601 week-year in Java SimpleDateFormat, NOT the calendar year. ` +
852
+ "This is a common mistake: 'YYYY' for a December date can silently produce the following year's number. " +
853
+ "Use lowercase 'yyyy' for the calendar year (e.g., 'dd-MM-yyyy' instead of 'dd-MM-YYYY').", `attributes.${field}`);
854
+ }
842
855
  };
843
856
  checkFmt("inputFormat");
844
857
  checkFmt("outputFormat");
@@ -1313,9 +1326,20 @@ function lintStatic(attrs) {
1313
1326
  while ((m = refPattern.exec(value)) !== null) {
1314
1327
  vtlRefs.add(m[1]);
1315
1328
  }
1329
+ // 3a. Detect VTL-local variables assigned via #set($var = ...) — these are internal
1330
+ // VTL temporaries, NOT dynamic attributes that need to be defined in the attributes object.
1331
+ const vtlLocalVars = new Set();
1332
+ const setPattern = /#set\(\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*[=\s]/g;
1333
+ let sm;
1334
+ while ((sm = setPattern.exec(value)) !== null) {
1335
+ vtlLocalVars.add(sm[1]);
1336
+ }
1316
1337
  if (vtlRefs.size > 0) {
1317
- // 4. Warn for each VTL reference that has no matching dynamic variable in attributes
1338
+ // 4. Warn for each VTL reference that has no matching dynamic variable in attributes.
1339
+ // Skip VTL-local variables (those assigned with #set inside the template itself).
1318
1340
  for (const varName of vtlRefs) {
1341
+ if (vtlLocalVars.has(varName))
1342
+ continue; // #set local — not an attribute
1319
1343
  if (!Object.prototype.hasOwnProperty.call(attrs, varName)) {
1320
1344
  push(msgs, "warn", `VTL references $${varName} in value but no dynamic variable '${varName}' is defined in attributes. ` +
1321
1345
  `Add a '${varName}' key to attributes with a static string or nested transform that supplies the value.`, `attributes.${varName}`);
@@ -1412,6 +1436,63 @@ function lintRfc5646(attrs) {
1412
1436
  return msgs;
1413
1437
  }
1414
1438
  // ---------------------------------------------------------------------------
1439
+ // lintConcat — concat (Concatenation)
1440
+ // Docs: https://developer.sailpoint.com/docs/extensibility/transforms/operations/concatenation
1441
+ // Joins an ordered array of strings / nested-transform outputs into one string.
1442
+ // No automatic separators — spaces, hyphens, etc. must be explicit array entries.
1443
+ // ---------------------------------------------------------------------------
1444
+ function lintConcat(attrs) {
1445
+ const msgs = [];
1446
+ const values = attrs?.values;
1447
+ // --- 1. values is the only attribute and is required ---
1448
+ if (values === undefined || values === null) {
1449
+ push(msgs, "error", "values is required for concat. Provide an ordered array of strings and/or nested transform objects " +
1450
+ "whose outputs will be joined into a single string.", "attributes.values");
1451
+ return msgs;
1452
+ }
1453
+ if (!Array.isArray(values)) {
1454
+ push(msgs, "error", "values must be an array of strings and/or nested transform objects.", "attributes.values");
1455
+ return msgs;
1456
+ }
1457
+ if (values.length === 0) {
1458
+ push(msgs, "error", "values array must not be empty. Provide at least one string or nested transform.", "attributes.values");
1459
+ return msgs;
1460
+ }
1461
+ // --- 2. Warn if only a single entry — concat is pointless with one value ---
1462
+ if (values.length === 1) {
1463
+ push(msgs, "warn", "values array has only one entry. concat is designed to join multiple values — " +
1464
+ "consider using the nested transform directly instead of wrapping it in a concat.", "attributes.values");
1465
+ }
1466
+ // --- 3. Validate each item: must be string or a nested transform object ---
1467
+ values.forEach((item, idx) => {
1468
+ if (item === null || item === undefined) {
1469
+ push(msgs, "warn", `values[${idx}] is null/undefined — this will produce the string "null"/"undefined" in the output. ` +
1470
+ "Remove it or replace with a static string or a nested transform.", `attributes.values[${idx}]`);
1471
+ }
1472
+ else if (typeof item === "string") {
1473
+ // Valid — static strings (including spaces, separators, literals) are expected
1474
+ }
1475
+ else if (isPlainObject(item)) {
1476
+ if (typeof item.type !== "string" || item.type.trim() === "") {
1477
+ push(msgs, "error", `values[${idx}] is an object but is missing a 'type' field — it does not look like a valid nested transform. ` +
1478
+ "Add a 'type' (e.g., 'accountAttribute', 'identityAttribute', 'static').", `attributes.values[${idx}]`);
1479
+ }
1480
+ }
1481
+ else {
1482
+ push(msgs, "error", `values[${idx}] must be a string or a nested transform object {type, attributes}. ` +
1483
+ `Got: ${typeof item}.`, `attributes.values[${idx}]`);
1484
+ }
1485
+ });
1486
+ // --- 4. Separator hint: inform if no explicit spacing string found between transform objects ---
1487
+ const allTransforms = values.every((v) => isPlainObject(v));
1488
+ if (allTransforms && values.length > 1) {
1489
+ push(msgs, "info", "concat does not insert any separator between values automatically. " +
1490
+ "If the output needs spaces, hyphens, or other delimiters, add them as explicit string entries " +
1491
+ "in the values array (e.g., [firstName, \" \", lastName]).", "attributes.values");
1492
+ }
1493
+ return msgs;
1494
+ }
1495
+ // ---------------------------------------------------------------------------
1415
1496
  // Main lintTransform export
1416
1497
  // ---------------------------------------------------------------------------
1417
1498
  export function lintTransform(input) {
@@ -1502,6 +1583,8 @@ export function lintTransform(input) {
1502
1583
  messages.push(...lintRandom(requestedType, attrs));
1503
1584
  if (requestedType === "rfc5646")
1504
1585
  messages.push(...lintRfc5646(attrs));
1586
+ if (requestedType === "concat")
1587
+ messages.push(...lintConcat(attrs));
1505
1588
  // --- Recursive nested transform lint ---
1506
1589
  // Recursively lint every nested transform found inside attributes.
1507
1590
  // We start from normalized.attributes (not the root) to avoid double-linting root.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "isc-transforms-mcp",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "type": "module",
5
5
  "description": "MCP server for SailPoint Identity Security Cloud (ISC) Transform authoring — scaffold, strict lint, catalog, and safe upsert to live tenants.",
6
6
  "author": {