@ffschrattenecker/tm1-mcp-server 6.0.0 → 7.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +110 -1
  2. package/README.md +28 -7
  3. package/dist/config.d.ts +12 -1
  4. package/dist/config.js +120 -79
  5. package/dist/connections.d.ts +69 -0
  6. package/dist/connections.js +262 -0
  7. package/dist/http-transport.d.ts +2 -2
  8. package/dist/index.js +45 -37
  9. package/dist/lib/callgraph/tm1-adapter.d.ts +4 -1
  10. package/dist/lib/callgraph/tm1-adapter.js +5 -2
  11. package/dist/lib/slim-json-schema.d.ts +8 -0
  12. package/dist/lib/slim-json-schema.js +34 -0
  13. package/dist/lib/strip-comments.js +1 -1
  14. package/dist/lib/tm1-events.d.ts +2 -0
  15. package/dist/prompts/index.js +2 -2
  16. package/dist/resources/index.d.ts +4 -2
  17. package/dist/resources/index.js +93 -53
  18. package/dist/resources/subscriptions.d.ts +2 -1
  19. package/dist/resources/subscriptions.js +16 -7
  20. package/dist/server-instructions.d.ts +3 -0
  21. package/dist/server-instructions.js +12 -0
  22. package/dist/tm1-client/http.d.ts +1 -1
  23. package/dist/tm1-client/http.js +21 -4
  24. package/dist/tm1-client/services/process-service.d.ts +7 -1
  25. package/dist/tm1-client/services/process-service.js +7 -5
  26. package/dist/tm1-client.d.ts +7 -1
  27. package/dist/tm1-client.js +10 -11
  28. package/dist/tools/analysis/analyze-object-usage.d.ts +10 -0
  29. package/dist/tools/analysis/analyze-object-usage.js +37 -33
  30. package/dist/tools/analysis/audit-naming.js +1 -1
  31. package/dist/tools/analysis/delete-impact.d.ts +13 -0
  32. package/dist/tools/analysis/delete-impact.js +30 -0
  33. package/dist/tools/analysis/invalidate-callgraph-cache.js +2 -1
  34. package/dist/tools/analysis/trace-data-flow.js +1 -1
  35. package/dist/tools/celldata/write-cells.js +37 -2
  36. package/dist/tools/confirm.d.ts +4 -1
  37. package/dist/tools/confirm.js +12 -2
  38. package/dist/tools/define-tool.d.ts +35 -11
  39. package/dist/tools/define-tool.js +51 -4
  40. package/dist/tools/dimension-management/delete-dimension.js +18 -4
  41. package/dist/tools/index.d.ts +2 -2
  42. package/dist/tools/index.js +6 -12
  43. package/dist/tools/metadata/list-cubes.js +3 -3
  44. package/dist/tools/metadata/list-processes.js +11 -7
  45. package/dist/tools/model-building/delete-cube.js +18 -4
  46. package/dist/tools/model-building/set-cube-rules.js +13 -1
  47. package/dist/tools/model-building/unload-cube.js +1 -1
  48. package/dist/tools/operations/get-audit-log.js +1 -1
  49. package/dist/tools/operations/get-jobs.js +2 -2
  50. package/dist/tools/operations/get-message-log.js +1 -1
  51. package/dist/tools/operations/get-server-info.js +10 -1
  52. package/dist/tools/operations/get-server-state.js +1 -1
  53. package/dist/tools/operations/get-threads.js +2 -2
  54. package/dist/tools/operations/get-transaction-log.js +1 -1
  55. package/dist/tools/operations/list-connections.d.ts +2 -0
  56. package/dist/tools/operations/list-connections.js +35 -0
  57. package/dist/tools/operations/save-data.js +1 -1
  58. package/dist/tools/schemas/items-monitoring.d.ts +11 -0
  59. package/dist/tools/schemas/items-monitoring.js +9 -1
  60. package/dist/tools/schemas/items-processes.d.ts +3 -0
  61. package/dist/tools/schemas/items-processes.js +2 -1
  62. package/dist/tools/ti-development/diff-processes.d.ts +42 -0
  63. package/dist/tools/ti-development/diff-processes.js +4 -4
  64. package/dist/tools/ti-development/execute-process.js +29 -4
  65. package/dist/tools/ti-development/import-pro-file.js +1 -1
  66. package/dist/tools/ti-development/preflight.d.ts +24 -0
  67. package/dist/tools/ti-development/preflight.js +32 -0
  68. package/dist/tools/ti-development/search-code.js +1 -1
  69. package/dist/tools/ti-development/upsert-process.js +100 -11
  70. package/dist/tools/with-annotations.d.ts +10 -0
  71. package/dist/tools/with-annotations.js +58 -3
  72. package/npm-shrinkwrap.json +4586 -0
  73. package/package.json +9 -7
  74. package/dist/tools/ti-development/get-process-code.d.ts +0 -2
  75. package/dist/tools/ti-development/get-process-code.js +0 -100
  76. package/dist/tools/ti-development/get-process-datasource.d.ts +0 -2
  77. package/dist/tools/ti-development/get-process-datasource.js +0 -20
  78. package/dist/tools/ti-development/get-process-parameters.d.ts +0 -2
  79. package/dist/tools/ti-development/get-process-parameters.js +0 -25
  80. package/dist/tools/ti-development/get-process-variables.d.ts +0 -2
  81. package/dist/tools/ti-development/get-process-variables.js +0 -38
@@ -47,7 +47,7 @@ export const registerGetServerState = defineTool({
47
47
  tm1Client.server.getInfo(),
48
48
  tm1Client.cubes.list(),
49
49
  tm1Client.dimensions.list(),
50
- tm1Client.processes.list(),
50
+ tm1Client.processes.list({ namesOnly: true }),
51
51
  tm1Client.chores.list(),
52
52
  tm1Client.security.listClients(),
53
53
  ]);
@@ -9,7 +9,7 @@ const registerListThreads = defineTool({
9
9
  name: "tm1_list_threads",
10
10
  description: "List active threads on the TM1 server (running processes, chores, MDX queries, etc.). Paginated (default 50/page). (v11 only)",
11
11
  annotations: READ_ONLY,
12
- enabled: (tm1Client) => tm1Client.version === 11,
12
+ version: 11,
13
13
  output: pageShapeFor(ThreadSchema),
14
14
  input: { ...PAGINATION_SCHEMA, ...FORMAT_SCHEMA },
15
15
  handler: async ({ limit, offset, fetchAll, format }, tm1Client) => {
@@ -33,7 +33,7 @@ const registerCancelThread = defineTool({
33
33
  "(v11 only)",
34
34
  ],
35
35
  annotations: DESTRUCTIVE,
36
- enabled: (tm1Client) => tm1Client.version === 11,
36
+ version: 11,
37
37
  output: MutationResultSchema,
38
38
  input: {
39
39
  id: z.number().int().describe("Thread ID to cancel"),
@@ -10,7 +10,7 @@ export const registerGetTransactionLog = defineTool({
10
10
  // v12 deprecated TransactionLogEntry/TransactionLogEntries (and the
11
11
  // TransactionLog / TailTransactionLog functions) in 12.0.0; all of them serve
12
12
  // empty with no successor endpoint.
13
- enabled: (tm1Client) => tm1Client.version === 11,
13
+ version: 11,
14
14
  output: {
15
15
  count: z.number().int(),
16
16
  coverage: z
@@ -0,0 +1,2 @@
1
+ export declare const registerListConnections: import("../define-tool.js").ToolRegistrar;
2
+ //# sourceMappingURL=list-connections.d.ts.map
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import { defineTool } from "../define-tool.js";
3
+ import { READ_ONLY } from "../annotations.js";
4
+ const ConnectionSchema = z.object({
5
+ name: z.string(),
6
+ mode: z.enum(["readwrite", "readonly"]).optional(),
7
+ tm1Version: z.string().optional(),
8
+ connected: z.boolean(),
9
+ error: z.string().optional(),
10
+ });
11
+ export const registerListConnections = defineTool({
12
+ name: "tm1_list_connections",
13
+ description: "List the TM1 connections this server can reach: name (the `connection` argument of every other tool), readonly/readwrite mode, TM1 version, and whether a session is open. Makes no TM1 call.",
14
+ annotations: READ_ONLY,
15
+ output: z.object({ connections: z.array(ConnectionSchema) }),
16
+ input: {},
17
+ connectionless: true,
18
+ handler: (_args, registry) => {
19
+ const connections = registry.status().map((c) => ({
20
+ name: c.name,
21
+ ...(c.mode && { mode: c.mode }),
22
+ ...(c.tm1Version && { tm1Version: c.tm1Version }),
23
+ connected: c.connected,
24
+ ...((c.configError ?? c.lastError) && {
25
+ error: c.configError ?? c.lastError,
26
+ }),
27
+ }));
28
+ return {
29
+ content: [
30
+ { type: "text", text: JSON.stringify({ connections }) },
31
+ ],
32
+ };
33
+ },
34
+ });
35
+ //# sourceMappingURL=list-connections.js.map
@@ -13,7 +13,7 @@ export const registerSaveData = defineTool({
13
13
  "Executes as an unbound TI process via ExecuteProcessWithReturn; no process object is created on the server.",
14
14
  ],
15
15
  annotations: withVersion(IDEMPOTENT_WRITE, "v11"),
16
- enabled: (tm1Client) => tm1Client.version === 11,
16
+ version: 11,
17
17
  output: MutationResultSchema,
18
18
  input: {
19
19
  cube: z
@@ -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
- "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.",
35
+ "Before: tm1_check_process_code (syntax) and/or tm1_compile_process (full compile). Discover required params with tm1_get_process.",
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,
@@ -74,9 +86,22 @@ export const registerExecuteProcess = defineTool({
74
86
  const result = await withToolHint(tm1Client.processes.execute(processName, parameters, {
75
87
  signal: extra?.signal,
76
88
  ...(timeoutMs ? { timeoutMs } : {}),
77
- }), `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.`);
89
+ }), `Process '${processName}' failed at runtime. Inspect cascade with tm1_diagnose_process_error(processName='${processName}', includeRelated=true). Verify parameter shape via tm1_get_process; 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;
@@ -79,7 +79,7 @@ export const registerImportProFile = defineTool({
79
79
  if (failure)
80
80
  return preflightResult(failure);
81
81
  }
82
- const allProcs = await tm1Client.processes.list();
82
+ const allProcs = await tm1Client.processes.list({ namesOnly: true });
83
83
  const exists = allProcs.some((p) => p.name === processName);
84
84
  if (mode === "create" && exists) {
85
85
  throw new TM1Error({
@@ -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
@@ -14,7 +14,7 @@ export const registerSearchCode = defineTool({
14
14
  "Regex search across all TI process code (Prolog/Metadata/Data/Epilog).",
15
15
  "Returns matches paginated (default 50/page) with process name, tab, line number, and trimmed line text.",
16
16
  "Wrapper over tm1_get_all_processes_code that avoids dumping ~MB of code through the channel.",
17
- "Use tm1_get_process_code on a hit to inspect the surrounding context.",
17
+ "Use tm1_get_process on a hit to inspect the surrounding context.",
18
18
  "Set groupBy='process' (or 'tab') for a sorted count aggregation instead of individual match lines.",
19
19
  ],
20
20
  annotations: READ_ONLY,
@@ -6,12 +6,14 @@ 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
13
15
  // `query`, so an ODBC source could be created here but its SQL could not —
14
- // while tm1_get_process_datasource reads it back.
16
+ // while tm1_get_process reads it back.
15
17
  // Strict, so a misspelled field is rejected instead of silently dropped.
16
18
  const dataSourceSchema = sharedDataSourceSchema.strict();
17
19
  const parameterSchema = z.object({
@@ -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 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
  },
@@ -1,6 +1,16 @@
1
1
  import type pino from "pino";
2
2
  import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { type McpToolResult } from "./error-format.js";
3
4
  export declare function deriveTitle(toolName: string): string;
5
+ /**
6
+ * Cut an oversized page down to the longest prefix of its items that fits,
7
+ * turning it into an ordinary shorter page: count, has_more and next_offset
8
+ * are rewritten so the caller walks on from exactly where it stopped. The
9
+ * result is still valid JSON of the declared shape — unlike a text cut — and
10
+ * the call is not wasted. Returns undefined when not even one item fits (or
11
+ * the payload is not a page), leaving the caller to report the overflow.
12
+ */
13
+ export declare function trimPageToFit(result: McpToolResult, maxChars: number): McpToolResult | undefined;
4
14
  export declare function narrowingHint(inputKeys: ReadonlySet<string>): string;
5
15
  export declare function withAnnotations(server: McpServer, logger: pino.Logger, mode: "readwrite" | "readonly", responseMode?: "legacy" | "structured", maxResponseChars?: number): McpServer;
6
16
  //# sourceMappingURL=with-annotations.d.ts.map
@@ -14,7 +14,7 @@ const TITLE_CASING = {
14
14
  v11: "v11",
15
15
  };
16
16
  // Derive a human-readable title from a snake_case tool name:
17
- // "tm1_get_process_code" → "Get Process Code". Single point of derivation —
17
+ // "tm1_get_process" → "Get Process". Single point of derivation —
18
18
  // no per-tool overrides in ANNOTATION_MAP.
19
19
  export function deriveTitle(toolName) {
20
20
  return toolName
@@ -49,6 +49,55 @@ function responseChars(result) {
49
49
  return text;
50
50
  return JSON.stringify(result.structuredContent).length;
51
51
  }
52
+ function isPagePayload(value) {
53
+ if (typeof value !== "object" || value === null)
54
+ return false;
55
+ const v = value;
56
+ return (Array.isArray(v.items) &&
57
+ typeof v.offset === "number" &&
58
+ typeof v.total === "number");
59
+ }
60
+ /**
61
+ * Cut an oversized page down to the longest prefix of its items that fits,
62
+ * turning it into an ordinary shorter page: count, has_more and next_offset
63
+ * are rewritten so the caller walks on from exactly where it stopped. The
64
+ * result is still valid JSON of the declared shape — unlike a text cut — and
65
+ * the call is not wasted. Returns undefined when not even one item fits (or
66
+ * the payload is not a page), leaving the caller to report the overflow.
67
+ */
68
+ export function trimPageToFit(result, maxChars) {
69
+ const page = result.structuredContent;
70
+ if (!isPagePayload(page))
71
+ return undefined;
72
+ const withText = (result.content ?? []).some((c) => c.type === "text");
73
+ const build = (n) => {
74
+ const trimmed = {
75
+ ...page,
76
+ count: n,
77
+ has_more: true, // n < items.length: at least one item was dropped
78
+ next_offset: page.offset + n,
79
+ items: page.items.slice(0, n),
80
+ };
81
+ return {
82
+ ...result,
83
+ content: withText
84
+ ? [{ type: "text", text: JSON.stringify(trimmed) }]
85
+ : [],
86
+ structuredContent: trimmed,
87
+ };
88
+ };
89
+ // Largest n in [1, items.length) whose serialization fits.
90
+ let lo = 0;
91
+ let hi = page.items.length - 1;
92
+ while (lo < hi) {
93
+ const mid = Math.ceil((lo + hi) / 2);
94
+ if (responseChars(build(mid)) <= maxChars)
95
+ lo = mid;
96
+ else
97
+ hi = mid - 1;
98
+ }
99
+ return lo > 0 ? build(lo) : undefined;
100
+ }
52
101
  // How to narrow a call, derived from the tool's own input keys so a new tool
53
102
  // gets a correct hint without a per-tool table to keep in step.
54
103
  export function narrowingHint(inputKeys) {
@@ -199,12 +248,18 @@ responseMode = "legacy", maxResponseChars = DEFAULT_MAX_RESPONSE_CHARS) {
199
248
  }
200
249
  };
201
250
  const SLOW_TOOL_MS = 5000;
202
- // Replace an oversized success with an error that says how to narrow the
203
- // call. Never truncate: a cut JSON payload is worse than none.
251
+ // An oversized page is cut to the items that fit (see trimPageToFit).
252
+ // Anything else becomes an error that says how to narrow the call — never a
253
+ // truncated text: a cut JSON payload is worse than none.
204
254
  const guardSize = (toolName, result, hint) => {
205
255
  const size = responseChars(result);
206
256
  if (size <= maxResponseChars)
207
257
  return result;
258
+ const trimmed = trimPageToFit(result, maxResponseChars);
259
+ if (trimmed) {
260
+ logger.info({ tool: toolName, size, maxResponseChars }, "page trimmed to fit the response limit");
261
+ return trimmed;
262
+ }
208
263
  logger.warn({ tool: toolName, size, maxResponseChars }, "response too large");
209
264
  return formatTm1ErrorResult(new TM1Error({
210
265
  code: TM1ErrorCode.RESPONSE_TOO_LARGE,