@ffschrattenecker/tm1-mcp-server 6.0.0 → 6.1.1

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.
@@ -3,22 +3,36 @@ import { defineTool } from "../define-tool.js";
3
3
  import { DESTRUCTIVE } from "../annotations.js";
4
4
  import { MutationResultSchema } from "../schemas/items.js";
5
5
  import { actionResponse } from "../format.js";
6
- import { CONFIRM_SCHEMA, requireConfirm } from "../confirm.js";
6
+ import { DRY_RUN_CONFIRM_SCHEMA, requireConfirm } from "../confirm.js";
7
7
  import { withToolHint } from "../error-format.js";
8
+ import { deleteImpact } from "../analysis/delete-impact.js";
8
9
  export const registerDeleteCube = defineTool({
9
10
  name: "tm1_delete_cube",
10
11
  description: [
11
12
  "Delete a TM1 cube and all its data. This action is irreversible.",
12
13
  "Safety: pass confirm=<cube name verbatim>. Mismatched confirm rejects the call.",
13
- "Before: tm1_analyze_object_usage to find rules or processes referencing the cube; tm1_get_cube_stats to size the data loss.",
14
+ "Before: dryRun=true lists every process or rule that references the cube, in one call; tm1_get_cube_stats sizes the data loss.",
14
15
  ],
15
16
  annotations: DESTRUCTIVE,
16
17
  output: MutationResultSchema,
17
18
  input: {
18
19
  cubeName: z.string().describe("Cube name (case-sensitive)"),
19
- ...CONFIRM_SCHEMA,
20
+ dryRun: z
21
+ .boolean()
22
+ .optional()
23
+ .default(false)
24
+ .describe("Delete nothing: return what references the cube (processes and rules, per source). Needs no confirm and is not one."),
25
+ ...DRY_RUN_CONFIRM_SCHEMA,
20
26
  },
21
- handler: async ({ cubeName, confirm }, tm1Client) => {
27
+ handler: async ({ cubeName, dryRun, confirm }, tm1Client) => {
28
+ if (dryRun) {
29
+ return actionResponse({
30
+ success: true,
31
+ dryRun: true,
32
+ cubeName,
33
+ impact: await deleteImpact(tm1Client, "cube", cubeName),
34
+ });
35
+ }
22
36
  requireConfirm(confirm, cubeName, "cube");
23
37
  await withToolHint(tm1Client.cubes.delete(cubeName), "Cube delete failed. Common causes: cube still referenced by TI processes / chores / rules (run tm1_analyze_object_usage to inspect), insufficient permissions, or cube does not exist (tm1_list_cubes to verify name + casing).");
24
38
  return actionResponse({ success: true, cubeName });
@@ -14,7 +14,7 @@ export const registerSetCubeRules = defineTool({
14
14
  "Create or replace the rules for a TM1 cube.",
15
15
  "SKIPCHECK; belongs at the top and FEEDERS; before all feeder definitions — SKIPCHECK is what makes feeders take effect, so rules with feeders need it.",
16
16
  "Pass exactly one source: rules (the full text — replaces everything), edits (find/replace patch against the current text; each find must match exactly once, else nothing is written), or filePath (full text from a host file under TM1_LOCAL_FILE_ROOT).",
17
- "The full resulting text is syntax-checked before anything is written (preflight). After: tm1_get_cube_rules to read back; the callgraph cache is dropped automatically.",
17
+ "The full resulting text is syntax-checked before anything is written (preflight). The stored text is read back after writing (verified.textMatches), so no separate tm1_get_cube_rules is needed; the callgraph cache is dropped automatically.",
18
18
  ],
19
19
  annotations: IDEMPOTENT_DESTRUCTIVE,
20
20
  output: MutationResultSchema,
@@ -65,6 +65,17 @@ export const registerSetCubeRules = defineTool({
65
65
  }
66
66
  }
67
67
  await withToolHint(tm1Client.cubes.updateRules(cubeName, text), `Inspect details for the offending line.`);
68
+ // Read back what TM1 stored, compared with line endings normalized. The
69
+ // write has landed by now, so a failed read-back is reported, not thrown.
70
+ const norm = (x) => x.replace(/\r\n/g, "\n").trimEnd();
71
+ let verified;
72
+ try {
73
+ const stored = (await tm1Client.cubes.getRules(cubeName)).rulesText;
74
+ verified = { textMatches: norm(stored) === norm(text) };
75
+ }
76
+ catch (e) {
77
+ verified = { readBackError: e.message };
78
+ }
68
79
  const lineCount = text.split("\n").length;
69
80
  // Rule changes shift call edges (DB(), feeders) — drop callgraph TTL early.
70
81
  const { cleared: callgraphEntriesCleared } = invalidateCallgraphCache();
@@ -75,6 +86,7 @@ export const registerSetCubeRules = defineTool({
75
86
  lineCount,
76
87
  ...(edits !== undefined ? { editsApplied: edits.length } : {}),
77
88
  callgraphEntriesCleared,
89
+ verified,
78
90
  });
79
91
  },
80
92
  });
@@ -27,10 +27,19 @@ export const registerGetServerInfo = defineTool({
27
27
  handler: async ({ format }, tm1Client) => {
28
28
  const info = await tm1Client.server.getInfo();
29
29
  const x = info.extra ?? {};
30
+ const access = tm1Client.access;
30
31
  const payload = {
31
32
  // The MCP server's own package identity, so a client can gate on it
32
33
  // without parsing the initialize handshake.
33
- mcpServer: { name: NAME, version: VERSION },
34
+ // mode/environment let a client read what this connection may do and
35
+ // where it points, instead of inferring it from which tools are listed.
36
+ mcpServer: {
37
+ name: NAME,
38
+ version: VERSION,
39
+ mode: access.mode,
40
+ environment: access.environment ?? "unspecified",
41
+ ...(access.modeReason ? { modeReason: access.modeReason } : {}),
42
+ },
34
43
  serverName: info.serverName,
35
44
  productVersion: info.productVersion,
36
45
  productEdition: info.productEdition,
@@ -4,6 +4,17 @@ export declare const ServerInfoSchema: z.ZodObject<{
4
4
  mcpServer: z.ZodOptional<z.ZodObject<{
5
5
  name: z.ZodString;
6
6
  version: z.ZodString;
7
+ mode: z.ZodOptional<z.ZodEnum<{
8
+ readwrite: "readwrite";
9
+ readonly: "readonly";
10
+ }>>;
11
+ environment: z.ZodOptional<z.ZodEnum<{
12
+ dev: "dev";
13
+ test: "test";
14
+ prod: "prod";
15
+ unspecified: "unspecified";
16
+ }>>;
17
+ modeReason: z.ZodOptional<z.ZodString>;
7
18
  }, z.core.$strip>>;
8
19
  serverName: z.ZodString;
9
20
  productVersion: z.ZodString;
@@ -7,7 +7,15 @@ export { AuditLogDetailSchema, ErrorLogFileSchema, JobSchema, MessageLogEntrySch
7
7
  import { AuditLogDetailSchema } from "../../schemas/monitoring.js";
8
8
  export const ServerInfoSchema = z
9
9
  .object({
10
- mcpServer: z.object({ name: z.string(), version: z.string() }).optional(),
10
+ mcpServer: z
11
+ .object({
12
+ name: z.string(),
13
+ version: z.string(),
14
+ mode: z.enum(["readwrite", "readonly"]).optional(),
15
+ environment: z.enum(["dev", "test", "prod", "unspecified"]).optional(),
16
+ modeReason: z.string().optional(),
17
+ })
18
+ .optional(),
11
19
  serverName: z.string(),
12
20
  productVersion: z.string(),
13
21
  productEdition: z.string().optional(),
@@ -154,12 +154,15 @@ export declare const UpsertProcessResultSchema: z.ZodObject<{
154
154
  action: z.ZodEnum<{
155
155
  created: "created";
156
156
  updated: "updated";
157
+ wouldCreate: "wouldCreate";
158
+ wouldUpdate: "wouldUpdate";
157
159
  }>;
158
160
  appliedSteps: z.ZodArray<z.ZodString>;
159
161
  backup: z.ZodOptional<z.ZodObject<{
160
162
  json: z.ZodString;
161
163
  ti: z.ZodString;
162
164
  }, z.core.$strip>>;
165
+ dryRun: z.ZodOptional<z.ZodBoolean>;
163
166
  }, z.core.$loose>;
164
167
  export declare const InstallProBundleResultSchema: z.ZodObject<{
165
168
  directory: z.ZodString;
@@ -102,9 +102,10 @@ const ProcessBackupSchema = z.object({ json: z.string(), ti: z.string() });
102
102
  export const UpsertProcessResultSchema = z
103
103
  .object({
104
104
  processName: z.string(),
105
- action: z.enum(["created", "updated"]),
105
+ action: z.enum(["created", "updated", "wouldCreate", "wouldUpdate"]),
106
106
  appliedSteps: z.array(z.string()),
107
107
  backup: ProcessBackupSchema.optional(),
108
+ dryRun: z.boolean().optional(),
108
109
  })
109
110
  .passthrough();
110
111
  const InstallBundleEntrySchema = z
@@ -1,2 +1,44 @@
1
+ import type { ProcessParameter, ProcessVariable, DataSource } from "../../types.js";
2
+ interface DiffHunk {
3
+ startA: number;
4
+ countA: number;
5
+ startB: number;
6
+ countB: number;
7
+ lines: Array<{
8
+ type: " " | "+" | "-";
9
+ text: string;
10
+ }>;
11
+ }
12
+ export declare function tabCodeDiff(codeA: string, codeB: string, contextLines: number): {
13
+ identical: boolean;
14
+ linesA: number;
15
+ linesB: number;
16
+ hunks: DiffHunk[];
17
+ };
18
+ export declare function diffParams(a: ProcessParameter[], b: ProcessParameter[]): {
19
+ identical: boolean;
20
+ added: string[];
21
+ removed: string[];
22
+ changed: {
23
+ name: string;
24
+ a: ProcessParameter;
25
+ b: ProcessParameter;
26
+ }[];
27
+ };
28
+ export declare function diffVars(a: ProcessVariable[], b: ProcessVariable[]): {
29
+ identical: boolean;
30
+ added: string[];
31
+ removed: string[];
32
+ changed: {
33
+ name: string;
34
+ a: ProcessVariable;
35
+ b: ProcessVariable;
36
+ }[];
37
+ };
38
+ export declare function diffDs(a: DataSource, b: DataSource): {
39
+ identical: boolean;
40
+ differences: string[];
41
+ };
1
42
  export declare const registerDiffProcesses: import("../define-tool.js").ToolRegistrar;
43
+ export {};
2
44
  //# sourceMappingURL=diff-processes.d.ts.map
@@ -105,7 +105,7 @@ function buildHunks(ops, contextLines) {
105
105
  });
106
106
  }
107
107
  const NORM = (s) => s.replace(/\r\n/g, "\n").trimEnd();
108
- function tabCodeDiff(codeA, codeB, contextLines) {
108
+ export function tabCodeDiff(codeA, codeB, contextLines) {
109
109
  const na = NORM(codeA);
110
110
  const nb = NORM(codeB);
111
111
  if (na === nb) {
@@ -119,7 +119,7 @@ function tabCodeDiff(codeA, codeB, contextLines) {
119
119
  return { identical: false, linesA: a.length, linesB: b.length, hunks };
120
120
  }
121
121
  // ── param / var / datasource diff ────────────────────────────────────────────
122
- function diffParams(a, b) {
122
+ export function diffParams(a, b) {
123
123
  const ma = new Map(a.map((p) => [p.name, p]));
124
124
  const mb = new Map(b.map((p) => [p.name, p]));
125
125
  const added = [];
@@ -147,7 +147,7 @@ function diffParams(a, b) {
147
147
  changed,
148
148
  };
149
149
  }
150
- function diffVars(a, b) {
150
+ export function diffVars(a, b) {
151
151
  const ma = new Map(a.map((v) => [v.name, v]));
152
152
  const mb = new Map(b.map((v) => [v.name, v]));
153
153
  const added = [];
@@ -204,7 +204,7 @@ function diffIgnoredColumns(a, b) {
204
204
  renamed,
205
205
  };
206
206
  }
207
- function diffDs(a, b) {
207
+ export function diffDs(a, b) {
208
208
  const diffs = [];
209
209
  const fields = [
210
210
  "type",
@@ -5,6 +5,7 @@ import { CONFIRM_SCHEMA, requireConfirm } from "../confirm.js";
5
5
  import { DESTRUCTIVE } from "../annotations.js";
6
6
  import { ProcessResultSchema } from "../schemas/items.js";
7
7
  import { defineTool } from "../define-tool.js";
8
+ import { tailLines } from "../operations/error-log-helpers.js";
8
9
  /**
9
10
  * Client-abort recovery hint, branched by TM1 major version: v11 exposes
10
11
  * server threads (tm1_list_threads / tm1_cancel_thread), v12 exposes jobs
@@ -15,13 +16,24 @@ export function abortHint(version) {
15
16
  const cancel = version === 12 ? "tm1_cancel_job" : "tm1_cancel_thread";
16
17
  return `Request aborted by the client — the process was NOT confirmed failed and may still be executing. Use ${monitor} to check for it and ${cancel} to stop it. Do NOT blindly re-run: that risks a duplicate execution.`;
17
18
  }
19
+ const ERROR_LOG_TAIL_LINES = 40;
20
+ async function fetchErrorLog(tm1Client, filename) {
21
+ try {
22
+ const raw = await tm1Client.server.getErrorLogContent(filename);
23
+ const { body, truncated, totalLines } = tailLines(raw, ERROR_LOG_TAIL_LINES);
24
+ return { filename, totalLines, truncated, content: body };
25
+ }
26
+ catch (e) {
27
+ return { filename, fetchError: e.message };
28
+ }
29
+ }
18
30
  export const registerExecuteProcess = defineTool({
19
31
  name: "tm1_execute_process",
20
32
  description: [
21
33
  "Execute a TurboIntegrator process on the TM1 server with optional parameters.",
22
34
  "Non-idempotent: each call re-runs the process — do not retry blindly on transport errors without checking server state.",
23
35
  "Before: tm1_check_process_code (syntax) and/or tm1_compile_process (full compile). Discover required params with tm1_get_process_parameters.",
24
- "On failure: use tm1_diagnose_process_error for combined log + cascade fetch.",
36
+ "When TM1 wrote an error log for the run (failure or minor errors), its last 40 lines come back as errorLog. For cascade siblings and older logs use tm1_diagnose_process_error.",
25
37
  ],
26
38
  annotations: DESTRUCTIVE,
27
39
  output: ProcessResultSchema,
@@ -75,8 +87,21 @@ export const registerExecuteProcess = defineTool({
75
87
  signal: extra?.signal,
76
88
  ...(timeoutMs ? { timeoutMs } : {}),
77
89
  }), `Process '${processName}' failed at runtime. Inspect cascade with tm1_diagnose_process_error(processName='${processName}', includeRelated=true). Verify parameter shape via tm1_get_process_parameters; check syntax with tm1_compile_process before re-running.`);
90
+ // TM1 names the run's own error log when it wrote one (minor errors
91
+ // included). Attach its tail so judging the run takes no extra call.
92
+ const errorLog = result.errorLogFile
93
+ ? await fetchErrorLog(tm1Client, result.errorLogFile)
94
+ : undefined;
78
95
  return {
79
- content: [{ type: "text", text: JSON.stringify(result) }],
96
+ content: [
97
+ {
98
+ type: "text",
99
+ text: JSON.stringify({
100
+ ...result,
101
+ ...(errorLog ? { errorLog } : {}),
102
+ }),
103
+ },
104
+ ],
80
105
  // A TI process that ran but reported failure is a tool failure, not a
81
106
  // successful call carrying success:false. Flag isError so agents that
82
107
  // branch on the MCP error signal don't silently treat it as success;
@@ -36,4 +36,28 @@ export declare function preflightResult(failure: PreflightFailure): {
36
36
  }[];
37
37
  isError: true;
38
38
  };
39
+ export interface PreflightChecks {
40
+ ok: boolean;
41
+ syntax: {
42
+ ok: boolean;
43
+ errors: Array<{
44
+ procedure?: string | undefined;
45
+ lineNumber?: number | undefined;
46
+ message: string;
47
+ }>;
48
+ };
49
+ references: {
50
+ ok: boolean;
51
+ unresolved: number;
52
+ issues: RefIssue[];
53
+ partial: boolean;
54
+ unresolvableArgs: number;
55
+ };
56
+ }
57
+ /**
58
+ * Both checks, neither short-circuiting the other: the dryRun report the
59
+ * user approves from. runPreflight stops at the first failure because the
60
+ * install is refused either way; a pre-approval report needs every finding.
61
+ */
62
+ export declare function runChecks(tm1Client: TM1Client, p: PreflightPayload): Promise<PreflightChecks>;
39
63
  //# sourceMappingURL=preflight.d.ts.map
@@ -53,4 +53,36 @@ export function preflightResult(failure) {
53
53
  isError: true,
54
54
  };
55
55
  }
56
+ /**
57
+ * Both checks, neither short-circuiting the other: the dryRun report the
58
+ * user approves from. runPreflight stops at the first failure because the
59
+ * install is refused either way; a pre-approval report needs every finding.
60
+ */
61
+ export async function runChecks(tm1Client, p) {
62
+ const [check, refs] = await Promise.all([
63
+ tm1Client.processes.check({
64
+ name: p.name,
65
+ prolog: p.prolog,
66
+ metadata: p.metadata,
67
+ data: p.data,
68
+ epilog: p.epilog,
69
+ ...(p.parameters !== undefined ? { parameters: p.parameters } : {}),
70
+ ...(p.variables !== undefined ? { variables: p.variables } : {}),
71
+ ...(p.dataSource !== undefined ? { dataSource: p.dataSource } : {}),
72
+ }),
73
+ checkProcessRefs(tm1Client, p),
74
+ ]);
75
+ const references = {
76
+ ok: refs.unresolved === 0,
77
+ unresolved: refs.unresolved,
78
+ issues: refs.issues,
79
+ partial: refs.partial,
80
+ unresolvableArgs: refs.unresolvableArgs,
81
+ };
82
+ return {
83
+ ok: check.success && references.ok,
84
+ syntax: { ok: check.success, errors: check.errors },
85
+ references,
86
+ };
87
+ }
56
88
  //# sourceMappingURL=preflight.js.map
@@ -6,7 +6,9 @@ import { IDEMPOTENT_DESTRUCTIVE } from "../annotations.js";
6
6
  import { UpsertProcessResultSchema } from "../schemas/items.js";
7
7
  import { defineTool } from "../define-tool.js";
8
8
  import { OVERWRITE_CONFIRM_SCHEMA, requireOverwriteConfirm, } from "../confirm.js";
9
- import { preflightResult, runPreflight } from "./preflight.js";
9
+ import { preflightResult, runChecks, runPreflight, } from "./preflight.js";
10
+ import { diffDs, diffParams, diffVars, tabCodeDiff } from "./diff-processes.js";
11
+ import { maskCode } from "../../lib/mask-secrets.js";
10
12
  import { backupProcess } from "./process-backup.js";
11
13
  // The same data source shape the git round-trip and check_process_code use.
12
14
  // This tool used to carry its own copy, which had drifted: it was missing
@@ -20,6 +22,7 @@ const parameterSchema = z.object({
20
22
  defaultValue: z.union([z.string(), z.number()]),
21
23
  prompt: z.string().optional(),
22
24
  });
25
+ const TABS = ["prolog", "metadata", "data", "epilog"];
23
26
  const variableSchema = z.object({
24
27
  name: z.string(),
25
28
  type: z.enum(["String", "Numeric"]),
@@ -57,8 +60,13 @@ export const registerUpsertProcess = defineTool({
57
60
  .optional()
58
61
  .default(false)
59
62
  .describe("After deploy, run tm1.Compile and include the result in the response (compile: {ok, errorCount, errors}). Off by default — compile holds a brief lock on the process and serializes badly under bulk-deploy."),
63
+ dryRun: z
64
+ .boolean()
65
+ .optional()
66
+ .default(false)
67
+ .describe("Write nothing. Run the syntax AND reference check on the process as it would be after this call (both reported, neither stops the other) and diff it against the installed version (credentials masked). Needs no confirm and is not one: the real call still needs it. Replaces a separate check_process_code + validate_process_refs + diff before the risk assessment."),
60
68
  },
61
- handler: async ({ processName, prolog, metadata, data, epilog, parameters, variables, dataSource, hasSecurityAccess, mode, preflight, autoCompile, confirm, }, tm1Client) => {
69
+ handler: async ({ processName, prolog, metadata, data, epilog, parameters, variables, dataSource, hasSecurityAccess, mode, preflight, autoCompile, dryRun, confirm, }, tm1Client) => {
62
70
  const trail = [];
63
71
  const exists = await tm1Client.processes.exists(processName);
64
72
  if (mode === "create" && exists) {
@@ -73,16 +81,13 @@ export const registerUpsertProcess = defineTool({
73
81
  message: `Process '${processName}' does not exist; mode=update`,
74
82
  });
75
83
  }
76
- // Replacing an installed process is not undoable through the API.
77
- if (exists)
78
- requireOverwriteConfirm(confirm, processName, "process");
79
- if (preflight) {
80
- // Check the process as it will stand after this call, not the fields
81
- // the caller happened to send: an omitted tab keeps its installed code.
84
+ // The process as it will stand after this call, not the fields the
85
+ // caller happened to send: an omitted tab keeps its installed code.
86
+ const resolve = async () => {
82
87
  const current = exists
83
88
  ? await tm1Client.processes.getCode(processName)
84
89
  : { prolog: "", metadata: "", data: "", epilog: "" };
85
- const failure = await runPreflight(tm1Client, {
90
+ return {
86
91
  name: processName,
87
92
  prolog: prolog ?? current.prolog,
88
93
  metadata: metadata ?? current.metadata,
@@ -96,7 +101,79 @@ export const registerUpsertProcess = defineTool({
96
101
  ? await tm1Client.processes.getVariables(processName)
97
102
  : [],
98
103
  ...(dataSource !== undefined ? { dataSource } : {}),
99
- });
104
+ };
105
+ };
106
+ if (dryRun) {
107
+ // Deliberately before the confirm gate: a dry run writes nothing, and
108
+ // it is what the risk assessment is built from.
109
+ const payload = await resolve();
110
+ const [checks, installed, installedParams] = await Promise.all([
111
+ runChecks(tm1Client, payload),
112
+ exists
113
+ ? tm1Client.processes.getCode(processName)
114
+ : Promise.resolve({ prolog: "", metadata: "", data: "", epilog: "" }),
115
+ exists
116
+ ? tm1Client.processes.getParameters(processName)
117
+ : Promise.resolve([]),
118
+ ]);
119
+ const tabs = Object.fromEntries(TABS.map((t) => [
120
+ t,
121
+ tabCodeDiff(maskCode(installed[t] ?? ""), maskCode(payload[t]), 3),
122
+ ]));
123
+ const params = diffParams(installedParams, payload.parameters ?? []);
124
+ // Variables and datasource change only when the caller sends them; a
125
+ // datasource swap must not come back as "identical".
126
+ const sendsVars = variables !== undefined && variables.length > 0;
127
+ const [installedVars, installedDs] = await Promise.all([
128
+ sendsVars && exists
129
+ ? tm1Client.processes.getVariableLayout(processName)
130
+ : Promise.resolve({ variables: [] }),
131
+ dataSource !== undefined && exists
132
+ ? tm1Client.processes.getDataSource(processName)
133
+ : Promise.resolve({}),
134
+ ]);
135
+ const vars = sendsVars
136
+ ? diffVars(installedVars.variables, variables)
137
+ : undefined;
138
+ const ds = dataSource !== undefined
139
+ ? // updateDataSource PATCHes only the fields sent; the rest stay.
140
+ diffDs(installedDs, {
141
+ ...installedDs,
142
+ ...dataSource,
143
+ })
144
+ : undefined;
145
+ const identical = Object.values(tabs).every((t) => t.identical) &&
146
+ params.identical &&
147
+ (vars?.identical ?? true) &&
148
+ (ds?.identical ?? true);
149
+ return {
150
+ content: [
151
+ {
152
+ type: "text",
153
+ text: JSON.stringify({
154
+ processName,
155
+ dryRun: true,
156
+ action: exists ? "wouldUpdate" : "wouldCreate",
157
+ appliedSteps: [],
158
+ overwritesExisting: exists,
159
+ checks,
160
+ diff: {
161
+ identical,
162
+ tabs,
163
+ parameters: params,
164
+ ...(vars ? { variables: vars } : {}),
165
+ ...(ds ? { dataSource: ds } : {}),
166
+ },
167
+ }),
168
+ },
169
+ ],
170
+ };
171
+ }
172
+ // Replacing an installed process is not undoable through the API.
173
+ if (exists)
174
+ requireOverwriteConfirm(confirm, processName, "process");
175
+ if (preflight) {
176
+ const failure = await runPreflight(tm1Client, await resolve());
100
177
  if (failure)
101
178
  return preflightResult(failure);
102
179
  }
@@ -137,6 +214,17 @@ export const registerUpsertProcess = defineTool({
137
214
  // Process body/parameters/datasource may have changed call sites — drop the
138
215
  // 60s callgraph TTL so the next analysis sees fresh references instead of stale graph.
139
216
  const { cleared: callgraphEntriesCleared } = invalidateCallgraphCache();
217
+ // Read the code back: TM1 stores what REST sent, so a mismatch means the
218
+ // write did not land as sent. Saves the caller a get_process_code turn.
219
+ const sent = { prolog, metadata, data, epilog };
220
+ const sentTabs = TABS.filter((t) => sent[t] !== undefined);
221
+ let verified;
222
+ if (sentTabs.length > 0) {
223
+ const stored = await tm1Client.processes.getCode(processName);
224
+ const norm = (x) => (x ?? "").replace(/\r\n/g, "\n").trimEnd();
225
+ const mismatchedTabs = sentTabs.filter((t) => norm(stored[t]) !== norm(sent[t]));
226
+ verified = { codeMatches: mismatchedTabs.length === 0, mismatchedTabs };
227
+ }
140
228
  let compile;
141
229
  if (autoCompile) {
142
230
  const result = await tm1Client.processes.compile(processName);
@@ -157,6 +245,7 @@ export const registerUpsertProcess = defineTool({
157
245
  appliedSteps: trail,
158
246
  callgraphEntriesCleared,
159
247
  ...(backup ? { backup } : {}),
248
+ ...(verified !== undefined ? { verified } : {}),
160
249
  ...(compile !== undefined ? { compile } : {}),
161
250
  }, null, 2),
162
251
  },