isc-transforms-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/JSONS/authoritative-operation-catalog.json +280 -0
- package/JSONS/sailpoint.isc.transforms.accountAttribute.schema.json +164 -0
- package/JSONS/sailpoint.isc.transforms.base64Decode.schema.json +37 -0
- package/JSONS/sailpoint.isc.transforms.base64Encode.schema.json +32 -0
- package/JSONS/sailpoint.isc.transforms.concat.schema.json +109 -0
- package/JSONS/sailpoint.isc.transforms.conditional.schema.json +161 -0
- package/JSONS/sailpoint.isc.transforms.dateCompare.schema.json +159 -0
- package/JSONS/sailpoint.isc.transforms.dateFormat.schema.json +101 -0
- package/JSONS/sailpoint.isc.transforms.dateMath.schema.json +119 -0
- package/JSONS/sailpoint.isc.transforms.decomposeDiacriticalMarks.schema.json +92 -0
- package/JSONS/sailpoint.isc.transforms.displayName.schema.json +42 -0
- package/JSONS/sailpoint.isc.transforms.e164phone.schema.json +107 -0
- package/JSONS/sailpoint.isc.transforms.firstValid.schema.json +129 -0
- package/JSONS/sailpoint.isc.transforms.generateRandomString.schema.json +94 -0
- package/JSONS/sailpoint.isc.transforms.getEndOfString.schema.json +118 -0
- package/JSONS/sailpoint.isc.transforms.getReferenceIdentityAttribute.schema.json +79 -0
- package/JSONS/sailpoint.isc.transforms.identityAttribute.schema.json +104 -0
- package/JSONS/sailpoint.isc.transforms.index.schema.json +48 -0
- package/JSONS/sailpoint.isc.transforms.indexOf.schema.json +90 -0
- package/JSONS/sailpoint.isc.transforms.iso3166.schema.json +103 -0
- package/JSONS/sailpoint.isc.transforms.join.schema.json +113 -0
- package/JSONS/sailpoint.isc.transforms.lastIndexOf.schema.json +90 -0
- package/JSONS/sailpoint.isc.transforms.leftPad.schema.json +96 -0
- package/JSONS/sailpoint.isc.transforms.lookup.schema.json +100 -0
- package/JSONS/sailpoint.isc.transforms.lower.schema.json +80 -0
- package/JSONS/sailpoint.isc.transforms.normalizeNames.schema.json +79 -0
- package/JSONS/sailpoint.isc.transforms.randomAlphaNumeric.schema.json +53 -0
- package/JSONS/sailpoint.isc.transforms.randomNumeric.schema.json +53 -0
- package/JSONS/sailpoint.isc.transforms.reference.schema.json +90 -0
- package/JSONS/sailpoint.isc.transforms.replace.schema.json +96 -0
- package/JSONS/sailpoint.isc.transforms.replaceAll.schema.json +96 -0
- package/JSONS/sailpoint.isc.transforms.rfc5646.schema.json +79 -0
- package/JSONS/sailpoint.isc.transforms.rightPad.schema.json +96 -0
- package/JSONS/sailpoint.isc.transforms.rule.schema.json +106 -0
- package/JSONS/sailpoint.isc.transforms.split.schema.json +103 -0
- package/JSONS/sailpoint.isc.transforms.static.schema.json +131 -0
- package/JSONS/sailpoint.isc.transforms.substring.schema.json +167 -0
- package/JSONS/sailpoint.isc.transforms.trim.schema.json +93 -0
- package/JSONS/sailpoint.isc.transforms.upper.schema.json +80 -0
- package/JSONS/sailpoint.isc.transforms.usernameGenerator.schema.json +106 -0
- package/JSONS/sailpoint.isc.transforms.uuid.schema.json +32 -0
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/bin/isc-transforms-mcp.mjs +3 -0
- package/dist/allowlist.js +37 -0
- package/dist/config.js +67 -0
- package/dist/http/errors.js +19 -0
- package/dist/http/iscAuth.js +45 -0
- package/dist/http/iscClient.js +73 -0
- package/dist/index.js +613 -0
- package/dist/logger.js +9 -0
- package/dist/redact.js +28 -0
- package/dist/transforms/catalog.js +566 -0
- package/dist/transforms/explain.js +266 -0
- package/dist/transforms/generate.js +551 -0
- package/dist/transforms/index.js +9 -0
- package/dist/transforms/lint.js +839 -0
- package/dist/transforms/normalize.js +96 -0
- package/dist/transforms/patterns.js +295 -0
- package/dist/transforms/testcases.js +350 -0
- package/dist/transforms/validate.js +250 -0
- package/dist/util/diff.js +23 -0
- package/package.json +76 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// src/index.ts — SailPoint ISC Transforms MCP Server
|
|
2
|
+
//
|
|
3
|
+
// ┌─────────────────────────────────────────────────────────────────────────┐
|
|
4
|
+
// │ PHASE 1 — Offline transform authoring (no ISC tenant required) │
|
|
5
|
+
// │ isc.transforms.generate Requirement → transform JSON │
|
|
6
|
+
// │ isc.transforms.validate JSON Schema validation (AJV + schemas) │
|
|
7
|
+
// │ isc.transforms.lint Semantic lint (doc-aligned rules) │
|
|
8
|
+
// │ isc.transforms.explain Explain errors + auto-correct │
|
|
9
|
+
// │ isc.transforms.suggestPattern Named nested-transform patterns │
|
|
10
|
+
// │ isc.transforms.generateTestCases Illustrative test cases per operation │
|
|
11
|
+
// │ isc.transforms.catalog List all 44+ operation types │
|
|
12
|
+
// │ isc.transforms.getSchema Return the JSON Schema for an op type │
|
|
13
|
+
// │ isc.transforms.scaffold Scaffold a valid starter payload │
|
|
14
|
+
// │ isc.ping Health check │
|
|
15
|
+
// ├─────────────────────────────────────────────────────────────────────────┤
|
|
16
|
+
// │ PHASE 2 — Connected publish (requires ISC tenant credentials) │
|
|
17
|
+
// │ isc.transforms.list GET /v3/transforms │
|
|
18
|
+
// │ isc.transforms.get GET /v3/transforms/:id │
|
|
19
|
+
// │ isc.transforms.upsert Create / update with dry-run + confirm │
|
|
20
|
+
// │ isc.transforms.findReferences Scan identity profiles for transform │
|
|
21
|
+
// └─────────────────────────────────────────────────────────────────────────┘
|
|
22
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
23
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { TRANSFORM_CATALOG, listTransformTypes, lintTransform, toCanonicalType, getTransformSpec, validateTransform, generateTransform, suggestPattern, generateTestCases, explainTransformErrors, getOperationSchema, listSchemaCoverage, } from "./transforms/index.js";
|
|
26
|
+
import { loadConfig } from "./config.js";
|
|
27
|
+
import { createLogger } from "./logger.js";
|
|
28
|
+
import { redactDeep } from "./redact.js";
|
|
29
|
+
import { IScClient } from "./http/iscClient.js";
|
|
30
|
+
import { toSafeError } from "./http/errors.js";
|
|
31
|
+
import { jsonPatch } from "./util/diff.js";
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
function asText(obj) {
|
|
36
|
+
return JSON.stringify(obj, null, 2);
|
|
37
|
+
}
|
|
38
|
+
const TOOL_STYLE = (process.env.MCP_TOOL_STYLE ?? "flat").toLowerCase();
|
|
39
|
+
function tn(name) {
|
|
40
|
+
return TOOL_STYLE === "flat" ? name.replace(/[.]/g, "_") : name;
|
|
41
|
+
}
|
|
42
|
+
function summarizeLint(lint) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
const warnings = [];
|
|
45
|
+
const infos = [];
|
|
46
|
+
for (const m of lint.messages ?? []) {
|
|
47
|
+
const line = `${m.path ? m.path + ": " : ""}${m.message}`;
|
|
48
|
+
if (m.level === "error")
|
|
49
|
+
errors.push(line);
|
|
50
|
+
else if (m.level === "warn")
|
|
51
|
+
warnings.push(line);
|
|
52
|
+
else
|
|
53
|
+
infos.push(line);
|
|
54
|
+
}
|
|
55
|
+
return { errors, warnings, infos };
|
|
56
|
+
}
|
|
57
|
+
function jsonPointerEscape(s) {
|
|
58
|
+
return s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
59
|
+
}
|
|
60
|
+
function findJsonPointers(root, predicate) {
|
|
61
|
+
const hits = [];
|
|
62
|
+
const walk = (node, path, key) => {
|
|
63
|
+
if (predicate(node, key))
|
|
64
|
+
hits.push({ path, value: node });
|
|
65
|
+
if (Array.isArray(node)) {
|
|
66
|
+
for (let i = 0; i < node.length; i++)
|
|
67
|
+
walk(node[i], `${path}/${i}`, String(i));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (node && typeof node === "object") {
|
|
71
|
+
for (const [k, v] of Object.entries(node))
|
|
72
|
+
walk(v, `${path}/${jsonPointerEscape(k)}`, k);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
walk(root, "");
|
|
76
|
+
return hits;
|
|
77
|
+
}
|
|
78
|
+
function phase2Guard(cfg, toolName) {
|
|
79
|
+
// Check license tier first — must be enterprise to use Phase 2 tools
|
|
80
|
+
if (cfg.licenseTier !== "enterprise") {
|
|
81
|
+
return (`Tool '${toolName}' requires an Enterprise license. ` +
|
|
82
|
+
`Phase 2 tools (list, get, upsert, findReferences) that connect to a live ISC tenant ` +
|
|
83
|
+
`are available on the Enterprise plan. ` +
|
|
84
|
+
`Get your license key at: https://YOUR-STORE-URL (set it as ISC_MCP_LICENSE_KEY). ` +
|
|
85
|
+
`All Phase 1 tools (generate, validate, lint, explain, patterns, test-cases, catalog, schema, scaffold) ` +
|
|
86
|
+
`are free and work fully offline.`);
|
|
87
|
+
}
|
|
88
|
+
// License OK — now check ISC credentials
|
|
89
|
+
if (cfg.offline) {
|
|
90
|
+
return (`Tool '${toolName}' requires ISC tenant credentials (Phase 2). ` +
|
|
91
|
+
`Set ISC_TENANT + (ISC_PAT_CLIENT_ID & ISC_PAT_CLIENT_SECRET) or ISC_ACCESS_TOKEN ` +
|
|
92
|
+
`in your environment variables, then restart the MCP server.`);
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Server builder — shared between stdio (index.ts) and HTTP (server.ts)
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
export async function buildMcpServer(cfg) {
|
|
100
|
+
const log = createLogger(cfg.debug);
|
|
101
|
+
const isc = new IScClient(cfg);
|
|
102
|
+
const server = new McpServer({
|
|
103
|
+
name: "isc-transforms-mcp",
|
|
104
|
+
version: "1.0.0",
|
|
105
|
+
});
|
|
106
|
+
// =========================================================================
|
|
107
|
+
// PHASE 1 — Offline authoring tools (no ISC credentials needed)
|
|
108
|
+
// =========================================================================
|
|
109
|
+
// ── Health ────────────────────────────────────────────────────────────────
|
|
110
|
+
server.registerTool(tn("isc.ping"), {
|
|
111
|
+
title: "Ping",
|
|
112
|
+
description: "Basic health check. Returns 'pong' and reports whether the server is running in offline (Phase 1 only) or connected (Phase 1 + 2) mode.",
|
|
113
|
+
inputSchema: z.object({}),
|
|
114
|
+
}, async () => ({
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: asText({
|
|
119
|
+
status: "pong",
|
|
120
|
+
license: cfg.licenseTier === "enterprise" ? "Enterprise" : "Personal (Free)",
|
|
121
|
+
phase1_tools: "available",
|
|
122
|
+
phase2_tools: cfg.licenseTier === "enterprise"
|
|
123
|
+
? (cfg.offline ? "licensed but no ISC credentials set" : "available")
|
|
124
|
+
: "requires Enterprise license — see ISC_MCP_LICENSE_KEY",
|
|
125
|
+
connection: cfg.offline ? "offline (no ISC credentials)" : "connected to ISC tenant",
|
|
126
|
+
server: "isc-transforms-mcp@1.0.0",
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
}));
|
|
131
|
+
// ── 1. Generate transform from plain-English requirement ─────────────────
|
|
132
|
+
server.registerTool(tn("isc.transforms.generate"), {
|
|
133
|
+
title: "Generate Transform from Requirement",
|
|
134
|
+
description: "Converts a plain-English requirement into a SailPoint ISC transform JSON payload. " +
|
|
135
|
+
"Parses the requirement for operation keywords, entity names, date formats, and null-handling hints, " +
|
|
136
|
+
"then selects the best matching operation and builds the JSON. " +
|
|
137
|
+
"Returns the transform JSON, confidence level, alternative operations, a doc URL, " +
|
|
138
|
+
"and a list of <placeholder> fields that need real values before deployment. " +
|
|
139
|
+
"OFFLINE — no ISC tenant required.",
|
|
140
|
+
inputSchema: z.object({
|
|
141
|
+
requirement: z
|
|
142
|
+
.string()
|
|
143
|
+
.min(5)
|
|
144
|
+
.describe("Plain-English description of what the transform should do. " +
|
|
145
|
+
"Examples: 'Generate a unique username using first initial plus last name', " +
|
|
146
|
+
"'Fall back from work email to personal email', " +
|
|
147
|
+
"'Convert a Java epoch timestamp to ISO8601', " +
|
|
148
|
+
"'If department equals Engineering return Building A else Building B'."),
|
|
149
|
+
transform_name: z
|
|
150
|
+
.string()
|
|
151
|
+
.optional()
|
|
152
|
+
.describe("Optional explicit name for the transform. Auto-derived from the requirement if omitted."),
|
|
153
|
+
}),
|
|
154
|
+
}, async ({ requirement, transform_name }) => {
|
|
155
|
+
try {
|
|
156
|
+
const result = generateTransform(requirement, transform_name);
|
|
157
|
+
return { content: [{ type: "text", text: asText(result) }] };
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
return { content: [{ type: "text", text: asText({ error: e?.message ?? String(e) }) }] };
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// ── 2. Validate transform JSON (AJV + JSON Schema) ────────────────────────
|
|
164
|
+
server.registerTool(tn("isc.transforms.validate"), {
|
|
165
|
+
title: "Validate Transform JSON",
|
|
166
|
+
description: "Validates a transform JSON payload in two stages: " +
|
|
167
|
+
"(1) Against the root index schema (name/type/attributes shape). " +
|
|
168
|
+
"(2) Against the operation-specific JSON Schema from the JSONS/ schema pack. " +
|
|
169
|
+
"Returns a structured result with valid flag, per-error path+message, doc URL, and warnings. " +
|
|
170
|
+
"OFFLINE — no ISC tenant required.",
|
|
171
|
+
inputSchema: z.object({
|
|
172
|
+
transform_json: z
|
|
173
|
+
.union([z.any(), z.string()])
|
|
174
|
+
.describe("The transform JSON object or JSON string to validate."),
|
|
175
|
+
}),
|
|
176
|
+
}, async ({ transform_json }) => {
|
|
177
|
+
let payload = transform_json;
|
|
178
|
+
if (typeof payload === "string") {
|
|
179
|
+
try {
|
|
180
|
+
payload = JSON.parse(payload.trim());
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
return { content: [{ type: "text", text: asText({ valid: false, errors: [{ stage: "parse", message: `Invalid JSON: ${e?.message}` }], warnings: [] }) }] };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const result = validateTransform(payload);
|
|
187
|
+
return { content: [{ type: "text", text: asText(result) }] };
|
|
188
|
+
});
|
|
189
|
+
// ── 3. Semantic lint (doc-aligned rules) ─────────────────────────────────
|
|
190
|
+
server.registerTool(tn("isc.transforms.lint"), {
|
|
191
|
+
title: "Lint Transform Semantics",
|
|
192
|
+
description: "Runs semantic lint rules that JSON Schema cannot enforce: " +
|
|
193
|
+
"conditional expression operator (eq only), dateMath expression grammar, " +
|
|
194
|
+
"accountAttribute source uniqueness, replace regex validity, " +
|
|
195
|
+
"requiresPeriodicRefresh type, unknown top-level fields, lookup default key, " +
|
|
196
|
+
"and 25+ other doc-aligned checks. " +
|
|
197
|
+
"When strict=true (default), throws on any error so the caller must fix and retry. " +
|
|
198
|
+
"OFFLINE — no ISC tenant required.",
|
|
199
|
+
inputSchema: z.object({
|
|
200
|
+
body: z.union([z.any(), z.string()]).optional(),
|
|
201
|
+
raw: z.string().optional(),
|
|
202
|
+
strict: z.boolean().optional().default(true),
|
|
203
|
+
}),
|
|
204
|
+
}, async ({ body, raw, strict = true }) => {
|
|
205
|
+
let payload = body ?? raw;
|
|
206
|
+
if (typeof payload === "string") {
|
|
207
|
+
const t = payload.trim();
|
|
208
|
+
try {
|
|
209
|
+
payload = t ? JSON.parse(t) : null;
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
const msg = `Invalid JSON: ${e?.message}`;
|
|
213
|
+
if (strict)
|
|
214
|
+
throw new Error(msg);
|
|
215
|
+
return { content: [{ type: "text", text: asText({ ok: false, errors: [msg], warnings: [], infos: [] }) }] };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
219
|
+
const msg = "Transform must be a JSON object.";
|
|
220
|
+
if (strict)
|
|
221
|
+
throw new Error(`Lint failed.\n- ${msg}`);
|
|
222
|
+
return { content: [{ type: "text", text: asText({ ok: false, errors: [msg], warnings: [], infos: [] }) }] };
|
|
223
|
+
}
|
|
224
|
+
const res = lintTransform(payload);
|
|
225
|
+
const sum = summarizeLint(res);
|
|
226
|
+
const ok = sum.errors.length === 0;
|
|
227
|
+
if (strict && !ok)
|
|
228
|
+
throw new Error(`Transform lint failed.\n- ${sum.errors.join("\n- ")}`);
|
|
229
|
+
const out = { ok, messages: res.messages, errors: sum.errors, warnings: sum.warnings, infos: sum.infos };
|
|
230
|
+
if (ok)
|
|
231
|
+
out.normalized = res.normalized;
|
|
232
|
+
return { content: [{ type: "text", text: asText(out) }] };
|
|
233
|
+
});
|
|
234
|
+
// ── 4. Explain errors ────────────────────────────────────────────────────
|
|
235
|
+
server.registerTool(tn("isc.transforms.explain"), {
|
|
236
|
+
title: "Explain Transform Error",
|
|
237
|
+
description: "Validates the transform, translates each schema/lint error into plain-English guidance, " +
|
|
238
|
+
"and attempts to produce a corrected JSON for simple/automatable issues " +
|
|
239
|
+
"(e.g. boolean string coercion, duplicate source reference, delimiter→separator rename, " +
|
|
240
|
+
"missing lookup default key, requiresPeriodicRefresh type). " +
|
|
241
|
+
"OFFLINE — no ISC tenant required.",
|
|
242
|
+
inputSchema: z.object({
|
|
243
|
+
transform_json: z
|
|
244
|
+
.union([z.any(), z.string()])
|
|
245
|
+
.describe("The transform JSON that has errors."),
|
|
246
|
+
error_message: z
|
|
247
|
+
.string()
|
|
248
|
+
.optional()
|
|
249
|
+
.describe("Optional external error or log message from the ISC transform tester."),
|
|
250
|
+
}),
|
|
251
|
+
}, async ({ transform_json, error_message }) => {
|
|
252
|
+
let payload = transform_json;
|
|
253
|
+
if (typeof payload === "string") {
|
|
254
|
+
try {
|
|
255
|
+
payload = JSON.parse(payload.trim());
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
return { content: [{ type: "text", text: asText({ explanation: `Invalid JSON: ${e?.message}`, issues: [], corrected_json: null }) }] };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const result = explainTransformErrors(payload, error_message);
|
|
262
|
+
return { content: [{ type: "text", text: asText(result) }] };
|
|
263
|
+
});
|
|
264
|
+
// ── 5. Suggest nested-transform pattern ──────────────────────────────────
|
|
265
|
+
server.registerTool(tn("isc.transforms.suggestPattern"), {
|
|
266
|
+
title: "Suggest Nested Transform Pattern",
|
|
267
|
+
description: "Matches a plain-English use-case description against a library of named nested-transform patterns " +
|
|
268
|
+
"and returns the best-matching complete example transform JSON. " +
|
|
269
|
+
"Available patterns: fallback email chain, conditional department→building, " +
|
|
270
|
+
"username first-initial+last-name+uniqueCounter, EPOCH→ISO8601 date, " +
|
|
271
|
+
"normalize+lowercase name, country code→region lookup, email from first.last@domain, " +
|
|
272
|
+
"date compare lifecycle state, E.164 phone normalisation, split domain from email. " +
|
|
273
|
+
"OFFLINE — no ISC tenant required.",
|
|
274
|
+
inputSchema: z.object({
|
|
275
|
+
description: z
|
|
276
|
+
.string()
|
|
277
|
+
.min(5)
|
|
278
|
+
.describe("Plain-English description of the pattern needed. " +
|
|
279
|
+
"Examples: 'fallback from work email to personal email to generated placeholder', " +
|
|
280
|
+
"'normalize first and last name and lowercase for email prefix'."),
|
|
281
|
+
}),
|
|
282
|
+
}, async ({ description }) => {
|
|
283
|
+
const result = suggestPattern(description);
|
|
284
|
+
return { content: [{ type: "text", text: asText(result) }] };
|
|
285
|
+
});
|
|
286
|
+
// ── 6. Generate test cases ────────────────────────────────────────────────
|
|
287
|
+
server.registerTool(tn("isc.transforms.generateTestCases"), {
|
|
288
|
+
title: "Generate Test Cases",
|
|
289
|
+
description: "Generates 2–5 illustrative test cases for a transform (happy-path, null-input, and edge cases). " +
|
|
290
|
+
"Each test case includes a description, input_value, expected_output, and an optional note. " +
|
|
291
|
+
"Suitable for manual QA in the ISC transform tester or as a reference for automated tests. " +
|
|
292
|
+
"OFFLINE — no ISC tenant required.",
|
|
293
|
+
inputSchema: z.object({
|
|
294
|
+
transform_json: z
|
|
295
|
+
.union([z.any(), z.string()])
|
|
296
|
+
.describe("The transform JSON to generate test cases for."),
|
|
297
|
+
}),
|
|
298
|
+
}, async ({ transform_json }) => {
|
|
299
|
+
let payload = transform_json;
|
|
300
|
+
if (typeof payload === "string") {
|
|
301
|
+
try {
|
|
302
|
+
payload = JSON.parse(payload.trim());
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
return { content: [{ type: "text", text: asText({ error: `Invalid JSON: ${e?.message}` }) }] };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const result = generateTestCases(payload);
|
|
310
|
+
return { content: [{ type: "text", text: asText(result) }] };
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
return { content: [{ type: "text", text: asText({ error: e?.message ?? String(e) }) }] };
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
// ── 7. Operation catalog ──────────────────────────────────────────────────
|
|
317
|
+
server.registerTool(tn("isc.transforms.catalog"), {
|
|
318
|
+
title: "List Transform Operations Catalog",
|
|
319
|
+
description: "Returns all supported SailPoint ISC transform operation types with: " +
|
|
320
|
+
"type key, human-readable title, required attributes, doc URL, schema coverage flag, and scaffold example. " +
|
|
321
|
+
"OFFLINE — no ISC tenant required.",
|
|
322
|
+
inputSchema: z.object({
|
|
323
|
+
include_scaffold: z.boolean().optional().default(true),
|
|
324
|
+
}),
|
|
325
|
+
}, async ({ include_scaffold = true }) => {
|
|
326
|
+
const schemaCoverage = Object.fromEntries(listSchemaCoverage().map((e) => [e.type, e.hasSchema]));
|
|
327
|
+
const items = listTransformTypes().map((t) => {
|
|
328
|
+
const s = TRANSFORM_CATALOG[t];
|
|
329
|
+
return {
|
|
330
|
+
type: s.type,
|
|
331
|
+
title: s.title,
|
|
332
|
+
docUrl: s.docUrl,
|
|
333
|
+
requiredAttributes: s.requiredAttributes ?? [],
|
|
334
|
+
attributesOptional: Boolean(s.attributesOptional),
|
|
335
|
+
hasJsonSchema: Boolean(schemaCoverage[t]),
|
|
336
|
+
scaffoldExample: include_scaffold ? s.scaffold(`EXAMPLE-${t}`) : undefined,
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
return { content: [{ type: "text", text: asText({ count: items.length, types: listTransformTypes(), items }) }] };
|
|
340
|
+
});
|
|
341
|
+
// ── 8. Get JSON Schema for an operation ──────────────────────────────────
|
|
342
|
+
server.registerTool(tn("isc.transforms.getSchema"), {
|
|
343
|
+
title: "Get Operation JSON Schema",
|
|
344
|
+
description: "Returns the full JSON Schema (Draft 2020-12) for a specific SailPoint ISC transform operation type. " +
|
|
345
|
+
"The schema shows exactly which attributes are required, optional, and what their types/constraints are, " +
|
|
346
|
+
"including nested-transform shapes. " +
|
|
347
|
+
"OFFLINE — no ISC tenant required.",
|
|
348
|
+
inputSchema: z.object({
|
|
349
|
+
operation_type: z
|
|
350
|
+
.string()
|
|
351
|
+
.min(1)
|
|
352
|
+
.describe("The operation type string, e.g. 'conditional', 'dateFormat', 'usernameGenerator', 'accountAttribute'."),
|
|
353
|
+
}),
|
|
354
|
+
}, async ({ operation_type }) => {
|
|
355
|
+
const canon = toCanonicalType(operation_type) ?? operation_type;
|
|
356
|
+
const schema = getOperationSchema(canon);
|
|
357
|
+
if (!schema) {
|
|
358
|
+
const types = listTransformTypes().join(", ");
|
|
359
|
+
return {
|
|
360
|
+
content: [{
|
|
361
|
+
type: "text",
|
|
362
|
+
text: asText({ error: `No schema found for type '${operation_type}'. Available types: ${types}` }),
|
|
363
|
+
}],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return { content: [{ type: "text", text: asText(schema) }] };
|
|
367
|
+
});
|
|
368
|
+
// ── 9. Scaffold ───────────────────────────────────────────────────────────
|
|
369
|
+
server.registerTool(tn("isc.transforms.scaffold"), {
|
|
370
|
+
title: "Scaffold Transform",
|
|
371
|
+
description: "Generates a valid minimal starter JSON payload for a given transform operation type. " +
|
|
372
|
+
"OFFLINE — no ISC tenant required.",
|
|
373
|
+
inputSchema: z.object({
|
|
374
|
+
type: z.string().min(1),
|
|
375
|
+
name: z.string().min(1).optional(),
|
|
376
|
+
}),
|
|
377
|
+
}, async ({ type, name }) => {
|
|
378
|
+
const canon = toCanonicalType(String(type));
|
|
379
|
+
const spec = canon ? getTransformSpec(canon) : undefined;
|
|
380
|
+
if (!canon || !spec) {
|
|
381
|
+
return {
|
|
382
|
+
content: [{
|
|
383
|
+
type: "text",
|
|
384
|
+
text: asText({ error: `Unknown type '${type}'. Run isc.transforms.catalog to see all valid types.` }),
|
|
385
|
+
}],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return { content: [{ type: "text", text: asText(spec.scaffold(name ?? `TF-${canon}`)) }] };
|
|
389
|
+
});
|
|
390
|
+
// =========================================================================
|
|
391
|
+
// PHASE 2 — Connected (ISC tenant credentials required)
|
|
392
|
+
// =========================================================================
|
|
393
|
+
// ── 10. List transforms ───────────────────────────────────────────────────
|
|
394
|
+
server.registerTool(tn("isc.transforms.list"), {
|
|
395
|
+
title: "List Transforms (ISC)",
|
|
396
|
+
description: "GET /v3/transforms — fetches all transform objects from the connected ISC tenant. " +
|
|
397
|
+
"REQUIRES ISC credentials (ISC_TENANT + ISC_PAT_CLIENT_ID / ISC_PAT_CLIENT_SECRET or ISC_ACCESS_TOKEN).",
|
|
398
|
+
inputSchema: z.object({
|
|
399
|
+
limit: z.number().int().min(1).max(250).optional(),
|
|
400
|
+
offset: z.number().int().min(0).optional(),
|
|
401
|
+
}),
|
|
402
|
+
}, async ({ limit, offset }) => {
|
|
403
|
+
const guard = phase2Guard(cfg, "isc.transforms.list");
|
|
404
|
+
if (guard)
|
|
405
|
+
return { content: [{ type: "text", text: asText({ error: guard }) }] };
|
|
406
|
+
try {
|
|
407
|
+
const qs = new URLSearchParams();
|
|
408
|
+
if (limit !== undefined)
|
|
409
|
+
qs.set("limit", String(limit));
|
|
410
|
+
if (offset !== undefined)
|
|
411
|
+
qs.set("offset", String(offset));
|
|
412
|
+
const path = qs.toString() ? `/transforms?${qs}` : "/transforms";
|
|
413
|
+
const res = await isc.request("GET", path);
|
|
414
|
+
return { content: [{ type: "text", text: asText(redactDeep(res)) }] };
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
return { content: [{ type: "text", text: asText({ error: redactDeep(toSafeError(e)) }) }] };
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
// ── 11. Get transform ─────────────────────────────────────────────────────
|
|
421
|
+
server.registerTool(tn("isc.transforms.get"), {
|
|
422
|
+
title: "Get Transform (ISC)",
|
|
423
|
+
description: "GET /v3/transforms/:id — fetches a single transform by ID from the connected ISC tenant. " +
|
|
424
|
+
"REQUIRES ISC credentials.",
|
|
425
|
+
inputSchema: z.object({ id: z.string().min(1) }),
|
|
426
|
+
}, async ({ id }) => {
|
|
427
|
+
const guard = phase2Guard(cfg, "isc.transforms.get");
|
|
428
|
+
if (guard)
|
|
429
|
+
return { content: [{ type: "text", text: asText({ error: guard }) }] };
|
|
430
|
+
try {
|
|
431
|
+
const res = await isc.request("GET", `/transforms/${encodeURIComponent(id)}`);
|
|
432
|
+
return { content: [{ type: "text", text: asText(redactDeep(res)) }] };
|
|
433
|
+
}
|
|
434
|
+
catch (e) {
|
|
435
|
+
return { content: [{ type: "text", text: asText({ error: redactDeep(toSafeError(e)) }) }] };
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
// ── 12. Upsert transform ──────────────────────────────────────────────────
|
|
439
|
+
server.registerTool(tn("isc.transforms.upsert"), {
|
|
440
|
+
title: "Upsert Transform (ISC, Safe)",
|
|
441
|
+
description: "Create or update a transform in the connected ISC tenant with dryRun + JSON-Patch diff + confirm-to-apply guardrails. " +
|
|
442
|
+
"Runs normalize + validate + lint before any write. " +
|
|
443
|
+
"dryRun=true (default) shows the planned change without applying it. " +
|
|
444
|
+
"Set confirm='APPLY_TRANSFORM_CREATE:<name>' or 'APPLY_TRANSFORM_UPDATE:<name>' to apply. " +
|
|
445
|
+
"REQUIRES ISC credentials. ISC_MCP_MODE=write required to apply changes.",
|
|
446
|
+
inputSchema: z.object({
|
|
447
|
+
id: z.string().min(1).optional().describe("Existing transform ID → update. Omit → create."),
|
|
448
|
+
body: z.union([z.any(), z.string()]).describe("Transform payload JSON."),
|
|
449
|
+
dryRun: z.boolean().optional().default(true),
|
|
450
|
+
confirm: z.string().optional(),
|
|
451
|
+
reason: z.string().optional(),
|
|
452
|
+
traceId: z.string().optional(),
|
|
453
|
+
}),
|
|
454
|
+
}, async ({ id, body, dryRun = true, confirm, reason, traceId }) => {
|
|
455
|
+
const guard = phase2Guard(cfg, "isc.transforms.upsert");
|
|
456
|
+
if (guard)
|
|
457
|
+
return { content: [{ type: "text", text: asText({ error: guard }) }] };
|
|
458
|
+
try {
|
|
459
|
+
const isUpdate = Boolean(id);
|
|
460
|
+
const mode = cfg.mode;
|
|
461
|
+
if (typeof body === "string") {
|
|
462
|
+
try {
|
|
463
|
+
body = JSON.parse(body.trim());
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
throw new Error(`body must be valid JSON: ${e?.message}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (!body || typeof body !== "object")
|
|
470
|
+
throw new Error("body must be a JSON object.");
|
|
471
|
+
// Stage 1: schema validation
|
|
472
|
+
const schemaResult = validateTransform(body);
|
|
473
|
+
if (!schemaResult.valid) {
|
|
474
|
+
const errList = schemaResult.errors.map((e) => `[${e.stage}] ${e.path ?? ""}: ${e.message}`).join("\n- ");
|
|
475
|
+
throw new Error(`Schema validation failed:\n- ${errList}`);
|
|
476
|
+
}
|
|
477
|
+
// Stage 2: semantic lint
|
|
478
|
+
const lintResult = lintTransform(body);
|
|
479
|
+
const lintErrors = lintResult.messages.filter((m) => m.level === "error");
|
|
480
|
+
if (lintErrors.length) {
|
|
481
|
+
const errList = lintErrors.map((m) => `${m.path ? m.path + ": " : ""}${m.message}`).join("\n- ");
|
|
482
|
+
throw new Error(`Semantic lint failed:\n- ${errList}`);
|
|
483
|
+
}
|
|
484
|
+
const lintWarnings = lintResult.messages.filter((m) => m.level === "warn");
|
|
485
|
+
if (isUpdate) {
|
|
486
|
+
const transformId = id;
|
|
487
|
+
const before = await isc.request("GET", `/transforms/${encodeURIComponent(transformId)}`);
|
|
488
|
+
if (body?.name && body.name !== before?.name)
|
|
489
|
+
throw new Error(`Update blocked: 'name' is immutable.`);
|
|
490
|
+
if (body?.type && body.type !== before?.type)
|
|
491
|
+
throw new Error(`Update blocked: 'type' is immutable.`);
|
|
492
|
+
const nextAttributes = body?.attributes ?? before?.attributes;
|
|
493
|
+
const after = { ...before, attributes: nextAttributes };
|
|
494
|
+
const diff = jsonPatch(before, after);
|
|
495
|
+
const expectedConfirm = `APPLY_TRANSFORM_UPDATE:${before?.name ?? transformId}`;
|
|
496
|
+
if (dryRun) {
|
|
497
|
+
return { content: [{ type: "text", text: asText(redactDeep({ traceId, dryRun: true, expectedConfirm, reason, lint: { warnings: lintWarnings }, diff })) }] };
|
|
498
|
+
}
|
|
499
|
+
if (mode !== "write")
|
|
500
|
+
throw new Error("Server is readonly. Set ISC_MCP_MODE=write.");
|
|
501
|
+
if ((confirm ?? "").trim() !== expectedConfirm)
|
|
502
|
+
throw new Error(`Confirm mismatch. Expected: "${expectedConfirm}".`);
|
|
503
|
+
const res = await isc.request("PUT", `/transforms/${encodeURIComponent(transformId)}`, { attributes: nextAttributes });
|
|
504
|
+
return { content: [{ type: "text", text: asText(redactDeep({ traceId, applied: true, expectedConfirm, result: res })) }] };
|
|
505
|
+
}
|
|
506
|
+
if (!body?.name)
|
|
507
|
+
throw new Error("Create requires body.name.");
|
|
508
|
+
if (!body?.type)
|
|
509
|
+
throw new Error("Create requires body.type.");
|
|
510
|
+
const createBody = { internal: body.internal ?? false, ...lintResult.normalized };
|
|
511
|
+
const diff = jsonPatch(null, createBody);
|
|
512
|
+
const expectedConfirm = `APPLY_TRANSFORM_CREATE:${createBody?.name}`;
|
|
513
|
+
if (dryRun) {
|
|
514
|
+
return { content: [{ type: "text", text: asText(redactDeep({ traceId, dryRun: true, expectedConfirm, reason, lint: { warnings: lintWarnings }, diff, bodyPreview: createBody })) }] };
|
|
515
|
+
}
|
|
516
|
+
if (mode !== "write")
|
|
517
|
+
throw new Error("Server is readonly. Set ISC_MCP_MODE=write.");
|
|
518
|
+
if ((confirm ?? "").trim() !== expectedConfirm)
|
|
519
|
+
throw new Error(`Confirm mismatch. Expected: "${expectedConfirm}".`);
|
|
520
|
+
const res = await isc.request("POST", "/transforms", createBody);
|
|
521
|
+
return { content: [{ type: "text", text: asText(redactDeep({ traceId, applied: true, expectedConfirm, result: res })) }] };
|
|
522
|
+
}
|
|
523
|
+
catch (e) {
|
|
524
|
+
return { content: [{ type: "text", text: asText({ error: redactDeep(toSafeError(e)) }) }] };
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
// ── 13. Find transform references ────────────────────────────────────────
|
|
528
|
+
server.registerTool(tn("isc.transforms.findReferences"), {
|
|
529
|
+
title: "Find Transform References (ISC)",
|
|
530
|
+
description: "Scans identity profiles in the connected ISC tenant for references to a transform ID or name. " +
|
|
531
|
+
"Returns JSON-pointer paths where the transform appears, grouped by identity profile. " +
|
|
532
|
+
"REQUIRES ISC credentials.",
|
|
533
|
+
inputSchema: z.object({
|
|
534
|
+
transformId: z.string().min(1).optional(),
|
|
535
|
+
transformName: z.string().min(1).optional(),
|
|
536
|
+
identityProfileIds: z.array(z.string().min(1)).optional(),
|
|
537
|
+
maxProfiles: z.number().int().min(1).max(500).optional().default(200),
|
|
538
|
+
includeSnippets: z.boolean().optional().default(true),
|
|
539
|
+
traceId: z.string().optional(),
|
|
540
|
+
}),
|
|
541
|
+
}, async ({ transformId, transformName, identityProfileIds, maxProfiles = 200, includeSnippets = true, traceId }) => {
|
|
542
|
+
const guard = phase2Guard(cfg, "isc.transforms.findReferences");
|
|
543
|
+
if (guard)
|
|
544
|
+
return { content: [{ type: "text", text: asText({ error: guard }) }] };
|
|
545
|
+
try {
|
|
546
|
+
if (!transformId && !transformName)
|
|
547
|
+
throw new Error("Provide transformId and/or transformName.");
|
|
548
|
+
const targets = [transformId, transformName].filter(Boolean).map(String);
|
|
549
|
+
const ids = [];
|
|
550
|
+
if (identityProfileIds?.length) {
|
|
551
|
+
ids.push(...identityProfileIds);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
let offset = 0;
|
|
555
|
+
while (ids.length < maxProfiles) {
|
|
556
|
+
const page = await isc.request("GET", `/identity-profiles?limit=250&offset=${offset}`);
|
|
557
|
+
const items = Array.isArray(page) ? page : page?.items ?? [];
|
|
558
|
+
if (!items.length)
|
|
559
|
+
break;
|
|
560
|
+
for (const it of items) {
|
|
561
|
+
if (it?.id)
|
|
562
|
+
ids.push(String(it.id));
|
|
563
|
+
if (ids.length >= maxProfiles)
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
offset += 250;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const results = [];
|
|
570
|
+
for (const pid of ids) {
|
|
571
|
+
const profile = await isc.request("GET", `/identity-profiles/${encodeURIComponent(pid)}`);
|
|
572
|
+
const hits = findJsonPointers(profile, (v, k) => {
|
|
573
|
+
if (typeof v !== "string")
|
|
574
|
+
return false;
|
|
575
|
+
if (!targets.includes(v))
|
|
576
|
+
return false;
|
|
577
|
+
const kk = String(k ?? "").toLowerCase();
|
|
578
|
+
return kk.includes("transform") || kk.includes("mapping") || kk.endsWith("id");
|
|
579
|
+
});
|
|
580
|
+
if (hits.length) {
|
|
581
|
+
results.push({
|
|
582
|
+
identityProfile: { id: profile?.id ?? pid, name: profile?.name },
|
|
583
|
+
matchCount: hits.length,
|
|
584
|
+
matches: hits.map((h) => ({ path: h.path, ...(includeSnippets ? { value: String(h.value).slice(0, 120) } : {}) })),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return { content: [{ type: "text", text: asText(redactDeep({ traceId, scanned: ids.length, targets, found: results.length, results })) }] };
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
return { content: [{ type: "text", text: asText({ error: redactDeep(toSafeError(e)) }) }] };
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
// =========================================================================
|
|
595
|
+
// Start
|
|
596
|
+
// =========================================================================
|
|
597
|
+
log.debug(`isc-transforms-mcp built — ${cfg.offline ? "OFFLINE (Phase 1 only)" : "CONNECTED (Phase 1 + 2)"}, ` +
|
|
598
|
+
`license: ${cfg.licenseTier}`);
|
|
599
|
+
return server;
|
|
600
|
+
}
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Stdio entry point (local Claude Desktop)
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
async function main() {
|
|
605
|
+
const cfg = loadConfig();
|
|
606
|
+
const server = await buildMcpServer(cfg);
|
|
607
|
+
const transport = new StdioServerTransport();
|
|
608
|
+
await server.connect(transport);
|
|
609
|
+
}
|
|
610
|
+
main().catch((err) => {
|
|
611
|
+
console.error("Fatal:", err);
|
|
612
|
+
process.exit(1);
|
|
613
|
+
});
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function createLogger(enabledDebug) {
|
|
2
|
+
const prefix = "[isc-mcp]";
|
|
3
|
+
return {
|
|
4
|
+
debug: (...a) => enabledDebug && console.error(prefix, "[debug]", ...a),
|
|
5
|
+
info: (...a) => console.error(prefix, "[info]", ...a),
|
|
6
|
+
warn: (...a) => console.error(prefix, "[warn]", ...a),
|
|
7
|
+
error: (...a) => console.error(prefix, "[error]", ...a)
|
|
8
|
+
};
|
|
9
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const SECRET_KEYS = new Set([
|
|
2
|
+
"authorization",
|
|
3
|
+
"access_token",
|
|
4
|
+
"refresh_token",
|
|
5
|
+
"client_secret",
|
|
6
|
+
"secret",
|
|
7
|
+
"token"
|
|
8
|
+
]);
|
|
9
|
+
export function redactDeep(obj) {
|
|
10
|
+
return redactAny(obj);
|
|
11
|
+
}
|
|
12
|
+
function redactAny(v) {
|
|
13
|
+
if (v === null || v === undefined)
|
|
14
|
+
return v;
|
|
15
|
+
if (Array.isArray(v))
|
|
16
|
+
return v.map(redactAny);
|
|
17
|
+
if (typeof v === "object") {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const [k, val] of Object.entries(v)) {
|
|
20
|
+
if (SECRET_KEYS.has(k.toLowerCase()))
|
|
21
|
+
out[k] = "***REDACTED***";
|
|
22
|
+
else
|
|
23
|
+
out[k] = redactAny(val);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
return v;
|
|
28
|
+
}
|