@ffschrattenecker/tm1-mcp-server 7.0.2 → 8.1.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 (116) hide show
  1. package/CHANGELOG.md +101 -1
  2. package/README.md +9 -6
  3. package/dist/config.d.ts +10 -1
  4. package/dist/config.js +40 -22
  5. package/dist/connections.d.ts +30 -2
  6. package/dist/connections.js +94 -21
  7. package/dist/http-transport.js +51 -4
  8. package/dist/index.js +6 -0
  9. package/dist/lib/callgraph/referenceIndex.js +2 -1
  10. package/dist/lib/callgraph/rulesLinter.d.ts +0 -19
  11. package/dist/lib/callgraph/rulesLinter.js +0 -590
  12. package/dist/lib/callgraph/tiParser.js +10 -5
  13. package/dist/lib/callgraph/tm1-adapter.d.ts +5 -1
  14. package/dist/lib/callgraph/tm1-adapter.js +41 -9
  15. package/dist/lib/callgraph/variableEnv.js +3 -2
  16. package/dist/lib/cell-address.d.ts +1 -1
  17. package/dist/lib/cell-address.js +2 -2
  18. package/dist/lib/complexity/antipatterns.js +3 -1
  19. package/dist/lib/complexity/comment-classifier.js +2 -1
  20. package/dist/lib/feeders/element-type-cache.js +4 -6
  21. package/dist/lib/naming/odata-filter.js +2 -1
  22. package/dist/lib/pro-parser.js +13 -4
  23. package/dist/lib/safe-regex.d.ts +0 -13
  24. package/dist/lib/safe-regex.js +40 -13
  25. package/dist/lib/sample-cells.js +1 -1
  26. package/dist/lib/ti-identifier.d.ts +11 -0
  27. package/dist/lib/ti-identifier.js +11 -0
  28. package/dist/lib/tm1-name.d.ts +9 -0
  29. package/dist/lib/tm1-name.js +9 -0
  30. package/dist/lib/v12-compat/deprecated-ti.js +2 -2
  31. package/dist/secrets-cli.d.ts +10 -0
  32. package/dist/secrets-cli.js +218 -0
  33. package/dist/secrets.d.ts +39 -0
  34. package/dist/secrets.js +128 -0
  35. package/dist/session-manager.d.ts +1 -0
  36. package/dist/session-manager.js +24 -0
  37. package/dist/tm1-client/connection/profile.js +2 -4
  38. package/dist/tm1-client/dispatcher.d.ts +1 -1
  39. package/dist/tm1-client/dispatcher.js +18 -6
  40. package/dist/tm1-client/http.d.ts +21 -0
  41. package/dist/tm1-client/http.js +39 -30
  42. package/dist/tm1-client/services/cell-service.d.ts +9 -7
  43. package/dist/tm1-client/services/cell-service.js +51 -25
  44. package/dist/tm1-client/services/chore-service.js +8 -9
  45. package/dist/tm1-client/services/cube-service.d.ts +14 -18
  46. package/dist/tm1-client/services/cube-service.js +51 -45
  47. package/dist/tm1-client/services/dimension-order.d.ts +13 -0
  48. package/dist/tm1-client/services/dimension-order.js +42 -0
  49. package/dist/tm1-client/services/dimension-service.js +4 -6
  50. package/dist/tm1-client/services/element-service.d.ts +35 -11
  51. package/dist/tm1-client/services/element-service.js +124 -60
  52. package/dist/tm1-client/services/file-service.d.ts +42 -6
  53. package/dist/tm1-client/services/file-service.js +225 -28
  54. package/dist/tm1-client/services/hierarchy-service.d.ts +19 -9
  55. package/dist/tm1-client/services/hierarchy-service.js +118 -12
  56. package/dist/tm1-client/services/monitoring-service.js +2 -5
  57. package/dist/tm1-client/services/odata-page.d.ts +6 -0
  58. package/dist/tm1-client/services/odata-page.js +8 -0
  59. package/dist/tm1-client/services/process-service.d.ts +1 -1
  60. package/dist/tm1-client/services/process-service.js +26 -26
  61. package/dist/tm1-client/services/security-service.js +8 -9
  62. package/dist/tm1-client/services/server-service.js +14 -15
  63. package/dist/tm1-client/services/subset-service.d.ts +23 -10
  64. package/dist/tm1-client/services/subset-service.js +82 -26
  65. package/dist/tm1-client/services/view-service.js +12 -13
  66. package/dist/tm1-client.js +5 -2
  67. package/dist/tools/analysis/check-v12-readiness.js +3 -3
  68. package/dist/tools/celldata/check-feeders.js +6 -6
  69. package/dist/tools/celldata/check-writable-coords.js +33 -22
  70. package/dist/tools/celldata/execute-mdx.js +10 -9
  71. package/dist/tools/celldata/get-view.js +10 -9
  72. package/dist/tools/celldata/member-ref.d.ts +15 -0
  73. package/dist/tools/celldata/member-ref.js +67 -0
  74. package/dist/tools/celldata/trace-cell-calculation.js +6 -6
  75. package/dist/tools/celldata/trace-feeders.js +6 -6
  76. package/dist/tools/celldata/write-cells.js +62 -12
  77. package/dist/tools/dimension-management/create-element-attribute.js +1 -1
  78. package/dist/tools/dimension-management/delete-hierarchy.js +3 -4
  79. package/dist/tools/dimension-management/get-element-attribute-values.js +7 -2
  80. package/dist/tools/dimension-management/list-element-attributes.js +1 -1
  81. package/dist/tools/dimension-management/update-element-attribute-value.js +8 -3
  82. package/dist/tools/dimension-management/update-element.js +14 -7
  83. package/dist/tools/fileops/container.d.ts +8 -0
  84. package/dist/tools/fileops/container.js +11 -0
  85. package/dist/tools/fileops/delete-file.js +4 -2
  86. package/dist/tools/fileops/get-file-content.js +45 -7
  87. package/dist/tools/fileops/list-files.js +5 -2
  88. package/dist/tools/fileops/search-files.js +4 -1
  89. package/dist/tools/fileops/upload-file.js +5 -2
  90. package/dist/tools/index.js +0 -2
  91. package/dist/tools/model-building/check-cube-rule.js +3 -2
  92. package/dist/tools/model-building/clear-cube.js +16 -27
  93. package/dist/tools/model-building/set-cube-rules.js +13 -20
  94. package/dist/tools/model-building/unload-cube.js +1 -1
  95. package/dist/tools/operations/get-cube-stats.js +1 -1
  96. package/dist/tools/operations/get-transaction-log.js +1 -1
  97. package/dist/tools/operations/list-error-logs.js +27 -2
  98. package/dist/tools/schemas/items-fileops.d.ts +4 -0
  99. package/dist/tools/schemas/items-fileops.js +1 -0
  100. package/dist/tools/security/list-clients.js +5 -5
  101. package/dist/tools/security/list-groups.js +1 -1
  102. package/dist/tools/subsets/create-subset.js +9 -5
  103. package/dist/tools/subsets/delete-subset.js +8 -3
  104. package/dist/tools/subsets/update-subset.js +9 -9
  105. package/dist/tools/ti-development/diff-process-with-file.js +4 -29
  106. package/dist/tools/ti-development/diff-processes.js +9 -0
  107. package/dist/tools/ti-development/export-process-to-git.js +9 -7
  108. package/dist/tools/ti-development/export-process-to-pro.js +15 -8
  109. package/dist/tools/ti-development/import-pro-file.js +10 -21
  110. package/dist/tools/ti-development/import-process-from-git.js +8 -18
  111. package/dist/tools/ti-development/install-pro-bundle.js +2 -2
  112. package/dist/tools/ti-development/upsert-process.js +26 -5
  113. package/npm-shrinkwrap.json +245 -9
  114. package/package.json +10 -5
  115. package/dist/tools/dimension-management/move-element.d.ts +0 -2
  116. package/dist/tools/dimension-management/move-element.js +0 -28
package/CHANGELOG.md CHANGED
@@ -7,6 +7,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [8.1.0] - 2026-09-28
11
+
12
+ ### Added
13
+
14
+ - Connection secrets can live in the OS keychain instead of the `.env`: set `TM1_SECRETS=keychain`
15
+ in a connection folder and store the secrets with `npx tm1-mcp-server secrets set|list|delete`,
16
+ or move an existing `.env` over with `secrets migrate <connection>`. The keychain is read the
17
+ first time a connection is used, never when connections are listed. At startup the server warns
18
+ about connections that still keep plaintext secrets. See
19
+ [docs/CONFIGURATION.md](docs/CONFIGURATION.md#secrets-in-the-os-keychain--tm1_secretskeychain).
20
+
21
+ ### Changed
22
+
23
+ - **`tm1_clear_cube` no longer declares `dimensions`/`tuples`.** They are dropped like any
24
+ unknown input, so an old call that still sends them empties the whole cube once `confirm`
25
+ matches. *Action:* remove them from stored calls; a partial clear is a TI process.
26
+
27
+ ## [8.0.0] - 2026-09-28
28
+
29
+ Merged upstream tm1-mcp-server 5.0.0 (flameY3T1, 2026-09-27). Where both lines had
30
+ built the same thing, upstream's behaviour was taken; fork-only features (connection
31
+ registry, `tm1_delete_elements`, rules patch mode, batch attribute writes, the
32
+ response-size guard, the per-connection caches) are unchanged.
33
+
34
+ ### Breaking
35
+
36
+ - **`tm1_move_element` is removed.** It attached the element to the new parent but left it
37
+ under the old one. *Action:* use `tm1_update_element` on both parents' `components`.
38
+ - **`tm1_clear_cube` takes only `cubeName` and `confirm`** (plus `timeoutMs`). The
39
+ `dimensions` and `tuples` inputs advertised a region clear the server cannot do: the only
40
+ route that works is a TI with `CubeClearData`, which empties the whole cube. A call that
41
+ still carries them is refused and nothing is cleared. *Action:* use a TI process for a
42
+ partial clear.
43
+ - **`tm1_write_cells` refuses a consolidated coordinate.** Every coordinate is checked before
44
+ anything is sent; write the leaves or run a TI process.
45
+ - **`tm1_set_cube_rules` fails with `VALIDATION_ERROR` when the preflight finds syntax
46
+ errors** (the errors are in `details`), instead of returning an `isError` payload. The check
47
+ still runs on the full resulting text, so an `edits` patch is checked as installed.
48
+ *Action:* `preflight: false` writes the text as is.
49
+ - **Exported files hold the process code unmasked.** `'***'` in written files broke the
50
+ round-trip; `maskSecrets` now affects only the inline response of
51
+ `tm1_export_process_to_git`. Keep such files out of version control if the code contains
52
+ password literals.
53
+ - **An unknown `TM1_MCP_TRANSPORT` or `TM1_LOG_LEVEL` stops the server at startup** instead of
54
+ falling back silently. Both are server-wide; a bad value in one connection's `.env` cannot
55
+ occur because folders may not set them.
56
+ - **Unknown attributes are `NOT_FOUND`** in `tm1_update_element_attribute_value` (was
57
+ `VALIDATION_ERROR`), and attribute names match ignoring case and spaces, as TM1 does.
58
+
59
+ ### Added
60
+
61
+ - **The five file tools reach the Applications tree** via `container: "applications"`.
62
+ - **`tm1_get_file_content` takes `encoding: "base64"`** for binary files. Its `maxBytes` default
63
+ is 48 KB there (64 KB for text), so a default read fits the 80k response limit.
64
+ - **Subset tools take `isPrivate`.**
65
+ - **`tm1_upsert_process` takes `variablesUIData`** and warns when the kept column layout no
66
+ longer fits the variables.
67
+ - **`tm1_clear_cube` takes `timeoutMs`** (1 s to 1 h).
68
+ - **Feeder and cell-trace tools address alternate hierarchies** with `Hierarchy:Element`.
69
+ - **The attribute value tools take `hierarchyName`** for alternate hierarchies (single element
70
+ and `updates[]`).
71
+
72
+ ### Fixed
73
+
74
+ - **A rejected login is never retried.** A 401/403 on login latches for that connection, so
75
+ wrong credentials no longer lock the account; fix the `.env` and restart.
76
+ - **MCP SDK 1.30.1**; `fast-uri` out of the vulnerable range; `npm run verify` runs
77
+ `npm audit` first.
78
+ - **The ReDoS guard rejects repeated alternations** such as `^(\w|\w)*!$`.
79
+ - **The HTTP transport caps a request body at 64 MB** (413) and accepts `Host: localhost:<port>`
80
+ and `[::1]:<port>`.
81
+ - **A v12 connection no longer demands `TM1_PASSWORD`** for auth modes that never send one.
82
+ - **A TM1 request can wait longer than five minutes**; `timeoutMs` is the only limit.
83
+ - **`tm1_write_cells` no longer writes when its own pre-check failed.**
84
+ - **`tm1_check_writable_coords` understands `[Dimension].[Hierarchy].[Element]`** and looks in
85
+ the hierarchy the write would hit.
86
+ - **`fetchAll` and `limit: 0` on `tm1_execute_mdx` and `tm1_get_view` stop at 5000 cells.**
87
+ - **`tm1_check_cube_rule` returns syntax errors as a normal result**, not as a tool error.
88
+ - **`tm1_clear_cube` tells the truth on a timeout**: the clear finishes on the server.
89
+ - **`tm1_update_subset` can change the element list** (replaces, in order).
90
+ - **`tm1_get_descendants` and `tm1_get_ancestors` load only the subtree**, and `leavesOnly`
91
+ no longer returns empty consolidations.
92
+ - **`tm1_update_element` reports type conversions.**
93
+ - **Process imports remove what the source no longer has** (parameters, variables, data
94
+ source); a `.pro` round trip keeps trailing whitespace.
95
+ - **Process analysis reads variable names with `.`, `$`, `%` and backtick, and `;;` inside a
96
+ string literal.**
97
+ - **Both diff tools compare the delimiter type and the ODBC unicode flag.**
98
+ - **`tm1_list_error_logs` reads v12 log names**, and `groupBy: "process"` passes schema checks.
99
+ - **`tm1_list_clients` and `tm1_list_groups` show names in markdown.**
100
+ - **`tm1_check_v12_readiness` gives the right reason for `SetODBCUnicodeInterface`.**
101
+ - **Seven tools no longer claim to be v11-only**, among them `tm1_import_pro_file` and
102
+ `tm1_install_pro_bundle`.
103
+ - **Corrected descriptions** of `tm1_unload_cube`, `tm1_clear_cube`, `tm1_delete_hierarchy`,
104
+ `tm1_get_cube_stats` and `tm1_get_transaction_log`.
105
+ - **The wire contracts were re-recorded against 11.8** upstream and merged with the fork's
106
+ own recordings; the recorder now merges by default (`--replace` starts over).
107
+
10
108
  ## [7.0.2] - 2026-09-27
11
109
 
12
110
  ### Fixed
@@ -1324,7 +1422,9 @@ Initial public release.
1324
1422
  - Quality gates: strict typecheck, ESLint, `lint:no-flat-api`,
1325
1423
  annotation-coverage, and tool-registration wiring.
1326
1424
 
1327
- [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.2...HEAD
1425
+ [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v8.1.0...HEAD
1426
+ [8.1.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v8.0.0...v8.1.0
1427
+ [8.0.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.2...v8.0.0
1328
1428
  [7.0.2]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.1...v7.0.2
1329
1429
  [7.0.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v7.0.0...v7.0.1
1330
1430
  [7.0.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.1...v7.0.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
- 112 tools across 12 categories — every one listed in
21
+ 111 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
 
@@ -45,7 +45,7 @@ published as `@ffschrattenecker/tm1-mcp-server`. The command is still `tm1-mcp-s
45
45
  in chat, plus five starting-point prompts.
46
46
 
47
47
  > **Token tip:** every registered tool's name + input schema costs context on
48
- > *each* turn, so exposing all 114 is wasteful when a session needs a slice.
48
+ > *each* turn, so exposing all 113 is wasteful when a session needs a slice.
49
49
  > Narrow the surface with your client's tool filter; `TM1_MODE=readonly` already
50
50
  > trims it to read-only tools.
51
51
 
@@ -171,7 +171,10 @@ its report, just redacted.
171
171
  Copy `mcp.json.example` to `.mcp.json` (project-local) or merge it into
172
172
  `~/.claude/settings.json`. **Do not put `TM1_PASSWORD` in either file** — those
173
173
  are routinely shared or committed; keep it in a gitignored `.env`
174
- ([where the server looks for one](docs/CONFIGURATION.md#where-credentials-are-read-from)).
174
+ ([where the server looks for one](docs/CONFIGURATION.md#where-credentials-are-read-from)),
175
+ or out of files altogether in the OS keychain with
176
+ `npx tm1-mcp-server secrets migrate <connection>`
177
+ ([details](docs/CONFIGURATION.md#secrets-in-the-os-keychain--tm1_secretskeychain)).
175
178
 
176
179
  For the `npx` install:
177
180
 
@@ -218,7 +221,7 @@ security notes and the `autoApprove` allowlist:
218
221
 
219
222
  <!-- TOOLS-AUTOGEN:START -->
220
223
 
221
- ## Tools (112)
224
+ ## Tools (111)
222
225
 
223
226
  Names and one-line descriptions: [docs/TOOLS.md](docs/TOOLS.md).
224
227
 
@@ -226,7 +229,7 @@ Names and one-line descriptions: [docs/TOOLS.md](docs/TOOLS.md).
226
229
  |---|---|
227
230
  | analysis | 10 |
228
231
  | celldata | 10 |
229
- | dimension-management | 14 |
232
+ | dimension-management | 13 |
230
233
  | fileops | 5 |
231
234
  | metadata | 9 |
232
235
  | model-building | 9 |
@@ -236,7 +239,7 @@ Names and one-line descriptions: [docs/TOOLS.md](docs/TOOLS.md).
236
239
  | subsets | 5 |
237
240
  | ti-development | 17 |
238
241
  | views | 4 |
239
- | **Total** | **112** |
242
+ | **Total** | **111** |
240
243
 
241
244
  <!-- TOOLS-AUTOGEN:END -->
242
245
 
package/dist/config.d.ts CHANGED
@@ -35,7 +35,16 @@ export interface TM1Config {
35
35
  export declare const DEFAULT_MAX_RESPONSE_CHARS = 80000;
36
36
  export type ServerSettings = Pick<TM1Config, "logLevel" | "logFile" | "transport" | "httpHost" | "httpPort" | "httpAllowedOrigins" | "httpToken" | "responseMode" | "maxResponseChars">;
37
37
  export declare function loadServerSettings(env?: NodeJS.ProcessEnv): ServerSettings;
38
- export declare function loadConfig(env?: NodeJS.ProcessEnv): TM1Config;
38
+ export interface LoadConfigOptions {
39
+ /**
40
+ * The secrets are not in `env` yet: they are read from the OS keychain when
41
+ * the connection is first used (see ./secrets.ts), and loadConfig runs again
42
+ * then, with them. Until then a missing secret is not an error, and neither
43
+ * is a missing TM1_USER — the keychain may hold a TM1_CAM_PASSPORT.
44
+ */
45
+ deferSecrets?: boolean;
46
+ }
47
+ export declare function loadConfig(env?: NodeJS.ProcessEnv, options?: LoadConfigOptions): TM1Config;
39
48
  /**
40
49
  * Stable label for a connection: host and port, plus the v12
41
50
  * instance/database. Keys per-connection state (process backups, the callgraph
package/dist/config.js CHANGED
@@ -24,15 +24,21 @@ function parseIntEnv(name, raw, def) {
24
24
  return n;
25
25
  }
26
26
  export function loadServerSettings(env = process.env) {
27
- const logLevelRaw = env.TM1_LOG_LEVEL ?? "info";
28
- const logLevel = VALID_LOG_LEVELS.includes(logLevelRaw)
29
- ? logLevelRaw
30
- : "info";
27
+ // Same parse shape as TM1_MODE: case-insensitive, an unknown value throws at
28
+ // startup instead of silently falling back to the default.
29
+ const logLevelRaw = (env.TM1_LOG_LEVEL ?? "info").trim().toLowerCase();
30
+ if (!VALID_LOG_LEVELS.includes(logLevelRaw)) {
31
+ throw new Error(`Invalid TM1_LOG_LEVEL: "${env.TM1_LOG_LEVEL}". Expected one of ${VALID_LOG_LEVELS.join(", ")}.`);
32
+ }
33
+ const logLevel = logLevelRaw;
31
34
  const logFile = env.TM1_LOG_FILE || undefined;
32
- const transportRaw = env.TM1_MCP_TRANSPORT ?? "stdio";
33
- const transport = VALID_TRANSPORTS.includes(transportRaw)
34
- ? transportRaw
35
- : "stdio";
35
+ // A typo here used to start on stdio without a word: the expected /mcp port
36
+ // never bound and the operator saw only a client that could not connect.
37
+ const transportRaw = (env.TM1_MCP_TRANSPORT ?? "stdio").trim().toLowerCase();
38
+ if (!VALID_TRANSPORTS.includes(transportRaw)) {
39
+ throw new Error(`Invalid TM1_MCP_TRANSPORT: "${env.TM1_MCP_TRANSPORT}". Expected "stdio" or "http".`);
40
+ }
41
+ const transport = transportRaw;
36
42
  // Default to loopback. Binding to 0.0.0.0 must be opt-in to avoid exposing
37
43
  // a TM1-credentialed MCP server to the LAN by accident.
38
44
  const httpHost = env.TM1_MCP_HTTP_HOST || "127.0.0.1";
@@ -100,7 +106,8 @@ export function loadServerSettings(env = process.env) {
100
106
  }
101
107
  // `env` defaults to the process environment. The multi-connection registry
102
108
  // passes one record per connection folder instead (see ./connections.ts).
103
- export function loadConfig(env = process.env) {
109
+ export function loadConfig(env = process.env, options = {}) {
110
+ const deferred = options.deferSecrets === true;
104
111
  const baseUrl = env.TM1_BASE_URL;
105
112
  const user = env.TM1_USER;
106
113
  const password = env.TM1_PASSWORD;
@@ -113,6 +120,22 @@ export function loadConfig(env = process.env) {
113
120
  // obtained out-of-band via TM1_CAM_PASSPORT instead.
114
121
  const namespace = env.TM1_NAMESPACE || undefined;
115
122
  const camPassport = env.TM1_CAM_PASSPORT || undefined;
123
+ // Which connection this is has to be known BEFORE the required-variable
124
+ // check below, because the answer decides which credential is required.
125
+ const tm1Version = env.TM1_VERSION || "11.8";
126
+ const instance = env.TM1_INSTANCE || undefined;
127
+ const database = env.TM1_DATABASE || undefined;
128
+ const versionMajor = Number.parseInt(tm1Version, 10);
129
+ const isV12 = Boolean(instance || database) || versionMajor === 12;
130
+ const version = isV12 ? 12 : 11;
131
+ // Every v12 auth mode except "basic" authenticates with a client secret, a
132
+ // bearer token or an API key, and the session login sends no password at all
133
+ // (connection/profile.ts buildV12Authorization). Demanding TM1_PASSWORD there
134
+ // rejects exactly the configuration docs/CONFIGURATION.md prescribes, and it
135
+ // does so at startup, before the server can say anything more useful. The v12
136
+ // block below still requires whichever credential the chosen mode does need.
137
+ const v12AuthMode = (env.TM1_AUTH_MODE ?? "s2s").trim().toLowerCase();
138
+ const passwordlessV12 = version === 12 && v12AuthMode !== "basic";
116
139
  // Required: baseUrl always. user/password only when NOT using a passport — a
117
140
  // passport carries the authenticated identity, so TM1 needs no credentials.
118
141
  // Empty strings are rejected (treated as unset). Password may be empty — some
@@ -121,17 +144,18 @@ export function loadConfig(env = process.env) {
121
144
  const missing = [];
122
145
  if (!baseUrl)
123
146
  missing.push("TM1_BASE_URL");
124
- if (!camPassport) {
147
+ if (!camPassport && !deferred) {
125
148
  if (!user)
126
149
  missing.push("TM1_USER");
127
- if (password === undefined)
150
+ if (password === undefined && !passwordlessV12) {
128
151
  missing.push("TM1_PASSWORD");
152
+ }
129
153
  }
130
154
  if (missing.length > 0) {
131
155
  throw new Error(`Missing or empty required environment variables: ${missing.join(", ")}. ` +
132
156
  `Set them in your shell or .env file before starting the server.`);
133
157
  }
134
- if (!camPassport && password === "") {
158
+ if (!camPassport && !passwordlessV12 && !deferred && password === "") {
135
159
  process.stderr.write("[tm1-mcp-server] WARNING: TM1_PASSWORD is empty. " +
136
160
  "If TM1 rejects with 401, check whether the account actually allows blank passwords.\n");
137
161
  }
@@ -140,7 +164,6 @@ export function loadConfig(env = process.env) {
140
164
  const keepAliveIntervalMs = parseIntEnv("TM1_KEEP_ALIVE_INTERVAL", env.TM1_KEEP_ALIVE_INTERVAL, 60000);
141
165
  const requestTimeoutMs = parseIntEnv("TM1_REQUEST_TIMEOUT", env.TM1_REQUEST_TIMEOUT, 30000);
142
166
  const server = loadServerSettings(env);
143
- const tm1Version = env.TM1_VERSION || "11.8";
144
167
  // Case-insensitive so a `TM1_MODE=ReadWrite` typo resolves to readwrite rather
145
168
  // than silently falling back to readonly (dropping every write tool without a
146
169
  // word). A genuinely-unknown value throws at startup — parity with the numeric
@@ -164,11 +187,6 @@ export function loadConfig(env = process.env) {
164
187
  "TM1_ENVIRONMENT=prod forces readonly; set TM1_ALLOW_PROD_WRITES=true to allow writes.";
165
188
  }
166
189
  // --- v12 (Planning Analytics Engine) connection ---------------------------
167
- const instance = env.TM1_INSTANCE || undefined;
168
- const database = env.TM1_DATABASE || undefined;
169
- const versionMajor = Number.parseInt(tm1Version, 10);
170
- const isV12 = Boolean(instance || database) || versionMajor === 12;
171
- const version = isV12 ? 12 : 11;
172
190
  // Keep the DISPLAY string (server_info, logs) consistent with the numeric
173
191
  // `version`: a v12 connection (isV12, via TM1_INSTANCE/TM1_DATABASE) declared
174
192
  // with a v11-looking TM1_VERSION="11.8" would otherwise report "11.8" to users
@@ -189,7 +207,7 @@ export function loadConfig(env = process.env) {
189
207
  if (!database) {
190
208
  throw new Error("v12 connection requires TM1_DATABASE (set alongside TM1_INSTANCE).");
191
209
  }
192
- const authModeRaw = (env.TM1_AUTH_MODE ?? "s2s").trim().toLowerCase();
210
+ const authModeRaw = v12AuthMode;
193
211
  if (!VALID_AUTH_MODES.includes(authModeRaw)) {
194
212
  throw new Error(`Invalid TM1_AUTH_MODE: "${env.TM1_AUTH_MODE}". ` +
195
213
  `Expected one of: ${VALID_AUTH_MODES.join(", ")}.`);
@@ -212,15 +230,15 @@ export function loadConfig(env = process.env) {
212
230
  if (authMode === "s2s") {
213
231
  if (!clientId)
214
232
  missingV12.push("TM1_CLIENT_ID");
215
- if (!clientSecret)
233
+ if (!clientSecret && !deferred)
216
234
  missingV12.push("TM1_CLIENT_SECRET");
217
235
  }
218
236
  else if (authMode === "access_token" || authMode === "oidc") {
219
- if (!accessToken)
237
+ if (!accessToken && !deferred)
220
238
  missingV12.push("TM1_ACCESS_TOKEN");
221
239
  }
222
240
  else if (authMode === "iam") {
223
- if (!apiKey)
241
+ if (!apiKey && !deferred)
224
242
  missingV12.push("TM1_API_KEY");
225
243
  if (!iamUrl)
226
244
  missingV12.push("TM1_IAM_URL");
@@ -1,6 +1,21 @@
1
1
  import pino from "pino";
2
2
  import { type TM1Config } from "./config.js";
3
+ import { type SecretStore } from "./secrets.js";
3
4
  import { TM1Client } from "./tm1-client.js";
5
+ /** Folder holding one `<name>/.env` per connection. */
6
+ export declare function connectionsDir(env: NodeJS.ProcessEnv): string;
7
+ /**
8
+ * The environment one connection folder resolves to — the only way a
9
+ * connection's `.env` is read, by the server and by scripts/run-live-for.ts
10
+ * alike, so a script can never log in with a differently parsed password.
11
+ *
12
+ * Server-level settings and non-TM1 variables (PATH, proxies) carry over from
13
+ * `env`; connection-level TM1_* keys come from the folder only, so a stray
14
+ * TM1_INSTANCE in the shell cannot reroute a v11 connection. The folder may
15
+ * not override server-level settings: one folder's TM1_MCP_TRANSPORT must not
16
+ * reconfigure the whole process.
17
+ */
18
+ export declare function connectionEnv(folder: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
4
19
  export interface ConnectionInfo {
5
20
  name: string;
6
21
  /** Folder the `.env` came from; undefined for the legacy single connection. */
@@ -16,6 +31,8 @@ export interface ConnectionInfo {
16
31
  connectionId?: string | undefined;
17
32
  /** Set when the folder's `.env` could not be turned into a config. */
18
33
  configError?: string | undefined;
34
+ /** "keychain" when the secrets are read from the OS keychain on first use. */
35
+ secrets?: "keychain" | undefined;
19
36
  }
20
37
  export interface ConnectionStatus extends ConnectionInfo {
21
38
  connected: boolean;
@@ -23,10 +40,14 @@ export interface ConnectionStatus extends ConnectionInfo {
23
40
  }
24
41
  export declare class ConnectionRegistry {
25
42
  private readonly logger;
43
+ /** Defaults to the OS keychain; tests inject a fake. */
44
+ private readonly secretStore?;
26
45
  private readonly entries;
27
46
  private constructor();
28
47
  /** Discover connections from the environment (see the header comment). */
29
- static fromEnvironment(env: NodeJS.ProcessEnv, logger: pino.Logger): ConnectionRegistry;
48
+ static fromEnvironment(env: NodeJS.ProcessEnv, logger: pino.Logger, options?: {
49
+ secretStore?: SecretStore;
50
+ }): ConnectionRegistry;
30
51
  /** Wrap one prebuilt client (tests, embedders). */
31
52
  static single(client: TM1Client, logger?: pino.Logger): ConnectionRegistry;
32
53
  /**
@@ -40,6 +61,8 @@ export declare class ConnectionRegistry {
40
61
  client: TM1Client;
41
62
  mode?: TM1Config["mode"];
42
63
  }>, logger?: pino.Logger): ConnectionRegistry;
64
+ /** Validate one connection's env; keychain secrets are read later. */
65
+ private addEnv;
43
66
  private addConfig;
44
67
  private discover;
45
68
  /** Every connection name, including ones whose config failed to load. */
@@ -59,9 +82,14 @@ export declare class ConnectionRegistry {
59
82
  /**
60
83
  * The client for a connection, built and logged in on first use. A failed
61
84
  * login does not poison the entry: the client's own request path retries
62
- * authentication, so the next call gets a fresh attempt.
85
+ * authentication, so the next call gets a fresh attempt — except when TM1
86
+ * rejected the credentials (401/403). The client is kept, so its
87
+ * SessionManager's latch answers every later call without another login
88
+ * until the server restarts.
63
89
  */
64
90
  get(name: string | undefined): Promise<TM1Client>;
91
+ /** The full config of a keychain connection, secrets included. */
92
+ private resolveSecrets;
65
93
  /** Connection metadata for a resolved call (mode/version gates). */
66
94
  resolveInfo(name: string | undefined): ConnectionInfo;
67
95
  disconnectAll(): Promise<void>;
@@ -18,12 +18,17 @@
18
18
  // defaults to readonly: a connection-level key (TM1_MODE, TM1_BASE_URL, …) in
19
19
  // the server's own environment is NOT inherited, so a TM1_MODE=readwrite in
20
20
  // the launching shell cannot silently arm every connection.
21
+ //
22
+ // A folder with TM1_SECRETS=keychain keeps its secrets in the OS keychain
23
+ // (see ./secrets.ts). Discovery validates it without them; get() reads them
24
+ // and builds the real config on first use.
21
25
  import { existsSync, readdirSync, readFileSync } from "node:fs";
22
26
  import { homedir } from "node:os";
23
27
  import { join } from "node:path";
24
28
  import { parse as parseDotenv } from "dotenv";
25
29
  import pino from "pino";
26
30
  import { connectionIdOf, loadConfig } from "./config.js";
31
+ import { keychainHint, plaintextSecretKeys, usesKeychain, withKeychainSecrets, } from "./secrets.js";
27
32
  import { SessionManager } from "./session-manager.js";
28
33
  import { TM1Client } from "./tm1-client.js";
29
34
  import { TM1Error, TM1ErrorCode } from "./types.js";
@@ -39,21 +44,53 @@ const SERVER_LEVEL_KEYS = new Set([
39
44
  function isServerLevelKey(key) {
40
45
  return SERVER_LEVEL_KEYS.has(key) || key.startsWith("TM1_MCP_");
41
46
  }
47
+ /** Folder holding one `<name>/.env` per connection. */
48
+ export function connectionsDir(env) {
49
+ return env.TM1_CONNECTIONS_DIR || join(homedir(), ".tm1", "mcp-servers");
50
+ }
51
+ /**
52
+ * The environment one connection folder resolves to — the only way a
53
+ * connection's `.env` is read, by the server and by scripts/run-live-for.ts
54
+ * alike, so a script can never log in with a differently parsed password.
55
+ *
56
+ * Server-level settings and non-TM1 variables (PATH, proxies) carry over from
57
+ * `env`; connection-level TM1_* keys come from the folder only, so a stray
58
+ * TM1_INSTANCE in the shell cannot reroute a v11 connection. The folder may
59
+ * not override server-level settings: one folder's TM1_MCP_TRANSPORT must not
60
+ * reconfigure the whole process.
61
+ */
62
+ export function connectionEnv(folder, env) {
63
+ const connEnv = {};
64
+ for (const [key, value] of Object.entries(env)) {
65
+ if (!key.startsWith("TM1_") || isServerLevelKey(key))
66
+ connEnv[key] = value;
67
+ }
68
+ const fileEnv = parseDotenv(readFileSync(join(folder, ".env")));
69
+ for (const [key, value] of Object.entries(fileEnv)) {
70
+ if (!isServerLevelKey(key))
71
+ connEnv[key] = value;
72
+ }
73
+ return connEnv;
74
+ }
42
75
  export class ConnectionRegistry {
43
76
  logger;
77
+ secretStore;
44
78
  entries = new Map();
45
- constructor(logger) {
79
+ constructor(logger,
80
+ /** Defaults to the OS keychain; tests inject a fake. */
81
+ secretStore) {
46
82
  this.logger = logger;
83
+ this.secretStore = secretStore;
47
84
  }
48
85
  /** Discover connections from the environment (see the header comment). */
49
- static fromEnvironment(env, logger) {
50
- const registry = new ConnectionRegistry(logger);
86
+ static fromEnvironment(env, logger, options = {}) {
87
+ const registry = new ConnectionRegistry(logger, options.secretStore);
51
88
  const explicitDir = env.TM1_CONNECTIONS_DIR;
52
89
  if (!explicitDir && env.TM1_BASE_URL) {
53
- registry.addConfig("default", loadConfig(env));
90
+ registry.addEnv("default", env);
54
91
  return registry;
55
92
  }
56
- const dir = explicitDir || join(homedir(), ".tm1", "mcp-servers");
93
+ const dir = connectionsDir(env);
57
94
  registry.discover(dir, env);
58
95
  if (registry.entries.size === 0) {
59
96
  throw new Error(`No TM1 connections found. Set TM1_BASE_URL (single connection) or ` +
@@ -95,6 +132,17 @@ export class ConnectionRegistry {
95
132
  }
96
133
  return registry;
97
134
  }
135
+ /** Validate one connection's env; keychain secrets are read later. */
136
+ addEnv(name, env, dir) {
137
+ const keychain = usesKeychain(env);
138
+ const config = loadConfig(env, { deferSecrets: keychain });
139
+ this.addConfig(name, config, dir);
140
+ if (keychain) {
141
+ const entry = this.entries.get(name);
142
+ entry.env = env;
143
+ entry.info.secrets = "keychain";
144
+ }
145
+ }
98
146
  addConfig(name, config, dir) {
99
147
  this.entries.set(name, {
100
148
  info: {
@@ -119,30 +167,21 @@ export class ConnectionRegistry {
119
167
  .map((s) => s.trim())
120
168
  .filter(Boolean))
121
169
  : undefined;
122
- // Server-level settings and non-TM1 variables (PATH, proxies) carry over;
123
- // connection-level TM1_* keys come from the folder only.
124
- const base = {};
125
- for (const [key, value] of Object.entries(env)) {
126
- if (!key.startsWith("TM1_") || isServerLevelKey(key))
127
- base[key] = value;
128
- }
129
170
  const names = readdirSync(dir, { withFileTypes: true })
130
171
  .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, ".env")))
131
172
  .map((d) => d.name)
132
173
  .filter((name) => !only || only.has(name))
133
174
  .sort((a, b) => a.localeCompare(b));
175
+ const plaintext = [];
134
176
  for (const name of names) {
135
177
  const folder = join(dir, name);
136
178
  try {
137
- const fileEnv = parseDotenv(readFileSync(join(folder, ".env")));
138
- const connEnv = { ...base };
139
- for (const [key, value] of Object.entries(fileEnv)) {
140
- // The folder may not override server-level settings: one folder's
141
- // TM1_MCP_TRANSPORT must not reconfigure the whole process.
142
- if (!isServerLevelKey(key))
143
- connEnv[key] = value;
179
+ const connEnv = connectionEnv(folder, env);
180
+ this.addEnv(name, connEnv, folder);
181
+ if (!this.entries.get(name).env &&
182
+ plaintextSecretKeys(connEnv).length) {
183
+ plaintext.push(name);
144
184
  }
145
- this.addConfig(name, loadConfig(connEnv), folder);
146
185
  }
147
186
  catch (err) {
148
187
  const message = err instanceof Error ? err.message : String(err);
@@ -152,6 +191,10 @@ export class ConnectionRegistry {
152
191
  });
153
192
  }
154
193
  }
194
+ if (plaintext.length > 0) {
195
+ this.logger.warn({ connections: plaintext }, "plaintext secrets in .env — move them to the OS keychain with " +
196
+ "`npx tm1-mcp-server secrets migrate <connection>`");
197
+ }
155
198
  }
156
199
  /** Every connection name, including ones whose config failed to load. */
157
200
  get names() {
@@ -216,10 +259,26 @@ export class ConnectionRegistry {
216
259
  /**
217
260
  * The client for a connection, built and logged in on first use. A failed
218
261
  * login does not poison the entry: the client's own request path retries
219
- * authentication, so the next call gets a fresh attempt.
262
+ * authentication, so the next call gets a fresh attempt — except when TM1
263
+ * rejected the credentials (401/403). The client is kept, so its
264
+ * SessionManager's latch answers every later call without another login
265
+ * until the server restarts.
220
266
  */
221
267
  async get(name) {
222
268
  const entry = this.entryFor(name);
269
+ if (!entry.client && entry.env) {
270
+ // Concurrent first calls share one keychain read. A failure is not
271
+ // cached: the next call reads again, after the user stored the entry.
272
+ entry.resolving ??= this.resolveSecrets(entry)
273
+ .catch((err) => {
274
+ entry.lastError = err instanceof Error ? err.message : String(err);
275
+ throw err;
276
+ })
277
+ .finally(() => {
278
+ entry.resolving = undefined;
279
+ });
280
+ entry.config = await entry.resolving;
281
+ }
223
282
  if (!entry.client) {
224
283
  const config = entry.config;
225
284
  const logger = this.logger.child({ connection: entry.info.name });
@@ -242,6 +301,20 @@ export class ConnectionRegistry {
242
301
  await entry.connecting;
243
302
  return entry.client;
244
303
  }
304
+ /** The full config of a keychain connection, secrets included. */
305
+ async resolveSecrets(entry) {
306
+ const { name } = entry.info;
307
+ const env = await withKeychainSecrets(name, entry.env, this.secretStore);
308
+ try {
309
+ return loadConfig(env);
310
+ }
311
+ catch (err) {
312
+ throw new TM1Error({
313
+ code: TM1ErrorCode.VALIDATION_ERROR,
314
+ message: `Connection "${name}": ${err instanceof Error ? err.message : String(err)}${keychainHint(name)}`,
315
+ });
316
+ }
317
+ }
245
318
  /** Connection metadata for a resolved call (mode/version gates). */
246
319
  resolveInfo(name) {
247
320
  return this.entryFor(name).info;
@@ -16,8 +16,25 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
16
16
  //
17
17
  // Per MCP best practices: bind 127.0.0.1 by default and enable DNS-rebinding
18
18
  // protection. allowedHosts/Origins narrow what the transport accepts.
19
+ // Cap on a single /mcp request body. Without one the whole request is buffered
20
+ // and then copied again by Buffer.concat().toString(), so a large enough POST
21
+ // can take the process down before any tool-level limit applies. Sized above
22
+ // the biggest legitimate payload: tm1_upload_file accepts 32 MB of bytes, which
23
+ // is ~43 MB base64 plus JSON framing.
24
+ // ponytail: fixed constant, make it configurable if a deployment needs more.
25
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
19
26
  export async function startHttpTransport(buildServer, config, logger) {
20
- const allowedHost = `${config.httpHost}:${config.httpPort}`;
27
+ // The SDK compares the Host header as one string, port included, so every
28
+ // entry needs the port. Loopback names are safe to always allow: a rebinding
29
+ // attacker's page sends its own hostname, never one of these.
30
+ const allowedHosts = [
31
+ ...new Set([
32
+ `${config.httpHost}:${config.httpPort}`,
33
+ `127.0.0.1:${config.httpPort}`,
34
+ `localhost:${config.httpPort}`,
35
+ `[::1]:${config.httpPort}`,
36
+ ]),
37
+ ];
21
38
  if (!config.httpToken) {
22
39
  logger.warn("HTTP transport has no TM1_MCP_HTTP_TOKEN set — /mcp requests are unauthenticated. " +
23
40
  "Bind loopback only, or front the server with an authenticating reverse proxy.");
@@ -55,12 +72,42 @@ export async function startHttpTransport(buildServer, config, logger) {
55
72
  }
56
73
  let body;
57
74
  if (req.method === "POST") {
75
+ // Refuse on the declared length before a single byte is read; the
76
+ // counter below is the backstop for a chunked body that declares none.
77
+ const declared = Number(req.headers["content-length"]);
78
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
79
+ logger.warn({ declared, limit: MAX_BODY_BYTES }, "Request body too large");
80
+ res.statusCode = 413;
81
+ res.setHeader("Content-Type", "application/json");
82
+ // Close the connection: the client is still holding a body this
83
+ // server will never read, and keep-alive would leave it queued.
84
+ res.setHeader("Connection", "close");
85
+ res.end(JSON.stringify({
86
+ error: `Request body exceeds ${MAX_BODY_BYTES} bytes`,
87
+ }));
88
+ return;
89
+ }
58
90
  try {
59
91
  const chunks = [];
92
+ let size = 0;
60
93
  for await (const chunk of req) {
61
- chunks.push(typeof chunk === "string"
94
+ const buf = typeof chunk === "string"
62
95
  ? Buffer.from(chunk)
63
- : chunk);
96
+ : chunk;
97
+ size += buf.length;
98
+ if (size > MAX_BODY_BYTES) {
99
+ logger.warn({ limit: MAX_BODY_BYTES }, "Request body too large");
100
+ res.statusCode = 413;
101
+ res.setHeader("Content-Type", "application/json");
102
+ res.end(JSON.stringify({
103
+ error: `Request body exceeds ${MAX_BODY_BYTES} bytes`,
104
+ }));
105
+ // Answer first, then stop reading: the rest of the upload is
106
+ // dropped instead of being buffered behind an already-sent reply.
107
+ req.destroy();
108
+ return;
109
+ }
110
+ chunks.push(buf);
64
111
  }
65
112
  const raw = Buffer.concat(chunks).toString("utf8");
66
113
  body = raw ? JSON.parse(raw) : undefined;
@@ -80,7 +127,7 @@ export async function startHttpTransport(buildServer, config, logger) {
80
127
  // sessionIdGenerator omitted → stateless mode (single-use per request)
81
128
  enableJsonResponse: true,
82
129
  enableDnsRebindingProtection: true,
83
- allowedHosts: [allowedHost, "127.0.0.1", "localhost"],
130
+ allowedHosts,
84
131
  allowedOrigins: config.httpAllowedOrigins,
85
132
  });
86
133
  res.on("close", () => {