@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [7.0.0] - 2026-09-27
11
+
12
+ ### Breaking
13
+
14
+ - **One server for all TM1 connections.** Without `TM1_BASE_URL`, the server discovers every
15
+ `<name>/.env` under `~/.tm1/mcp-servers` (or `TM1_CONNECTIONS_DIR`) and every tool takes a
16
+ `connection` argument. Replace the per-instance MCP entries with a single one: the client then
17
+ carries one tool list instead of one per TM1 instance. Setups with `TM1_BASE_URL` keep working
18
+ unchanged as a single connection. See docs/CONFIGURATION.md, "Several TM1 connections".
19
+ - **`tm1_get_process_code`, `tm1_get_process_parameters`, `tm1_get_process_variables` and
20
+ `tm1_get_process_datasource` are removed.** `tm1_get_process` returns every part, each behind
21
+ an include-flag (`includeCode=false` for parameters/variables/datasource only). The datasource
22
+ now sits under `dataSource`.
23
+ - **`tm1_list_processes` returns names only by default.** Pass `fields=['name','parameters']`
24
+ for parameter lists; TM1 is then asked for the parameters too, instead of on every listing.
25
+ - **`tm1_list_cubes` defaults to `includeDimensions=false`.**
26
+
27
+ ### Added
28
+
29
+ - `tm1_list_connections`: configured connections, their mode, environment, version and session state.
30
+ - `TM1_ENVIRONMENT` / `TM1_ALLOW_PROD_WRITES` are read per connection folder, never inherited from
31
+ the launching shell. A prod connection forced to readonly says so in `tm1_list_connections` and in
32
+ the refusal hint of a write tool.
33
+ - Oversized paginated results are cut to the items that fit, with `has_more`/`next_offset`
34
+ pointing at the rest, instead of failing with `RESPONSE_TOO_LARGE`.
35
+ - Server `instructions` (≤500 characters) tell the model how to pick a connection and keep
36
+ results small.
37
+
38
+ ### Fixed
39
+
40
+ - The callgraph cache is keyed per connection, so an index built for one TM1 server is never
41
+ answered for another.
42
+
43
+ ## [6.1.1] - 2026-09-26
44
+
45
+ ### Fixed
46
+
47
+ - **Publishing.** The publish workflow handed npm the tarball as `pkg/<file>.tgz`, which npm reads
48
+ as a GitHub `user/repo` spec, so the 6.1.0 release failed at upload. Contents are identical to
49
+ 6.1.0, which was tagged but never reached npm: install 6.1.1.
50
+
51
+ ## [6.1.0] - 2026-09-26 [not published]
52
+
53
+ ### Added
54
+
55
+ - **`tm1_delete_dimension` / `tm1_delete_cube` `dryRun`.** Returns every process and rule that
56
+ references the object, per source, plus the cubes a dimension is part of. Nothing is deleted,
57
+ and it needs no `confirm` (the field is now optional in the schema and still required on the real
58
+ call). Before, the check before a delete took `tm1_analyze_object_usage` plus
59
+ `tm1_find_orphan_dimensions` as separate calls. Elements are not covered: the usage index does
60
+ not resolve element references.
61
+ - **`TM1_ENVIRONMENT=dev|test|prod`, and `mode`/`environment` in `tm1_get_server_info`.**
62
+ `mcpServer` now carries `mode` (`readonly`/`readwrite`), `environment` (`unspecified` when
63
+ unset) and, when the mode was forced, `modeReason`. Before, a client could only guess which
64
+ environment it was on from free-text labels, and inferred readonly from missing tools.
65
+ `TM1_ENVIRONMENT=prod` forces readonly even under `TM1_MODE=readwrite`, unless
66
+ `TM1_ALLOW_PROD_WRITES=true` is set too. A readwrite `.env` copied from a dev instance is the
67
+ easy way to end up with write tools on production. An unknown value refuses to start, like
68
+ `TM1_MODE`.
69
+ - **`tm1_execute_process` attaches the run's error log.** When TM1 names an error log for the run
70
+ (a failure, or `HasMinorErrors` on a run that committed), the last 40 lines come back as
71
+ `errorLog`. A run that reports `CompletedWithMessages` can still have skipped every record, and
72
+ telling that apart used to need a `tm1_diagnose_process_error` call on every non-clean run. That
73
+ tool stays for cascade siblings and older logs.
74
+ - **`tm1_set_cube_rules` and `tm1_write_cells` read back what they wrote.** `set_cube_rules`
75
+ returns `verified.textMatches`; `write_cells` re-reads up to 20 of the written cells and lists
76
+ any whose stored value differs in `verified.mismatches` (a rule or spread overriding the value).
77
+ Each replaces a separate read-back call. A failed read-back is reported as `readBackError` and
78
+ never turns a landed write into an error.
79
+ - **`tm1_upsert_process` `dryRun`.** Runs the syntax and the reference check on the process as it
80
+ would be after the call, reports both (neither stops the other), and diffs it against the
81
+ installed version with credentials masked. Nothing is written. It needs no `confirm` and does not
82
+ count as one: the real overwrite still asks. Before, a caller that wanted the findings in front of
83
+ the user before the overwrite needed `tm1_check_process_code`, `tm1_validate_process_refs` and a
84
+ diff as three separate calls, because the install preflight only runs once the write is approved.
85
+ - **`tm1_upsert_process` reads the code back.** The result carries `verified: {codeMatches,
86
+ mismatchedTabs}` for the tabs it sent, so confirming the change landed no longer takes a
87
+ `tm1_get_process_code` call.
88
+
89
+ ### ⚠️ Behavior change
90
+
91
+ - **Node.js 22.19 or newer is required.** Node 20 reached end of life in April 2026. CI now
92
+ tests Node 22 and 24.
93
+
94
+ ### Security
95
+
96
+ - **Consumers now get the pinned dependency tree.** The package ships `npm-shrinkwrap.json`, so
97
+ `npx` installs the exact versions CI tested and audited. Before, the lockfile was never published
98
+ and dependencies resolved fresh on every install, so the 6.0.1 lockfile fixes did not reach users.
99
+ - **Hardened release pipeline.** npm publishing runs in a separate job that holds the OIDC
100
+ token and only uploads the tarball built and tested in the previous job.
101
+
102
+ ### Note
103
+
104
+ - 6.0.1 was tagged but never published to npm (the tag push did not trigger the publish
105
+ workflow). Its fixes ship in the next release.
106
+
107
+ ## [6.0.1] - 2026-09-25
108
+
109
+ ### Security
110
+
111
+ - **Dependency advisories patched.** The lockfile now pins fixed versions of `fast-uri` (host
112
+ confusion / SSRF, high), `hono` and `@hono/node-server`, `qs` and `body-parser` (moderate / low),
113
+ all pulled in by the MCP SDK. No tool contract changes.
114
+
10
115
  ## [6.0.0] - 2026-09-25
11
116
 
12
117
  ### Breaking
@@ -1194,7 +1299,11 @@ Initial public release.
1194
1299
  - Quality gates: strict typecheck, ESLint, `lint:no-flat-api`,
1195
1300
  annotation-coverage, and tool-registration wiring.
1196
1301
 
1197
- [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.0.0...HEAD
1302
+ [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.0...HEAD
1303
+ [7.0.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.1...v7.0.0
1304
+ [6.1.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.0...v6.1.1
1305
+ [6.1.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.0.1...v6.1.0
1306
+ [6.0.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.0.0...v6.0.1
1198
1307
  [6.0.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v5.0.0...v6.0.0
1199
1308
  [5.0.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v4.1.0...v5.0.0
1200
1309
  [4.1.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v4.0.0...v4.1.0
package/README.md CHANGED
@@ -18,7 +18,7 @@ published as `@ffschrattenecker/tm1-mcp-server`. The command is still `tm1-mcp-s
18
18
 
19
19
  ## Features
20
20
 
21
- 115 tools across 12 categories — every one listed in
21
+ 112 tools across 12 categories — every one listed in
22
22
  [docs/TOOLS.md](docs/TOOLS.md), with working JSON payloads in
23
23
  [docs/EXAMPLES.md](docs/EXAMPLES.md). Past plain CRUD over the REST API:
24
24
 
@@ -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,27 @@ 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.
110
+
111
+ ### Several TM1 servers — one process
112
+
113
+ Give each connection its own folder with a `.env` under `~/.tm1/mcp-servers/`
114
+ (or the folder named by `TM1_CONNECTIONS_DIR`) and leave `TM1_BASE_URL` unset:
115
+
116
+ ```text
117
+ ~/.tm1/mcp-servers/
118
+ dev/.env # TM1_BASE_URL=… TM1_MODE=readwrite
119
+ prod/.env # TM1_BASE_URL=… (readonly: the default)
120
+ ```
121
+
122
+ One server then serves them all: every tool takes a `connection` argument and
123
+ `tm1_list_connections` shows what is configured. Each folder sets its own
124
+ `TM1_MODE` — a write against a readonly connection is refused per call — and
125
+ no connection logs in before its first use. One entry replaces one MCP server
126
+ per TM1 instance, so the client carries one tool list instead of N.
127
+ [docs/CONFIGURATION.md](docs/CONFIGURATION.md#several-tm1-connections) has the details.
107
128
 
108
129
  Host-disk file access is default-off in the same spirit: the `.pro` and git
109
130
  tools accept inline content, and touch host paths only once
@@ -181,7 +202,7 @@ security notes and the `autoApprove` allowlist:
181
202
 
182
203
  ## Compatibility
183
204
 
184
- - Node.js >= 20
205
+ - Node.js >= 22.19
185
206
  - TM1 11.8 / Planning Analytics 2.0 — the primary target; some metadata-write
186
207
  paths assume 11.x semantics, and v12-only fields (e.g.
187
208
  `DataSource.usesUnicode`) are dropped when `TM1_VERSION` says `11.x`
@@ -197,7 +218,7 @@ security notes and the `autoApprove` allowlist:
197
218
 
198
219
  <!-- TOOLS-AUTOGEN:START -->
199
220
 
200
- ## Tools (115)
221
+ ## Tools (112)
201
222
 
202
223
  Names and one-line descriptions: [docs/TOOLS.md](docs/TOOLS.md).
203
224
 
@@ -209,13 +230,13 @@ Names and one-line descriptions: [docs/TOOLS.md](docs/TOOLS.md).
209
230
  | fileops | 5 |
210
231
  | metadata | 9 |
211
232
  | model-building | 9 |
212
- | operations | 15 |
233
+ | operations | 16 |
213
234
  | scheduling | 5 |
214
235
  | security | 8 |
215
236
  | subsets | 5 |
216
- | ti-development | 21 |
237
+ | ti-development | 17 |
217
238
  | views | 4 |
218
- | **Total** | **115** |
239
+ | **Total** | **112** |
219
240
 
220
241
  <!-- TOOLS-AUTOGEN:END -->
221
242
 
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,14 @@ 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 type ServerSettings = Pick<TM1Config, "logLevel" | "logFile" | "transport" | "httpHost" | "httpPort" | "httpAllowedOrigins" | "httpToken" | "responseMode" | "maxResponseChars">;
37
+ export declare function loadServerSettings(env?: NodeJS.ProcessEnv): ServerSettings;
38
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): TM1Config;
39
+ /**
40
+ * Stable label for a connection: host and port, plus the v12
41
+ * instance/database. Keys per-connection state (process backups, the callgraph
42
+ * cache, mutation events) — two folders pointing at the same server share it,
43
+ * which is correct: they see the same objects.
44
+ */
45
+ export declare function connectionIdOf(config: Pick<TM1Config, "baseUrl" | "instance" | "database">): string;
35
46
  //# 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,59 +23,20 @@ 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;
29
- // CAM auth (mirrors TM1py's RestService._build_authorization_token):
30
- // TM1_CAM_PASSPORT set → "CAMPassport <token>" (no user/password round-trip)
31
- // TM1_NAMESPACE set → "CAMNamespace b64(u:p:ns)" (needs user + password + namespace)
32
- // neither → "Basic b64(u:p)" (native TM1)
33
- // SSO/gateway (Windows SSPI) is intentionally unsupported here: TM1py only does
34
- // it via the Windows-only requests_negotiate_sspi package. Supply a passport
35
- // 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;
38
- // Required: baseUrl always. user/password only when NOT using a passport — a
39
- // passport carries the authenticated identity, so TM1 needs no credentials.
40
- // Empty strings are rejected (treated as unset). Password may be empty — some
41
- // TM1 setups allow a blank password for the admin account — so we warn but
42
- // don't block, letting the real 401 (if any) surface with context.
43
- const missing = [];
44
- if (!baseUrl)
45
- missing.push("TM1_BASE_URL");
46
- if (!camPassport) {
47
- if (!user)
48
- missing.push("TM1_USER");
49
- if (password === undefined)
50
- missing.push("TM1_PASSWORD");
51
- }
52
- if (missing.length > 0) {
53
- throw new Error(`Missing or empty required environment variables: ${missing.join(", ")}. ` +
54
- `Set them in your shell or .env file before starting the server.`);
55
- }
56
- if (!camPassport && password === "") {
57
- process.stderr.write("[tm1-mcp-server] WARNING: TM1_PASSWORD is empty. " +
58
- "If TM1 rejects with 401, check whether the account actually allows blank passwords.\n");
59
- }
60
- const sslRaw = process.env.TM1_SSL_REJECT_UNAUTHORIZED;
61
- 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";
26
+ export function loadServerSettings(env = process.env) {
27
+ const logLevelRaw = env.TM1_LOG_LEVEL ?? "info";
65
28
  const logLevel = VALID_LOG_LEVELS.includes(logLevelRaw)
66
29
  ? logLevelRaw
67
30
  : "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";
31
+ const logFile = env.TM1_LOG_FILE || undefined;
32
+ const transportRaw = env.TM1_MCP_TRANSPORT ?? "stdio";
71
33
  const transport = VALID_TRANSPORTS.includes(transportRaw)
72
34
  ? transportRaw
73
35
  : "stdio";
74
36
  // Default to loopback. Binding to 0.0.0.0 must be opt-in to avoid exposing
75
37
  // 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);
38
+ const httpHost = env.TM1_MCP_HTTP_HOST || "127.0.0.1";
39
+ const httpPort = parseIntEnv("TM1_MCP_HTTP_PORT", env.TM1_MCP_HTTP_PORT, 3000);
78
40
  // Origin allow-list for DNS-rebinding protection. Loopback origins are
79
41
  // always included so localhost dev clients work out of the box; if the
80
42
  // server binds to a non-loopback host we add http://<host>:<port> too.
@@ -89,7 +51,7 @@ export function loadConfig() {
89
51
  httpHost !== "0.0.0.0") {
90
52
  defaultOrigins.push(`http://${httpHost}:${httpPort}`);
91
53
  }
92
- const extraOriginsRaw = process.env.TM1_MCP_HTTP_ALLOWED_ORIGINS;
54
+ const extraOriginsRaw = env.TM1_MCP_HTTP_ALLOWED_ORIGINS;
93
55
  const extraOrigins = extraOriginsRaw
94
56
  ? extraOriginsRaw
95
57
  .split(",")
@@ -97,7 +59,7 @@ export function loadConfig() {
97
59
  .filter(Boolean)
98
60
  : [];
99
61
  const httpAllowedOrigins = Array.from(new Set([...defaultOrigins, ...extraOrigins]));
100
- const httpToken = process.env.TM1_MCP_HTTP_TOKEN || undefined;
62
+ const httpToken = env.TM1_MCP_HTTP_TOKEN || undefined;
101
63
  // Refuse a non-loopback HTTP bind without transport auth: TM1_MCP_HTTP_HOST=0.0.0.0
102
64
  // (or any LAN address) with no bearer token would expose an unauthenticated,
103
65
  // TM1-credentialed /mcp endpoint to the network. Loopback binds keep the
@@ -111,31 +73,99 @@ export function loadConfig() {
111
73
  `Refusing to expose an unauthenticated /mcp endpoint beyond localhost — set ` +
112
74
  `TM1_MCP_HTTP_TOKEN, or bind to 127.0.0.1/localhost.`);
113
75
  }
114
- // Case-insensitive so a `TM1_MODE=ReadWrite` typo resolves to readwrite rather
115
- // than silently falling back to readonly (dropping every write tool without a
116
- // word). A genuinely-unknown value throws at startup — parity with the numeric
117
- // env vars — instead of failing quietly.
118
- const modeRaw = (process.env.TM1_MODE ?? "readonly").trim().toLowerCase();
119
- if (!VALID_MODES.includes(modeRaw)) {
120
- throw new Error(`Invalid TM1_MODE: "${process.env.TM1_MODE}". Expected "readwrite" or "readonly".`);
121
- }
122
- const mode = modeRaw;
123
76
  // Same parse shape as TM1_MODE: case-insensitive, unknown value throws at
124
77
  // startup rather than silently picking a wire format the operator did not ask
125
78
  // for.
126
- const responseModeRaw = (process.env.TM1_RESPONSE_MODE ?? "legacy")
79
+ const responseModeRaw = (env.TM1_RESPONSE_MODE ?? "legacy")
127
80
  .trim()
128
81
  .toLowerCase();
129
82
  if (!VALID_RESPONSE_MODES.includes(responseModeRaw)) {
130
- throw new Error(`Invalid TM1_RESPONSE_MODE: "${process.env.TM1_RESPONSE_MODE}". Expected "legacy" or "structured".`);
83
+ throw new Error(`Invalid TM1_RESPONSE_MODE: "${env.TM1_RESPONSE_MODE}". Expected "legacy" or "structured".`);
131
84
  }
132
85
  const responseMode = responseModeRaw;
133
86
  // ~80k characters sits under Claude Code's default MCP output cap (25k
134
87
  // 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);
88
+ const maxResponseChars = parseIntEnv("TM1_MAX_RESPONSE_CHARS", env.TM1_MAX_RESPONSE_CHARS, DEFAULT_MAX_RESPONSE_CHARS);
89
+ return {
90
+ logLevel,
91
+ logFile,
92
+ transport,
93
+ httpHost,
94
+ httpPort,
95
+ httpAllowedOrigins,
96
+ httpToken,
97
+ responseMode,
98
+ maxResponseChars,
99
+ };
100
+ }
101
+ // `env` defaults to the process environment. The multi-connection registry
102
+ // passes one record per connection folder instead (see ./connections.ts).
103
+ export function loadConfig(env = process.env) {
104
+ const baseUrl = env.TM1_BASE_URL;
105
+ const user = env.TM1_USER;
106
+ const password = env.TM1_PASSWORD;
107
+ // CAM auth (mirrors TM1py's RestService._build_authorization_token):
108
+ // TM1_CAM_PASSPORT set → "CAMPassport <token>" (no user/password round-trip)
109
+ // TM1_NAMESPACE set → "CAMNamespace b64(u:p:ns)" (needs user + password + namespace)
110
+ // neither → "Basic b64(u:p)" (native TM1)
111
+ // SSO/gateway (Windows SSPI) is intentionally unsupported here: TM1py only does
112
+ // it via the Windows-only requests_negotiate_sspi package. Supply a passport
113
+ // obtained out-of-band via TM1_CAM_PASSPORT instead.
114
+ const namespace = env.TM1_NAMESPACE || undefined;
115
+ const camPassport = env.TM1_CAM_PASSPORT || undefined;
116
+ // Required: baseUrl always. user/password only when NOT using a passport — a
117
+ // passport carries the authenticated identity, so TM1 needs no credentials.
118
+ // Empty strings are rejected (treated as unset). Password may be empty — some
119
+ // TM1 setups allow a blank password for the admin account — so we warn but
120
+ // don't block, letting the real 401 (if any) surface with context.
121
+ const missing = [];
122
+ if (!baseUrl)
123
+ missing.push("TM1_BASE_URL");
124
+ if (!camPassport) {
125
+ if (!user)
126
+ missing.push("TM1_USER");
127
+ if (password === undefined)
128
+ missing.push("TM1_PASSWORD");
129
+ }
130
+ if (missing.length > 0) {
131
+ throw new Error(`Missing or empty required environment variables: ${missing.join(", ")}. ` +
132
+ `Set them in your shell or .env file before starting the server.`);
133
+ }
134
+ if (!camPassport && password === "") {
135
+ process.stderr.write("[tm1-mcp-server] WARNING: TM1_PASSWORD is empty. " +
136
+ "If TM1 rejects with 401, check whether the account actually allows blank passwords.\n");
137
+ }
138
+ const sslRaw = env.TM1_SSL_REJECT_UNAUTHORIZED;
139
+ const rejectUnauthorized = sslRaw === undefined ? true : sslRaw !== "false";
140
+ const keepAliveIntervalMs = parseIntEnv("TM1_KEEP_ALIVE_INTERVAL", env.TM1_KEEP_ALIVE_INTERVAL, 60000);
141
+ const requestTimeoutMs = parseIntEnv("TM1_REQUEST_TIMEOUT", env.TM1_REQUEST_TIMEOUT, 30000);
142
+ const server = loadServerSettings(env);
143
+ const tm1Version = env.TM1_VERSION || "11.8";
144
+ // Case-insensitive so a `TM1_MODE=ReadWrite` typo resolves to readwrite rather
145
+ // than silently falling back to readonly (dropping every write tool without a
146
+ // word). A genuinely-unknown value throws at startup — parity with the numeric
147
+ // env vars — instead of failing quietly.
148
+ const modeRaw = (env.TM1_MODE ?? "readonly").trim().toLowerCase();
149
+ if (!VALID_MODES.includes(modeRaw)) {
150
+ throw new Error(`Invalid TM1_MODE: "${env.TM1_MODE}". Expected "readwrite" or "readonly".`);
151
+ }
152
+ const envRaw = env.TM1_ENVIRONMENT?.trim().toLowerCase() || undefined;
153
+ if (envRaw !== undefined &&
154
+ !VALID_ENVIRONMENTS.includes(envRaw)) {
155
+ throw new Error(`Invalid TM1_ENVIRONMENT: "${env.TM1_ENVIRONMENT}". Expected "dev", "test" or "prod".`);
156
+ }
157
+ const environment = envRaw;
158
+ const allowProdWrites = env.TM1_ALLOW_PROD_WRITES?.trim().toLowerCase() === "true";
159
+ let mode = modeRaw;
160
+ let modeReason;
161
+ if (environment === "prod" && mode === "readwrite" && !allowProdWrites) {
162
+ mode = "readonly";
163
+ modeReason =
164
+ "TM1_ENVIRONMENT=prod forces readonly; set TM1_ALLOW_PROD_WRITES=true to allow writes.";
165
+ }
136
166
  // --- v12 (Planning Analytics Engine) connection ---------------------------
137
- const instance = process.env.TM1_INSTANCE || undefined;
138
- const database = process.env.TM1_DATABASE || undefined;
167
+ const instance = env.TM1_INSTANCE || undefined;
168
+ const database = env.TM1_DATABASE || undefined;
139
169
  const versionMajor = Number.parseInt(tm1Version, 10);
140
170
  const isV12 = Boolean(instance || database) || versionMajor === 12;
141
171
  const version = isV12 ? 12 : 11;
@@ -159,19 +189,17 @@ export function loadConfig() {
159
189
  if (!database) {
160
190
  throw new Error("v12 connection requires TM1_DATABASE (set alongside TM1_INSTANCE).");
161
191
  }
162
- const authModeRaw = (process.env.TM1_AUTH_MODE ?? "s2s")
163
- .trim()
164
- .toLowerCase();
192
+ const authModeRaw = (env.TM1_AUTH_MODE ?? "s2s").trim().toLowerCase();
165
193
  if (!VALID_AUTH_MODES.includes(authModeRaw)) {
166
- throw new Error(`Invalid TM1_AUTH_MODE: "${process.env.TM1_AUTH_MODE}". ` +
194
+ throw new Error(`Invalid TM1_AUTH_MODE: "${env.TM1_AUTH_MODE}". ` +
167
195
  `Expected one of: ${VALID_AUTH_MODES.join(", ")}.`);
168
196
  }
169
197
  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;
198
+ clientId = env.TM1_CLIENT_ID || undefined;
199
+ clientSecret = env.TM1_CLIENT_SECRET || undefined;
200
+ accessToken = env.TM1_ACCESS_TOKEN || undefined;
201
+ apiKey = env.TM1_API_KEY || undefined;
202
+ iamUrl = env.TM1_IAM_URL || undefined;
175
203
  const missingV12 = [];
176
204
  // Every v12 mode — not just "basic" — sends `{ User: config.user }` in the
177
205
  // session login body (profile.ts buildLoginRequest), so TM1_USER is required
@@ -203,6 +231,7 @@ export function loadConfig() {
203
231
  }
204
232
  }
205
233
  return {
234
+ ...server,
206
235
  baseUrl: baseUrl,
207
236
  // In passport mode user/password are unused; default to "" so the type stays
208
237
  // a plain string and the Authorization header is built from the passport.
@@ -213,17 +242,10 @@ export function loadConfig() {
213
242
  ssl: { rejectUnauthorized },
214
243
  keepAliveIntervalMs,
215
244
  requestTimeoutMs,
216
- logLevel,
217
- logFile,
218
245
  tm1Version: effectiveTm1Version,
219
- transport,
220
- httpHost,
221
- httpPort,
222
- httpAllowedOrigins,
223
- httpToken,
224
246
  mode,
225
- responseMode,
226
- maxResponseChars,
247
+ ...(environment !== undefined ? { environment } : {}),
248
+ ...(modeReason !== undefined ? { modeReason } : {}),
227
249
  version,
228
250
  instance,
229
251
  database,
@@ -235,4 +257,23 @@ export function loadConfig() {
235
257
  iamUrl,
236
258
  };
237
259
  }
260
+ /**
261
+ * Stable label for a connection: host and port, plus the v12
262
+ * instance/database. Keys per-connection state (process backups, the callgraph
263
+ * cache, mutation events) — two folders pointing at the same server share it,
264
+ * which is correct: they see the same objects.
265
+ */
266
+ export function connectionIdOf(config) {
267
+ let host;
268
+ try {
269
+ const url = new URL(config.baseUrl);
270
+ host = url.port ? `${url.hostname}_${url.port}` : url.hostname;
271
+ }
272
+ catch {
273
+ host = config.baseUrl;
274
+ }
275
+ return [host, config.instance, config.database]
276
+ .filter((part) => Boolean(part))
277
+ .join("_");
278
+ }
238
279
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,69 @@
1
+ import pino from "pino";
2
+ import { type TM1Config } from "./config.js";
3
+ import { TM1Client } from "./tm1-client.js";
4
+ export interface ConnectionInfo {
5
+ name: string;
6
+ /** Folder the `.env` came from; undefined for the legacy single connection. */
7
+ dir?: string | undefined;
8
+ mode?: TM1Config["mode"] | undefined;
9
+ environment?: TM1Config["environment"];
10
+ /** Why mode differs from TM1_MODE (prod forces readonly). */
11
+ modeReason?: string | undefined;
12
+ version?: 11 | 12 | undefined;
13
+ tm1Version?: string | undefined;
14
+ baseUrl?: string | undefined;
15
+ /** connectionIdOf() — what mutation events and caches are keyed by. */
16
+ connectionId?: string | undefined;
17
+ /** Set when the folder's `.env` could not be turned into a config. */
18
+ configError?: string | undefined;
19
+ }
20
+ export interface ConnectionStatus extends ConnectionInfo {
21
+ connected: boolean;
22
+ lastError?: string | undefined;
23
+ }
24
+ export declare class ConnectionRegistry {
25
+ private readonly logger;
26
+ private readonly entries;
27
+ private constructor();
28
+ /** Discover connections from the environment (see the header comment). */
29
+ static fromEnvironment(env: NodeJS.ProcessEnv, logger: pino.Logger): ConnectionRegistry;
30
+ /** Wrap one prebuilt client (tests, embedders). */
31
+ static single(client: TM1Client, logger?: pino.Logger): ConnectionRegistry;
32
+ /**
33
+ * Prebuilt clients under explicit names. The owner keeps the clients'
34
+ * lifecycle: disconnectAll() leaves them alone. Mode defaults to readwrite
35
+ * because the registration-time gate (TM1_MODE via withAnnotations) already
36
+ * decided what an embedder may call.
37
+ */
38
+ static of(clients: ReadonlyArray<{
39
+ name: string;
40
+ client: TM1Client;
41
+ mode?: TM1Config["mode"];
42
+ }>, logger?: pino.Logger): ConnectionRegistry;
43
+ private addConfig;
44
+ private discover;
45
+ /** Every connection name, including ones whose config failed to load. */
46
+ get names(): string[];
47
+ /** Connections that can actually be used. */
48
+ get usableNames(): string[];
49
+ /** True when tools should not expose a `connection` argument at all. */
50
+ get isSingle(): boolean;
51
+ get anyReadwrite(): boolean;
52
+ hasVersion(version: 11 | 12): boolean;
53
+ /** A client exists and is not mid-login. Never triggers a login itself. */
54
+ isConnected(name: string): boolean;
55
+ info(name: string): ConnectionInfo | undefined;
56
+ status(): ConnectionStatus[];
57
+ /** Resolve a connection name (optional when there is only one). */
58
+ private entryFor;
59
+ /**
60
+ * The client for a connection, built and logged in on first use. A failed
61
+ * login does not poison the entry: the client's own request path retries
62
+ * authentication, so the next call gets a fresh attempt.
63
+ */
64
+ get(name: string | undefined): Promise<TM1Client>;
65
+ /** Connection metadata for a resolved call (mode/version gates). */
66
+ resolveInfo(name: string | undefined): ConnectionInfo;
67
+ disconnectAll(): Promise<void>;
68
+ }
69
+ //# sourceMappingURL=connections.d.ts.map