@ffschrattenecker/tm1-mcp-server 7.0.1 → 7.0.2

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,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [7.0.2] - 2026-09-27
11
+
12
+ ### Fixed
13
+
14
+ - On servers with `EnableSandboxDimension=true`, `tm1_get_cell_value` and `tm1_check_writable_coords`
15
+ (without `dimensions`) accept a coordinate that leaves out `Sandboxes` and bind it to `Base`, as
16
+ `tm1_write_cells` already did. Before, they refused it as one element short.
17
+ - `tm1_sample_cells` found no cells in any cube with a `Sandboxes` dimension: TM1's `NONEMPTY()`
18
+ returns an empty set whenever Sandboxes is part of the row set. An unfiltered Sandboxes is now
19
+ pinned to `Base` in WHERE.
20
+ - Markdown tables (`format: "markdown"`) now escape backslashes, so a value ending in `\` no
21
+ longer breaks the table at the next `|`. In `tm1_execute_mdx`, raw cell values are no longer
22
+ escaped twice.
23
+
24
+ ### Changed
25
+
26
+ - Dependencies: undici 8, dotenv 18, zod 4.6, and minor/patch bumps. Dev: vitest 5.
27
+
10
28
  ## [7.0.1] - 2026-09-27
11
29
 
12
30
  ### Fixed
@@ -1306,7 +1324,8 @@ Initial public release.
1306
1324
  - Quality gates: strict typecheck, ESLint, `lint:no-flat-api`,
1307
1325
  annotation-coverage, and tool-registration wiring.
1308
1326
 
1309
- [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.1...HEAD
1327
+ [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.2...HEAD
1328
+ [7.0.2]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.1...v7.0.2
1310
1329
  [7.0.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.0...v7.0.1
1311
1330
  [7.0.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.1...v7.0.0
1312
1331
  [6.1.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.0...v6.1.1
@@ -1,5 +1,15 @@
1
1
  export declare const SANDBOX_DIMENSION = "Sandboxes";
2
2
  export declare const BASE_SANDBOX = "Base";
3
+ /** Index of the cube's `Sandboxes` dimension, or -1. */
4
+ export declare function sandboxPosition(cubeDimensions: readonly string[]): number;
5
+ /**
6
+ * Positional counterpart of resolveCellAddress, for tools that take one
7
+ * element per cube dimension (get_cell_value, check_writable_coords without
8
+ * `dimensions`). A coordinate exactly one short on a cube with `Sandboxes`
9
+ * is read as "Sandboxes left out" and gets `Base` at its position; anything
10
+ * else returns undefined and the caller's count check refuses it as before.
11
+ */
12
+ export declare function bindLeftOutSandbox(cubeDimensions: readonly string[], elements: readonly string[]): string[] | undefined;
3
13
  export interface ResolvedAddress {
4
14
  /** The cube's dimensions, in cube order. */
5
15
  dimensions: string[];
@@ -20,6 +20,25 @@
20
20
  import { TM1Error, TM1ErrorCode } from "../types.js";
21
21
  export const SANDBOX_DIMENSION = "Sandboxes";
22
22
  export const BASE_SANDBOX = "Base";
23
+ /** Index of the cube's `Sandboxes` dimension, or -1. */
24
+ export function sandboxPosition(cubeDimensions) {
25
+ return cubeDimensions.findIndex((d) => d.toLowerCase() === SANDBOX_DIMENSION.toLowerCase());
26
+ }
27
+ /**
28
+ * Positional counterpart of resolveCellAddress, for tools that take one
29
+ * element per cube dimension (get_cell_value, check_writable_coords without
30
+ * `dimensions`). A coordinate exactly one short on a cube with `Sandboxes`
31
+ * is read as "Sandboxes left out" and gets `Base` at its position; anything
32
+ * else returns undefined and the caller's count check refuses it as before.
33
+ */
34
+ export function bindLeftOutSandbox(cubeDimensions, elements) {
35
+ if (elements.length !== cubeDimensions.length - 1)
36
+ return undefined;
37
+ const pos = sandboxPosition(cubeDimensions);
38
+ if (pos < 0)
39
+ return undefined;
40
+ return [...elements.slice(0, pos), BASE_SANDBOX, ...elements.slice(pos)];
41
+ }
23
42
  export function resolveCellAddress(cubeName, cubeDimensions, given) {
24
43
  const byLower = new Map(cubeDimensions.map((d, i) => [d.toLowerCase(), i]));
25
44
  const positions = [];
@@ -37,7 +56,7 @@ export function resolveCellAddress(cubeName, cubeDimensions, given) {
37
56
  positions.push(pos ?? -1);
38
57
  }
39
58
  const missing = cubeDimensions.filter((_, i) => !seen.has(i));
40
- const sandboxPos = cubeDimensions.findIndex((d) => d.toLowerCase() === SANDBOX_DIMENSION.toLowerCase());
59
+ const sandboxPos = sandboxPosition(cubeDimensions);
41
60
  const onlySandboxMissing = missing.length === 1 && sandboxPos >= 0 && !seen.has(sandboxPos);
42
61
  const problems = [];
43
62
  if (unknown.length > 0)
@@ -9,6 +9,7 @@
9
9
  // cube's dimension order, names the uncovered positions, and `details` pairs
10
10
  // every dimension with the element that landed on it.
11
11
  import { TM1Error, TM1ErrorCode } from "../types.js";
12
+ import { BASE_SANDBOX, SANDBOX_DIMENSION, sandboxPosition, } from "./cell-address.js";
12
13
  export function dimensionCountMismatch(cubeName, dimensions, elements) {
13
14
  const missing = dimensions.slice(elements.length);
14
15
  const extra = elements.slice(dimensions.length);
@@ -19,7 +20,10 @@ export function dimensionCountMismatch(cubeName, dimensions, elements) {
19
20
  code: TM1ErrorCode.VALIDATION_ERROR,
20
21
  message: `Cube '${cubeName}' has ${dimensions.length} dimensions but ${elements.length} element(s) were given.` +
21
22
  gap,
22
- hint: `Pass exactly one element per dimension, in this order: ${dimensions.join(", ")}. A forgotten dimension anywhere in the list shifts every later element onto the wrong one — check the pairing in details.`,
23
+ hint: `Pass exactly one element per dimension, in this order: ${dimensions.join(", ")}. A forgotten dimension anywhere in the list shifts every later element onto the wrong one — check the pairing in details.` +
24
+ (sandboxPosition(dimensions) >= 0
25
+ ? ` Only ${SANDBOX_DIMENSION} may be left out; it is then bound to ${BASE_SANDBOX}.`
26
+ : ""),
23
27
  details: JSON.stringify(dimensions.map((dimension, i) => ({
24
28
  dimension,
25
29
  element: elements[i] ?? null,
@@ -1,4 +1,5 @@
1
1
  import type { MdxResult, CellValue } from "../types.js";
2
+ import { BASE_SANDBOX } from "./cell-address.js";
2
3
  export type SampleCellFilter = string | string[];
3
4
  export interface SampleCellsBuildArgs {
4
5
  cubeName: string;
@@ -29,6 +30,11 @@ export interface SampleCellsBuildResult {
29
30
  rowDims: string[];
30
31
  /** Dimension placed on COLUMNS. */
31
32
  columnDim: string;
33
+ /** Set when an unfiltered `Sandboxes` was pinned to `Base` in WHERE. */
34
+ sandboxPin?: {
35
+ dimension: string;
36
+ element: typeof BASE_SANDBOX;
37
+ };
32
38
  }
33
39
  export interface SampleCell {
34
40
  coordinates: Record<string, string>;
@@ -2,6 +2,7 @@
2
2
  // No I/O — kept pure for unit-testability.
3
3
  import { TM1Error, TM1ErrorCode } from "../types.js";
4
4
  import { escapeMdxName } from "./mdx.js";
5
+ import { BASE_SANDBOX, SANDBOX_DIMENSION } from "./cell-address.js";
5
6
  const memberRef = (dim, elem) => {
6
7
  // Pre-qualified pass-through (e.g. caller passes "[Dim].[Elem]").
7
8
  if (elem.startsWith("[") && elem.includes("].["))
@@ -36,10 +37,21 @@ export function buildSampleCellsMdx(args) {
36
37
  const whereParts = [];
37
38
  const rowDims = [];
38
39
  const rowSets = [];
40
+ // An unfiltered Sandboxes is pinned to Base instead of crossjoined: TM1's
41
+ // NONEMPTY() returns an empty set whenever Sandboxes is part of the row set,
42
+ // even as {[Sandboxes].[Base]} alone, so every sample of a sandboxed cube
43
+ // came back empty. Measured on plapp-franz (EnableSandboxDimension=true).
44
+ // Base is also what every other read through this server sees.
45
+ let sandboxPin;
39
46
  for (const dim of dimensions) {
40
47
  if (dim === columnDim)
41
48
  continue;
42
- const f = filters[dim];
49
+ let f = filters[dim];
50
+ if (f === undefined &&
51
+ dim.toLowerCase() === SANDBOX_DIMENSION.toLowerCase()) {
52
+ f = BASE_SANDBOX;
53
+ sandboxPin = { dimension: dim, element: BASE_SANDBOX };
54
+ }
43
55
  if (typeof f === "string") {
44
56
  whereDims.push(dim);
45
57
  whereParts.push(memberRef(dim, f));
@@ -103,7 +115,13 @@ export function buildSampleCellsMdx(args) {
103
115
  }
104
116
  const whereClause = whereParts.length > 0 ? ` WHERE (${whereParts.join(",")})` : "";
105
117
  const mdx = `SELECT ${columnSet} ON COLUMNS, ${rowAxisExpr} ON ROWS FROM [${escapeMdxName(cubeName)}]${whereClause}`;
106
- return { mdx, whereDims, rowDims, columnDim };
118
+ return {
119
+ mdx,
120
+ whereDims,
121
+ rowDims,
122
+ columnDim,
123
+ ...(sandboxPin ? { sandboxPin } : {}),
124
+ };
107
125
  }
108
126
  /**
109
127
  * Convert flat MdxResult cells into self-describing {coordinates,value,formattedValue}.
@@ -7,6 +7,7 @@
7
7
  import { TM1Error, TM1ErrorCode } from "../../types.js";
8
8
  import { escapeMdxName } from "../../lib/mdx.js";
9
9
  import { dimensionCountMismatch } from "../../lib/coordinate-error.js";
10
+ import { bindLeftOutSandbox } from "../../lib/cell-address.js";
10
11
  import { freeCellset, transformCellsetResponse } from "./cellset-transform.js";
11
12
  // OData key encoder: double ' per OData literal rules, then percent-encode.
12
13
  const enc = (s) => encodeURIComponent(String(s).replace(/'/g, "''"));
@@ -44,6 +45,8 @@ export class CellService {
44
45
  const cubePath = `/api/v1/Cubes('${enc(cubeName)}')?$expand=Dimensions($select=Name)`;
45
46
  const cubeMeta = await this.http.request("GET", cubePath);
46
47
  const dims = cubeMeta.Dimensions.map((d) => d.Name);
48
+ // Sandboxes left out → Base, as tm1_write_cells does.
49
+ elements = bindLeftOutSandbox(dims, elements) ?? elements;
47
50
  if (elements.length !== dims.length) {
48
51
  throw dimensionCountMismatch(cubeName, dims, elements);
49
52
  }
@@ -5,7 +5,7 @@ import { READ_ONLY } from "../annotations.js";
5
5
  import { WritableCoordsResultSchema } from "../schemas/items.js";
6
6
  import { defineTool } from "../define-tool.js";
7
7
  import { dimensionCountMismatch } from "../../lib/coordinate-error.js";
8
- import { resolveCellAddress } from "../../lib/cell-address.js";
8
+ import { bindLeftOutSandbox, resolveCellAddress, } from "../../lib/cell-address.js";
9
9
  export const registerCheckWritableCoords = defineTool({
10
10
  name: "tm1_check_writable_coords",
11
11
  description: "Pre-flight check before CellPutN/CellPutS. Verifies (1) every coord element exists, (2) every element is N-Level (writes to Consolidated elements silent-fail), and (3) whether the target cube has rules that may overlap the coord. Returns per-coord status + a rule-overlap warning. Use before writing cells in a TI process or via tm1_write_cells; pass the same dimensions list as the write to check exactly the cell it will address.",
@@ -15,7 +15,7 @@ export const registerCheckWritableCoords = defineTool({
15
15
  cubeName: z.string().describe("Target cube name"),
16
16
  coords: z
17
17
  .array(z.string())
18
- .describe("Element name per dimension: in cube dimension order, or in the order of dimensions when given."),
18
+ .describe("Element name per dimension: in cube dimension order, or in the order of dimensions when given. Sandboxes may be left out (bound to Base)."),
19
19
  dimensions: z
20
20
  .array(z.string())
21
21
  .optional()
@@ -41,8 +41,15 @@ export const registerCheckWritableCoords = defineTool({
41
41
  coords = address.toCubeOrder(given);
42
42
  sandboxDefaulted = address.sandboxDefaulted;
43
43
  }
44
- else if (coords.length !== dims.length) {
45
- throw dimensionCountMismatch(cubeName, dims, coords);
44
+ else {
45
+ const bound = bindLeftOutSandbox(dims, coords);
46
+ if (bound) {
47
+ coords = bound;
48
+ sandboxDefaulted = "Base";
49
+ }
50
+ else if (coords.length !== dims.length) {
51
+ throw dimensionCountMismatch(cubeName, dims, coords);
52
+ }
46
53
  }
47
54
  const checks = await Promise.all(dims.map(async (dim, idx) => {
48
55
  // coords.length === dims.length is guarded above
@@ -1,19 +1,14 @@
1
1
  import { z } from "zod";
2
2
  import { PAGINATION_SCHEMA } from "../pagination.js";
3
- import { FORMAT_SCHEMA, payloadResponse } from "../format.js";
3
+ import { FORMAT_SCHEMA, mdEscape, payloadResponse } from "../format.js";
4
4
  import { withToolHint } from "../error-format.js";
5
5
  import { clipAxesToWindow } from "../../tm1-client/services/cellset-transform.js";
6
6
  import { MdxResultSchema } from "../schemas/items.js";
7
7
  import { READ_ONLY } from "../annotations.js";
8
8
  import { defineTool } from "../define-tool.js";
9
- function mdCell(v) {
10
- if (v === null || v === undefined)
11
- return "";
12
- return String(v).replace(/\|/g, "\\|").replace(/\n/g, " ");
13
- }
14
9
  const tupleLabel = (tuple) => tuple.members.map((m) => m.name).join(" / ");
15
10
  const axisHeader = (axis) => axis.tuples[0]?.members.map((m) => m.hierarchyName).join(" / ") || "Axis";
16
- const cellText = (c) => c.formattedValue !== "" ? c.formattedValue : mdCell(c.value);
11
+ const cellText = (c) => c.formattedValue !== "" ? c.formattedValue : (c.value ?? "").toString();
17
12
  // Render an MDX cellset envelope as Markdown.
18
13
  //
19
14
  // Pivot grid when axis[0] (ON COLUMNS) and axis[1] (ON ROWS) drive the result
@@ -45,30 +40,30 @@ export function renderMdxMarkdown(env) {
45
40
  const rows = axes[1].tuples;
46
41
  const colCount = cols.length;
47
42
  const context = contextAxes
48
- .map((ax) => mdCell(tupleLabel(ax.tuples[0])))
43
+ .map((ax) => mdEscape(tupleLabel(ax.tuples[0])))
49
44
  .filter((s) => s.length > 0)
50
45
  .join(" · ");
51
46
  const head = context ? [meta, "", `**Context:** ${context}`] : [meta];
52
- const header = `| ${mdCell(axisHeader(axes[1]))} | ${cols.map((t) => mdCell(tupleLabel(t))).join(" | ")} |`;
47
+ const header = `| ${mdEscape(axisHeader(axes[1]))} | ${cols.map((t) => mdEscape(tupleLabel(t))).join(" | ")} |`;
53
48
  const sep = `| ${["---", ...cols.map(() => "---")].join(" | ")} |`;
54
49
  const body = rows.map((rt, r) => {
55
- const vals = cols.map((_, c) => mdCell(cellText(items[r * colCount + c])));
56
- return `| ${mdCell(tupleLabel(rt))} | ${vals.join(" | ")} |`;
50
+ const vals = cols.map((_, c) => mdEscape(cellText(items[r * colCount + c])));
51
+ return `| ${mdEscape(tupleLabel(rt))} | ${vals.join(" | ")} |`;
57
52
  });
58
53
  return [...head, "", header, sep, ...body].join("\n");
59
54
  }
60
55
  // Flat fallback: decode each cell's coordinate from its global ordinal.
61
56
  const radices = axes.map((a) => a.tuples.length);
62
- const header = `| ${[...axes.map((a) => mdCell(axisHeader(a))), "Value"].join(" | ")} |`;
57
+ const header = `| ${[...axes.map((a) => mdEscape(axisHeader(a))), "Value"].join(" | ")} |`;
63
58
  const sep = `| ${[...axes.map(() => "---"), "---"].join(" | ")} |`;
64
59
  const body = items.map((cell, i) => {
65
60
  let ord = offset + i;
66
61
  const coords = radices.map((n, k) => {
67
62
  const idx = ord % n;
68
63
  ord = Math.floor(ord / n);
69
- return mdCell(tupleLabel(axes[k].tuples[idx]));
64
+ return mdEscape(tupleLabel(axes[k].tuples[idx]));
70
65
  });
71
- return `| ${[...coords, mdCell(cellText(cell))].join(" | ")} |`;
66
+ return `| ${[...coords, mdEscape(cellText(cell))].join(" | ")} |`;
72
67
  });
73
68
  return [meta, "", header, sep, ...body].join("\n");
74
69
  }
@@ -18,7 +18,7 @@ export const registerGetCellValue = defineTool({
18
18
  cubeName: z.string().describe("Name of the TM1 cube"),
19
19
  elements: z
20
20
  .array(z.string())
21
- .describe("Element names for each dimension of the cube"),
21
+ .describe("One element per cube dimension, in cube order. Sandboxes may be left out (bound to Base)."),
22
22
  },
23
23
  handler: async ({ cubeName, elements }, tm1Client) => {
24
24
  const value = await tm1Client.cells.getValue(cubeName, elements);
@@ -71,6 +71,9 @@ export const registerSampleCells = defineTool({
71
71
  whereCoords[dim] = val;
72
72
  }
73
73
  }
74
+ if (built.sandboxPin) {
75
+ whereCoords[built.sandboxPin.dimension] = built.sandboxPin.element;
76
+ }
74
77
  const sampled = transformSampleCells({ result, whereCoords });
75
78
  const truncated = requested > 0 && sampled.length > requested;
76
79
  const cells = truncated ? sampled.slice(0, requested) : sampled;
@@ -31,6 +31,7 @@ export type ColumnSpec<T> = (keyof T & string) | Column<T>;
31
31
  * untouched, so the plain field name renders identically.
32
32
  */
33
33
  export declare function columnsOf<T>(specs: readonly ColumnSpec<T>[]): Column<T>[];
34
+ export declare function mdEscape(v: unknown): string;
34
35
  export declare function renderKV(obj: Record<string, unknown>, title?: string): string;
35
36
  export declare function renderTable<T>(rows: readonly T[], columns: Column<T>[]): string;
36
37
  export interface PageRenderOpts<T> {
@@ -38,7 +38,7 @@ export function columnsOf(specs) {
38
38
  ? { header: spec, get: (row) => row[spec] }
39
39
  : spec);
40
40
  }
41
- function mdEscape(v) {
41
+ export function mdEscape(v) {
42
42
  if (v === null || v === undefined)
43
43
  return "";
44
44
  const s = typeof v === "string"
@@ -48,7 +48,12 @@ function mdEscape(v) {
48
48
  : typeof v === "object"
49
49
  ? JSON.stringify(v)
50
50
  : String(v);
51
- return s.replace(/\|/g, "\\|").replace(/\n/g, " ").replace(/\r/g, "");
51
+ // Backslashes first, so an escaped pipe can't be undone by a preceding `\`.
52
+ return s
53
+ .replace(/\\/g, "\\\\")
54
+ .replace(/\|/g, "\\|")
55
+ .replace(/\n/g, " ")
56
+ .replace(/\r/g, "");
52
57
  }
53
58
  // Render an object as a 2-column key/value Markdown table.
54
59
  // Nested objects become indented sub-tables; scalars stringify directly.