@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [6.1.1] - 2026-09-26
11
+
12
+ ### Fixed
13
+
14
+ - **Publishing.** The publish workflow handed npm the tarball as `pkg/<file>.tgz`, which npm reads
15
+ as a GitHub `user/repo` spec, so the 6.1.0 release failed at upload. Contents are identical to
16
+ 6.1.0, which was tagged but never reached npm: install 6.1.1.
17
+
18
+ ## [6.1.0] - 2026-09-26 [not published]
19
+
20
+ ### Added
21
+
22
+ - **`tm1_delete_dimension` / `tm1_delete_cube` `dryRun`.** Returns every process and rule that
23
+ references the object, per source, plus the cubes a dimension is part of. Nothing is deleted,
24
+ and it needs no `confirm` (the field is now optional in the schema and still required on the real
25
+ call). Before, the check before a delete took `tm1_analyze_object_usage` plus
26
+ `tm1_find_orphan_dimensions` as separate calls. Elements are not covered: the usage index does
27
+ not resolve element references.
28
+ - **`TM1_ENVIRONMENT=dev|test|prod`, and `mode`/`environment` in `tm1_get_server_info`.**
29
+ `mcpServer` now carries `mode` (`readonly`/`readwrite`), `environment` (`unspecified` when
30
+ unset) and, when the mode was forced, `modeReason`. Before, a client could only guess which
31
+ environment it was on from free-text labels, and inferred readonly from missing tools.
32
+ `TM1_ENVIRONMENT=prod` forces readonly even under `TM1_MODE=readwrite`, unless
33
+ `TM1_ALLOW_PROD_WRITES=true` is set too. A readwrite `.env` copied from a dev instance is the
34
+ easy way to end up with write tools on production. An unknown value refuses to start, like
35
+ `TM1_MODE`.
36
+ - **`tm1_execute_process` attaches the run's error log.** When TM1 names an error log for the run
37
+ (a failure, or `HasMinorErrors` on a run that committed), the last 40 lines come back as
38
+ `errorLog`. A run that reports `CompletedWithMessages` can still have skipped every record, and
39
+ telling that apart used to need a `tm1_diagnose_process_error` call on every non-clean run. That
40
+ tool stays for cascade siblings and older logs.
41
+ - **`tm1_set_cube_rules` and `tm1_write_cells` read back what they wrote.** `set_cube_rules`
42
+ returns `verified.textMatches`; `write_cells` re-reads up to 20 of the written cells and lists
43
+ any whose stored value differs in `verified.mismatches` (a rule or spread overriding the value).
44
+ Each replaces a separate read-back call. A failed read-back is reported as `readBackError` and
45
+ never turns a landed write into an error.
46
+ - **`tm1_upsert_process` `dryRun`.** Runs the syntax and the reference check on the process as it
47
+ would be after the call, reports both (neither stops the other), and diffs it against the
48
+ installed version with credentials masked. Nothing is written. It needs no `confirm` and does not
49
+ count as one: the real overwrite still asks. Before, a caller that wanted the findings in front of
50
+ the user before the overwrite needed `tm1_check_process_code`, `tm1_validate_process_refs` and a
51
+ diff as three separate calls, because the install preflight only runs once the write is approved.
52
+ - **`tm1_upsert_process` reads the code back.** The result carries `verified: {codeMatches,
53
+ mismatchedTabs}` for the tabs it sent, so confirming the change landed no longer takes a
54
+ `tm1_get_process_code` call.
55
+
56
+ ### ⚠️ Behavior change
57
+
58
+ - **Node.js 22.19 or newer is required.** Node 20 reached end of life in April 2026. CI now
59
+ tests Node 22 and 24.
60
+
61
+ ### Security
62
+
63
+ - **Consumers now get the pinned dependency tree.** The package ships `npm-shrinkwrap.json`, so
64
+ `npx` installs the exact versions CI tested and audited. Before, the lockfile was never published
65
+ and dependencies resolved fresh on every install, so the 6.0.1 lockfile fixes did not reach users.
66
+ - **Hardened release pipeline.** npm publishing runs in a separate job that holds the OIDC
67
+ token and only uploads the tarball built and tested in the previous job.
68
+
69
+ ### Note
70
+
71
+ - 6.0.1 was tagged but never published to npm (the tag push did not trigger the publish
72
+ workflow). Its fixes ship in the next release.
73
+
74
+ ## [6.0.1] - 2026-09-25
75
+
76
+ ### Security
77
+
78
+ - **Dependency advisories patched.** The lockfile now pins fixed versions of `fast-uri` (host
79
+ confusion / SSRF, high), `hono` and `@hono/node-server`, `qs` and `body-parser` (moderate / low),
80
+ all pulled in by the MCP SDK. No tool contract changes.
81
+
10
82
  ## [6.0.0] - 2026-09-25
11
83
 
12
84
  ### Breaking
@@ -1194,7 +1266,10 @@ Initial public release.
1194
1266
  - Quality gates: strict typecheck, ESLint, `lint:no-flat-api`,
1195
1267
  annotation-coverage, and tool-registration wiring.
1196
1268
 
1197
- [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.0.0...HEAD
1269
+ [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.1...HEAD
1270
+ [6.1.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.0...v6.1.1
1271
+ [6.1.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.0.1...v6.1.0
1272
+ [6.0.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.0.0...v6.0.1
1198
1273
  [6.0.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v5.0.0...v6.0.0
1199
1274
  [5.0.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v4.1.0...v5.0.0
1200
1275
  [4.1.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v4.0.0...v4.1.0
package/README.md CHANGED
@@ -86,6 +86,7 @@ TM1_PASSWORD=your-password
86
86
  TM1_SSL_REJECT_UNAUTHORIZED=false
87
87
  TM1_VERSION=11.8
88
88
  TM1_MODE=readonly # readonly (default) | readwrite
89
+ # TM1_ENVIRONMENT=prod # dev | test | prod; prod forces readonly
89
90
  # TM1_RESPONSE_MODE=structured # legacy (default) | structured
90
91
  # TM1_MAX_RESPONSE_CHARS=80000 # larger results fail with RESPONSE_TOO_LARGE
91
92
  # TM1_LOCAL_FILE_ROOT=/srv/tm1-git # optional; enables host-disk file params
@@ -103,7 +104,9 @@ Analytics Engine) connection and its auth modes, host-disk file access.
103
104
  > tools are registered, so it cannot mutate or delete anything. Set
104
105
  > `TM1_MODE=readwrite` explicitly to enable the full lifecycle (cell writes,
105
106
  > cube/dimension/process deletion, TI execution), and never point a `readwrite`
106
- > server at production without reviewing the write path first.
107
+ > server at production without reviewing the write path first. Label production
108
+ > with `TM1_ENVIRONMENT=prod`: it then stays readonly even under
109
+ > `TM1_MODE=readwrite`, unless `TM1_ALLOW_PROD_WRITES=true` is set as well.
107
110
 
108
111
  Host-disk file access is default-off in the same spirit: the `.pro` and git
109
112
  tools accept inline content, and touch host paths only once
@@ -181,7 +184,7 @@ security notes and the `autoApprove` allowlist:
181
184
 
182
185
  ## Compatibility
183
186
 
184
- - Node.js >= 20
187
+ - Node.js >= 22.19
185
188
  - TM1 11.8 / Planning Analytics 2.0 — the primary target; some metadata-write
186
189
  paths assume 11.x semantics, and v12-only fields (e.g.
187
190
  `DataSource.usesUnicode`) are dropped when `TM1_VERSION` says `11.x`
package/dist/config.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface TM1Config {
18
18
  httpAllowedOrigins: string[];
19
19
  httpToken?: string | undefined;
20
20
  mode: "readwrite" | "readonly";
21
+ environment?: "dev" | "test" | "prod" | undefined;
22
+ modeReason?: string | undefined;
21
23
  responseMode: "legacy" | "structured";
22
24
  maxResponseChars: number;
23
25
  version: 11 | 12;
@@ -31,5 +33,5 @@ export interface TM1Config {
31
33
  iamUrl?: string | undefined;
32
34
  }
33
35
  export declare const DEFAULT_MAX_RESPONSE_CHARS = 80000;
34
- export declare function loadConfig(): TM1Config;
36
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): TM1Config;
35
37
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const VALID_LOG_LEVELS = ["debug", "info", "warn", "error"];
2
2
  const VALID_TRANSPORTS = ["stdio", "http"];
3
3
  const VALID_MODES = ["readwrite", "readonly"];
4
+ const VALID_ENVIRONMENTS = ["dev", "test", "prod"];
4
5
  const VALID_RESPONSE_MODES = ["legacy", "structured"];
5
6
  const VALID_AUTH_MODES = [
6
7
  "s2s",
@@ -22,10 +23,12 @@ function parseIntEnv(name, raw, def) {
22
23
  }
23
24
  return n;
24
25
  }
25
- export function loadConfig() {
26
- const baseUrl = process.env.TM1_BASE_URL;
27
- const user = process.env.TM1_USER;
28
- const password = process.env.TM1_PASSWORD;
26
+ // `env` defaults to the process environment. The multi-connection registry
27
+ // passes one record per connection folder instead (see ./connections.ts).
28
+ export function loadConfig(env = process.env) {
29
+ const baseUrl = env.TM1_BASE_URL;
30
+ const user = env.TM1_USER;
31
+ const password = env.TM1_PASSWORD;
29
32
  // CAM auth (mirrors TM1py's RestService._build_authorization_token):
30
33
  // TM1_CAM_PASSPORT set → "CAMPassport <token>" (no user/password round-trip)
31
34
  // TM1_NAMESPACE set → "CAMNamespace b64(u:p:ns)" (needs user + password + namespace)
@@ -33,8 +36,8 @@ export function loadConfig() {
33
36
  // SSO/gateway (Windows SSPI) is intentionally unsupported here: TM1py only does
34
37
  // it via the Windows-only requests_negotiate_sspi package. Supply a passport
35
38
  // obtained out-of-band via TM1_CAM_PASSPORT instead.
36
- const namespace = process.env.TM1_NAMESPACE || undefined;
37
- const camPassport = process.env.TM1_CAM_PASSPORT || undefined;
39
+ const namespace = env.TM1_NAMESPACE || undefined;
40
+ const camPassport = env.TM1_CAM_PASSPORT || undefined;
38
41
  // Required: baseUrl always. user/password only when NOT using a passport — a
39
42
  // passport carries the authenticated identity, so TM1 needs no credentials.
40
43
  // Empty strings are rejected (treated as unset). Password may be empty — some
@@ -57,24 +60,24 @@ export function loadConfig() {
57
60
  process.stderr.write("[tm1-mcp-server] WARNING: TM1_PASSWORD is empty. " +
58
61
  "If TM1 rejects with 401, check whether the account actually allows blank passwords.\n");
59
62
  }
60
- const sslRaw = process.env.TM1_SSL_REJECT_UNAUTHORIZED;
63
+ const sslRaw = env.TM1_SSL_REJECT_UNAUTHORIZED;
61
64
  const rejectUnauthorized = sslRaw === undefined ? true : sslRaw !== "false";
62
- const keepAliveIntervalMs = parseIntEnv("TM1_KEEP_ALIVE_INTERVAL", process.env.TM1_KEEP_ALIVE_INTERVAL, 60000);
63
- const requestTimeoutMs = parseIntEnv("TM1_REQUEST_TIMEOUT", process.env.TM1_REQUEST_TIMEOUT, 30000);
64
- const logLevelRaw = process.env.TM1_LOG_LEVEL ?? "info";
65
+ const keepAliveIntervalMs = parseIntEnv("TM1_KEEP_ALIVE_INTERVAL", env.TM1_KEEP_ALIVE_INTERVAL, 60000);
66
+ const requestTimeoutMs = parseIntEnv("TM1_REQUEST_TIMEOUT", env.TM1_REQUEST_TIMEOUT, 30000);
67
+ const logLevelRaw = env.TM1_LOG_LEVEL ?? "info";
65
68
  const logLevel = VALID_LOG_LEVELS.includes(logLevelRaw)
66
69
  ? logLevelRaw
67
70
  : "info";
68
- const logFile = process.env.TM1_LOG_FILE || undefined;
69
- const tm1Version = process.env.TM1_VERSION || "11.8";
70
- const transportRaw = process.env.TM1_MCP_TRANSPORT ?? "stdio";
71
+ const logFile = env.TM1_LOG_FILE || undefined;
72
+ const tm1Version = env.TM1_VERSION || "11.8";
73
+ const transportRaw = env.TM1_MCP_TRANSPORT ?? "stdio";
71
74
  const transport = VALID_TRANSPORTS.includes(transportRaw)
72
75
  ? transportRaw
73
76
  : "stdio";
74
77
  // Default to loopback. Binding to 0.0.0.0 must be opt-in to avoid exposing
75
78
  // a TM1-credentialed MCP server to the LAN by accident.
76
- const httpHost = process.env.TM1_MCP_HTTP_HOST || "127.0.0.1";
77
- const httpPort = parseIntEnv("TM1_MCP_HTTP_PORT", process.env.TM1_MCP_HTTP_PORT, 3000);
79
+ const httpHost = env.TM1_MCP_HTTP_HOST || "127.0.0.1";
80
+ const httpPort = parseIntEnv("TM1_MCP_HTTP_PORT", env.TM1_MCP_HTTP_PORT, 3000);
78
81
  // Origin allow-list for DNS-rebinding protection. Loopback origins are
79
82
  // always included so localhost dev clients work out of the box; if the
80
83
  // server binds to a non-loopback host we add http://<host>:<port> too.
@@ -89,7 +92,7 @@ export function loadConfig() {
89
92
  httpHost !== "0.0.0.0") {
90
93
  defaultOrigins.push(`http://${httpHost}:${httpPort}`);
91
94
  }
92
- const extraOriginsRaw = process.env.TM1_MCP_HTTP_ALLOWED_ORIGINS;
95
+ const extraOriginsRaw = env.TM1_MCP_HTTP_ALLOWED_ORIGINS;
93
96
  const extraOrigins = extraOriginsRaw
94
97
  ? extraOriginsRaw
95
98
  .split(",")
@@ -97,7 +100,7 @@ export function loadConfig() {
97
100
  .filter(Boolean)
98
101
  : [];
99
102
  const httpAllowedOrigins = Array.from(new Set([...defaultOrigins, ...extraOrigins]));
100
- const httpToken = process.env.TM1_MCP_HTTP_TOKEN || undefined;
103
+ const httpToken = env.TM1_MCP_HTTP_TOKEN || undefined;
101
104
  // Refuse a non-loopback HTTP bind without transport auth: TM1_MCP_HTTP_HOST=0.0.0.0
102
105
  // (or any LAN address) with no bearer token would expose an unauthenticated,
103
106
  // TM1-credentialed /mcp endpoint to the network. Loopback binds keep the
@@ -115,27 +118,40 @@ export function loadConfig() {
115
118
  // than silently falling back to readonly (dropping every write tool without a
116
119
  // word). A genuinely-unknown value throws at startup — parity with the numeric
117
120
  // env vars — instead of failing quietly.
118
- const modeRaw = (process.env.TM1_MODE ?? "readonly").trim().toLowerCase();
121
+ const modeRaw = (env.TM1_MODE ?? "readonly").trim().toLowerCase();
119
122
  if (!VALID_MODES.includes(modeRaw)) {
120
- throw new Error(`Invalid TM1_MODE: "${process.env.TM1_MODE}". Expected "readwrite" or "readonly".`);
123
+ throw new Error(`Invalid TM1_MODE: "${env.TM1_MODE}". Expected "readwrite" or "readonly".`);
124
+ }
125
+ const envRaw = env.TM1_ENVIRONMENT?.trim().toLowerCase() || undefined;
126
+ if (envRaw !== undefined &&
127
+ !VALID_ENVIRONMENTS.includes(envRaw)) {
128
+ throw new Error(`Invalid TM1_ENVIRONMENT: "${env.TM1_ENVIRONMENT}". Expected "dev", "test" or "prod".`);
129
+ }
130
+ const environment = envRaw;
131
+ const allowProdWrites = env.TM1_ALLOW_PROD_WRITES?.trim().toLowerCase() === "true";
132
+ let mode = modeRaw;
133
+ let modeReason;
134
+ if (environment === "prod" && mode === "readwrite" && !allowProdWrites) {
135
+ mode = "readonly";
136
+ modeReason =
137
+ "TM1_ENVIRONMENT=prod forces readonly; set TM1_ALLOW_PROD_WRITES=true to allow writes.";
121
138
  }
122
- const mode = modeRaw;
123
139
  // Same parse shape as TM1_MODE: case-insensitive, unknown value throws at
124
140
  // startup rather than silently picking a wire format the operator did not ask
125
141
  // for.
126
- const responseModeRaw = (process.env.TM1_RESPONSE_MODE ?? "legacy")
142
+ const responseModeRaw = (env.TM1_RESPONSE_MODE ?? "legacy")
127
143
  .trim()
128
144
  .toLowerCase();
129
145
  if (!VALID_RESPONSE_MODES.includes(responseModeRaw)) {
130
- throw new Error(`Invalid TM1_RESPONSE_MODE: "${process.env.TM1_RESPONSE_MODE}". Expected "legacy" or "structured".`);
146
+ throw new Error(`Invalid TM1_RESPONSE_MODE: "${env.TM1_RESPONSE_MODE}". Expected "legacy" or "structured".`);
131
147
  }
132
148
  const responseMode = responseModeRaw;
133
149
  // ~80k characters sits under Claude Code's default MCP output cap (25k
134
150
  // tokens) with room for the envelope; 23 recorded results overflowed it.
135
- const maxResponseChars = parseIntEnv("TM1_MAX_RESPONSE_CHARS", process.env.TM1_MAX_RESPONSE_CHARS, DEFAULT_MAX_RESPONSE_CHARS);
151
+ const maxResponseChars = parseIntEnv("TM1_MAX_RESPONSE_CHARS", env.TM1_MAX_RESPONSE_CHARS, DEFAULT_MAX_RESPONSE_CHARS);
136
152
  // --- v12 (Planning Analytics Engine) connection ---------------------------
137
- const instance = process.env.TM1_INSTANCE || undefined;
138
- const database = process.env.TM1_DATABASE || undefined;
153
+ const instance = env.TM1_INSTANCE || undefined;
154
+ const database = env.TM1_DATABASE || undefined;
139
155
  const versionMajor = Number.parseInt(tm1Version, 10);
140
156
  const isV12 = Boolean(instance || database) || versionMajor === 12;
141
157
  const version = isV12 ? 12 : 11;
@@ -159,19 +175,17 @@ export function loadConfig() {
159
175
  if (!database) {
160
176
  throw new Error("v12 connection requires TM1_DATABASE (set alongside TM1_INSTANCE).");
161
177
  }
162
- const authModeRaw = (process.env.TM1_AUTH_MODE ?? "s2s")
163
- .trim()
164
- .toLowerCase();
178
+ const authModeRaw = (env.TM1_AUTH_MODE ?? "s2s").trim().toLowerCase();
165
179
  if (!VALID_AUTH_MODES.includes(authModeRaw)) {
166
- throw new Error(`Invalid TM1_AUTH_MODE: "${process.env.TM1_AUTH_MODE}". ` +
180
+ throw new Error(`Invalid TM1_AUTH_MODE: "${env.TM1_AUTH_MODE}". ` +
167
181
  `Expected one of: ${VALID_AUTH_MODES.join(", ")}.`);
168
182
  }
169
183
  authMode = authModeRaw;
170
- clientId = process.env.TM1_CLIENT_ID || undefined;
171
- clientSecret = process.env.TM1_CLIENT_SECRET || undefined;
172
- accessToken = process.env.TM1_ACCESS_TOKEN || undefined;
173
- apiKey = process.env.TM1_API_KEY || undefined;
174
- iamUrl = process.env.TM1_IAM_URL || undefined;
184
+ clientId = env.TM1_CLIENT_ID || undefined;
185
+ clientSecret = env.TM1_CLIENT_SECRET || undefined;
186
+ accessToken = env.TM1_ACCESS_TOKEN || undefined;
187
+ apiKey = env.TM1_API_KEY || undefined;
188
+ iamUrl = env.TM1_IAM_URL || undefined;
175
189
  const missingV12 = [];
176
190
  // Every v12 mode — not just "basic" — sends `{ User: config.user }` in the
177
191
  // session login body (profile.ts buildLoginRequest), so TM1_USER is required
@@ -222,6 +236,8 @@ export function loadConfig() {
222
236
  httpAllowedOrigins,
223
237
  httpToken,
224
238
  mode,
239
+ ...(environment !== undefined ? { environment } : {}),
240
+ ...(modeReason !== undefined ? { modeReason } : {}),
225
241
  responseMode,
226
242
  maxResponseChars,
227
243
  version,
package/dist/index.js CHANGED
@@ -77,7 +77,10 @@ async function main() {
77
77
  catch (err) {
78
78
  logger.warn({ err }, "Initial TM1 connection failed — server will retry on first tool call");
79
79
  }
80
- if (config.mode === "readonly") {
80
+ if (config.modeReason) {
81
+ logger.warn(`${config.modeReason} Write and destructive tools will not be registered.`);
82
+ }
83
+ else if (config.mode === "readonly") {
81
84
  logger.info("TM1_MODE=readonly — write and destructive tools will not be registered");
82
85
  }
83
86
  // Branch on transport. stdio is the default for local MCP-client setups
@@ -38,6 +38,12 @@ export declare class TM1Client {
38
38
  constructor(config: TM1Config, sessionManager: SessionManager, logger: pino.Logger);
39
39
  /** The configured TM1 major version (11 or 12). Fixed for this connection. */
40
40
  get version(): 11 | 12;
41
+ /** Effective tool mode, the environment label, and why mode was forced. */
42
+ get access(): {
43
+ mode: TM1Config["mode"];
44
+ environment: TM1Config["environment"];
45
+ modeReason: string | undefined;
46
+ };
41
47
  /**
42
48
  * Stable label for this connection, used to keep per-connection files (the
43
49
  * process backups) apart: host and port, plus the v12 instance/database.
@@ -82,6 +82,14 @@ export class TM1Client {
82
82
  get version() {
83
83
  return this.config.version;
84
84
  }
85
+ /** Effective tool mode, the environment label, and why mode was forced. */
86
+ get access() {
87
+ return {
88
+ mode: this.config.mode,
89
+ environment: this.config.environment,
90
+ modeReason: this.config.modeReason,
91
+ };
92
+ }
85
93
  /**
86
94
  * Stable label for this connection, used to keep per-connection files (the
87
95
  * process backups) apart: host and port, plus the v12 instance/database.
@@ -1,2 +1,12 @@
1
+ import { buildCubeOrDimUsages } from "../../lib/callgraph/callGraph.js";
1
2
  export declare const registerAnalyzeObjectUsage: import("../define-tool.js").ToolRegistrar;
3
+ /** One row per referencing process or rule, most references first. */
4
+ export declare function summarizeBySource(all: ReturnType<typeof buildCubeOrDimUsages>): {
5
+ sourceKind: "process" | "rule";
6
+ sourceName: string;
7
+ accessTypes: string[];
8
+ sections: string[];
9
+ funcNames: string[];
10
+ count: number;
11
+ }[];
2
12
  //# sourceMappingURL=analyze-object-usage.d.ts.map
@@ -64,39 +64,7 @@ export const registerAnalyzeObjectUsage = defineTool({
64
64
  accessMode,
65
65
  });
66
66
  if (mode === "summary") {
67
- // Aggregate per source (process or rule). Key by kind+name so a process
68
- // and a cube-rule sharing a name don't collapse into one row.
69
- const bySource = new Map();
70
- for (const u of all) {
71
- const key = `${u.sourceKind}\x00${u.sourceName}`;
72
- let s = bySource.get(key);
73
- if (!s) {
74
- s = {
75
- sourceKind: u.sourceKind,
76
- sourceName: u.sourceName,
77
- accessTypes: new Set(),
78
- sections: new Set(),
79
- funcNames: new Set(),
80
- count: 0,
81
- };
82
- bySource.set(key, s);
83
- }
84
- s.accessTypes.add(u.accessType);
85
- s.sections.add(u.section);
86
- if (u.funcName)
87
- s.funcNames.add(u.funcName);
88
- s.count++;
89
- }
90
- const allSources = [...bySource.values()]
91
- .map((s) => ({
92
- sourceKind: s.sourceKind,
93
- sourceName: s.sourceName,
94
- accessTypes: [...s.accessTypes].sort(),
95
- sections: [...s.sections].sort(),
96
- funcNames: [...s.funcNames].sort(),
97
- count: s.count,
98
- }))
99
- .sort((a, b) => b.count - a.count || a.sourceName.localeCompare(b.sourceName));
67
+ const allSources = summarizeBySource(all);
100
68
  const sumTruncated = allSources.length > effectiveLimit;
101
69
  const sources = sumTruncated
102
70
  ? allSources.slice(0, effectiveLimit)
@@ -140,4 +108,40 @@ export const registerAnalyzeObjectUsage = defineTool({
140
108
  };
141
109
  },
142
110
  });
111
+ /** One row per referencing process or rule, most references first. */
112
+ export function summarizeBySource(all) {
113
+ // Aggregate per source (process or rule). Key by kind+name so a process
114
+ // and a cube-rule sharing a name don't collapse into one row.
115
+ const bySource = new Map();
116
+ for (const u of all) {
117
+ const key = `${u.sourceKind}\x00${u.sourceName}`;
118
+ let s = bySource.get(key);
119
+ if (!s) {
120
+ s = {
121
+ sourceKind: u.sourceKind,
122
+ sourceName: u.sourceName,
123
+ accessTypes: new Set(),
124
+ sections: new Set(),
125
+ funcNames: new Set(),
126
+ count: 0,
127
+ };
128
+ bySource.set(key, s);
129
+ }
130
+ s.accessTypes.add(u.accessType);
131
+ s.sections.add(u.section);
132
+ if (u.funcName)
133
+ s.funcNames.add(u.funcName);
134
+ s.count++;
135
+ }
136
+ return [...bySource.values()]
137
+ .map((s) => ({
138
+ sourceKind: s.sourceKind,
139
+ sourceName: s.sourceName,
140
+ accessTypes: [...s.accessTypes].sort(),
141
+ sections: [...s.sections].sort(),
142
+ funcNames: [...s.funcNames].sort(),
143
+ count: s.count,
144
+ }))
145
+ .sort((a, b) => b.count - a.count || a.sourceName.localeCompare(b.sourceName));
146
+ }
143
147
  //# sourceMappingURL=analyze-object-usage.js.map
@@ -0,0 +1,13 @@
1
+ import type { TM1Client } from "../../tm1-client.js";
2
+ export declare function deleteImpact(tm1Client: TM1Client, kind: "cube" | "dimension", name: string): Promise<{
3
+ sources: {
4
+ sourceKind: "process" | "rule";
5
+ sourceName: string;
6
+ accessTypes: string[];
7
+ count: number;
8
+ }[];
9
+ truncated?: boolean;
10
+ referencingSources: number;
11
+ usedInCubes?: string[];
12
+ }>;
13
+ //# sourceMappingURL=delete-impact.d.ts.map
@@ -0,0 +1,30 @@
1
+ import { buildIndexFromTM1 } from "../../lib/callgraph/tm1-adapter.js";
2
+ import { buildCubeOrDimUsages } from "../../lib/callgraph/callGraph.js";
3
+ import { summarizeBySource } from "./analyze-object-usage.js";
4
+ const MAX_SOURCES = 50;
5
+ export async function deleteImpact(tm1Client, kind, name) {
6
+ const [index, cubes] = await Promise.all([
7
+ buildIndexFromTM1(tm1Client, { includeControl: false }),
8
+ kind === "dimension" ? tm1Client.cubes.list() : Promise.resolve([]),
9
+ ]);
10
+ const sources = summarizeBySource(buildCubeOrDimUsages(index, kind, name, {
11
+ includeSystem: false,
12
+ accessMode: "all",
13
+ }));
14
+ const key = name.toLowerCase();
15
+ const usedInCubes = cubes
16
+ .filter((c) => (c.dimensions ?? []).some((d) => d.toLowerCase() === key))
17
+ .map((c) => c.name);
18
+ return {
19
+ ...(kind === "dimension" ? { usedInCubes } : {}),
20
+ referencingSources: sources.length,
21
+ ...(sources.length > MAX_SOURCES ? { truncated: true } : {}),
22
+ sources: sources.slice(0, MAX_SOURCES).map((s) => ({
23
+ sourceKind: s.sourceKind,
24
+ sourceName: s.sourceName,
25
+ accessTypes: s.accessTypes,
26
+ count: s.count,
27
+ })),
28
+ };
29
+ }
30
+ //# sourceMappingURL=delete-impact.js.map
@@ -6,6 +6,12 @@ import { MutationResultSchema } from "../schemas/items.js";
6
6
  import { defineTool } from "../define-tool.js";
7
7
  import { dimensionCountMismatch } from "../../lib/coordinate-error.js";
8
8
  import { resolveCellAddress } from "../../lib/cell-address.js";
9
+ const READ_BACK_LIMIT = 20;
10
+ function sameValue(sent, stored) {
11
+ if (typeof sent === "number")
12
+ return Number(stored) === sent;
13
+ return String(stored ?? "") === sent;
14
+ }
9
15
  export const registerWriteCells = defineTool({
10
16
  name: "tm1_write_cells",
11
17
  description: [
@@ -14,7 +20,8 @@ export const registerWriteCells = defineTool({
14
20
  "Writes to consolidated cells are rejected by TM1.",
15
21
  "Every cube dimension must be named (any order) — a dimension left out would land on its default member, so the call is refused. Only Sandboxes may be left out: it is bound to Base and echoed as sandboxDefaulted. A named sandbox is addressable only once its IncludeInSandboxDimension is true.",
16
22
  "Before: tm1_check_writable_coords to validate that target coordinates are leaf-level and addressable.",
17
- "Related: tm1_clear_cube for bulk wipe, tm1_get_cell_value to read back, tm1_execute_process for production data loads.",
23
+ "The written cells are read back (up to 20; verified.mismatches lists any that differ, e.g. a rule or spread overriding the value), so no separate tm1_get_cell_value is needed.",
24
+ "Related: tm1_clear_cube for bulk wipe, tm1_execute_process for production data loads.",
18
25
  ],
19
26
  annotations: DESTRUCTIVE,
20
27
  output: MutationResultSchema,
@@ -52,10 +59,38 @@ export const registerWriteCells = defineTool({
52
59
  // propagate to the index.ts Proxy unwrapped — wrapping it here would
53
60
  // clobber that hint with a generic one.
54
61
  const address = resolveCellAddress(cubeName, await tm1Client.cubes.getDimensionNames(cubeName), dimensions);
55
- await tm1Client.cells.writeCells(cubeName, address.dimensions, cells.map((c) => ({ ...c, elements: address.toCubeOrder(c.elements) })));
62
+ const ordered = cells.map((c) => ({
63
+ ...c,
64
+ elements: address.toCubeOrder(c.elements),
65
+ }));
66
+ await tm1Client.cells.writeCells(cubeName, address.dimensions, ordered);
67
+ // Read back a bounded sample: each read is its own MDX round trip. The
68
+ // write has landed by now, so a failed read-back is reported, not thrown.
69
+ const sample = ordered.slice(0, READ_BACK_LIMIT);
70
+ let verified;
71
+ try {
72
+ const readBack = await Promise.all(sample.map((c) => tm1Client.cells.getValue(cubeName, c.elements)));
73
+ verified = {
74
+ checked: sample.length,
75
+ ...(cells.length > sample.length
76
+ ? { unchecked: cells.length - sample.length }
77
+ : {}),
78
+ mismatches: sample
79
+ .map((c, i) => ({
80
+ elements: c.elements,
81
+ sent: c.value,
82
+ stored: readBack[i],
83
+ }))
84
+ .filter((m) => !sameValue(m.sent, m.stored)),
85
+ };
86
+ }
87
+ catch (e) {
88
+ verified = { checked: 0, readBackError: e.message };
89
+ }
56
90
  return actionResponse({
57
91
  success: true,
58
92
  cellsWritten: cells.length,
93
+ verified,
59
94
  ...(address.sandboxDefaulted
60
95
  ? { sandboxDefaulted: address.sandboxDefaulted }
61
96
  : {}),
@@ -2,7 +2,10 @@ import { z } from "zod";
2
2
  export declare const CONFIRM_SCHEMA: {
3
3
  confirm: z.ZodString;
4
4
  };
5
- export declare function requireConfirm(provided: string, target: string, kind: string): void;
5
+ export declare const DRY_RUN_CONFIRM_SCHEMA: {
6
+ confirm: z.ZodOptional<z.ZodString>;
7
+ };
8
+ export declare function requireConfirm(provided: string | undefined, target: string, kind: string): void;
6
9
  export declare const OVERWRITE_CONFIRM_SCHEMA: {
7
10
  confirm: z.ZodOptional<z.ZodString>;
8
11
  };
@@ -29,11 +29,21 @@ export const CONFIRM_SCHEMA = {
29
29
  .string()
30
30
  .describe("Safety check: repeat the target identifier verbatim (e.g. cube/dimension/process name) to confirm this irreversible action. Mismatched values reject the call."),
31
31
  };
32
+ // For a destructive tool with a dryRun: a dry run needs no confirm, so the
33
+ // field is optional in the schema and required at runtime on the real call.
34
+ export const DRY_RUN_CONFIRM_SCHEMA = {
35
+ confirm: z
36
+ .string()
37
+ .optional()
38
+ .describe("Required unless dryRun=true: repeat the target identifier verbatim to confirm this irreversible action. A dry run is not a confirmation."),
39
+ };
32
40
  export function requireConfirm(provided, target, kind) {
33
41
  if (provided !== target) {
34
42
  throw new TM1Error({
35
43
  code: TM1ErrorCode.VALIDATION_ERROR,
36
- message: `confirm mismatch — expected ${kind} name "${target}", got "${provided}".`,
44
+ message: provided === undefined
45
+ ? `This call is irreversible for ${kind} "${target}" and needs confirm="${target}". Nothing was changed.`
46
+ : `confirm mismatch — expected ${kind} name "${target}", got "${provided}".`,
37
47
  hint: `Re-issue the call with confirm="${target}" verbatim. This safety check prevents accidental destructive operations.`,
38
48
  });
39
49
  }
@@ -1,22 +1,36 @@
1
1
  import { z } from "zod";
2
2
  import { actionResponse } from "../format.js";
3
- import { CONFIRM_SCHEMA, requireConfirm } from "../confirm.js";
3
+ import { DRY_RUN_CONFIRM_SCHEMA, requireConfirm } from "../confirm.js";
4
4
  import { DESTRUCTIVE } from "../annotations.js";
5
5
  import { MutationResultSchema } from "../schemas/items.js";
6
6
  import { defineTool } from "../define-tool.js";
7
+ import { deleteImpact } from "../analysis/delete-impact.js";
7
8
  export const registerDeleteDimension = defineTool({
8
9
  name: "tm1_delete_dimension",
9
10
  description: [
10
11
  "Delete a TM1 dimension and all its hierarchies. Warning: fails if the dimension is used in a cube.",
11
- "Before: tm1_find_orphan_dimensions to confirm the dimension is unused, or tm1_analyze_object_usage for a targeted check.",
12
+ "Before: dryRun=true lists the cubes that contain it and every process or rule that references it, in one call.",
12
13
  ],
13
14
  annotations: DESTRUCTIVE,
14
15
  output: MutationResultSchema,
15
16
  input: {
16
17
  dimensionName: z.string().describe("Dimension name (case-sensitive)"),
17
- ...CONFIRM_SCHEMA,
18
+ dryRun: z
19
+ .boolean()
20
+ .optional()
21
+ .default(false)
22
+ .describe("Delete nothing: return what references the dimension (processes and rules, per source) and the cubes it is part of. Needs no confirm and is not one."),
23
+ ...DRY_RUN_CONFIRM_SCHEMA,
18
24
  },
19
- handler: async ({ dimensionName, confirm }, tm1Client) => {
25
+ handler: async ({ dimensionName, dryRun, confirm }, tm1Client) => {
26
+ if (dryRun) {
27
+ return actionResponse({
28
+ success: true,
29
+ dryRun: true,
30
+ dimensionName,
31
+ impact: await deleteImpact(tm1Client, "dimension", dimensionName),
32
+ });
33
+ }
20
34
  requireConfirm(confirm, dimensionName, "dimension");
21
35
  await tm1Client.dimensions.delete(dimensionName);
22
36
  return actionResponse({ success: true, dimensionName });