@ffschrattenecker/tm1-mcp-server 6.1.1 → 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 (63) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +23 -5
  3. package/dist/config.d.ts +9 -0
  4. package/dist/config.js +89 -64
  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 -40
  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 +1 -1
  27. package/dist/tm1-client.js +2 -11
  28. package/dist/tools/analysis/audit-naming.js +1 -1
  29. package/dist/tools/analysis/invalidate-callgraph-cache.js +2 -1
  30. package/dist/tools/analysis/trace-data-flow.js +1 -1
  31. package/dist/tools/confirm.js +1 -1
  32. package/dist/tools/define-tool.d.ts +35 -11
  33. package/dist/tools/define-tool.js +51 -4
  34. package/dist/tools/index.d.ts +2 -2
  35. package/dist/tools/index.js +6 -12
  36. package/dist/tools/metadata/list-cubes.js +3 -3
  37. package/dist/tools/metadata/list-processes.js +11 -7
  38. package/dist/tools/model-building/unload-cube.js +1 -1
  39. package/dist/tools/operations/get-audit-log.js +1 -1
  40. package/dist/tools/operations/get-jobs.js +2 -2
  41. package/dist/tools/operations/get-message-log.js +1 -1
  42. package/dist/tools/operations/get-server-state.js +1 -1
  43. package/dist/tools/operations/get-threads.js +2 -2
  44. package/dist/tools/operations/get-transaction-log.js +1 -1
  45. package/dist/tools/operations/list-connections.d.ts +2 -0
  46. package/dist/tools/operations/list-connections.js +35 -0
  47. package/dist/tools/operations/save-data.js +1 -1
  48. package/dist/tools/ti-development/execute-process.js +2 -2
  49. package/dist/tools/ti-development/import-pro-file.js +1 -1
  50. package/dist/tools/ti-development/search-code.js +1 -1
  51. package/dist/tools/ti-development/upsert-process.js +2 -2
  52. package/dist/tools/with-annotations.d.ts +10 -0
  53. package/dist/tools/with-annotations.js +58 -3
  54. package/npm-shrinkwrap.json +2 -2
  55. package/package.json +4 -3
  56. package/dist/tools/ti-development/get-process-code.d.ts +0 -2
  57. package/dist/tools/ti-development/get-process-code.js +0 -100
  58. package/dist/tools/ti-development/get-process-datasource.d.ts +0 -2
  59. package/dist/tools/ti-development/get-process-datasource.js +0 -20
  60. package/dist/tools/ti-development/get-process-parameters.d.ts +0 -2
  61. package/dist/tools/ti-development/get-process-parameters.js +0 -25
  62. package/dist/tools/ti-development/get-process-variables.d.ts +0 -2
  63. package/dist/tools/ti-development/get-process-variables.js +0 -38
package/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ 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
+
10
43
  ## [6.1.1] - 2026-09-26
11
44
 
12
45
  ### Fixed
@@ -1266,7 +1299,8 @@ Initial public release.
1266
1299
  - Quality gates: strict typecheck, ESLint, `lint:no-flat-api`,
1267
1300
  annotation-coverage, and tool-registration wiring.
1268
1301
 
1269
- [Unreleased]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.1...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
1270
1304
  [6.1.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.1.0...v6.1.1
1271
1305
  [6.1.0]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.0.1...v6.1.0
1272
1306
  [6.0.1]: https://github.com/ffschrattenecker/tm1-mcp-server/compare/v6.0.0...v6.0.1
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
 
@@ -108,6 +108,24 @@ Analytics Engine) connection and its auth modes, host-disk file access.
108
108
  > with `TM1_ENVIRONMENT=prod`: it then stays readonly even under
109
109
  > `TM1_MODE=readwrite`, unless `TM1_ALLOW_PROD_WRITES=true` is set as well.
110
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.
128
+
111
129
  Host-disk file access is default-off in the same spirit: the `.pro` and git
112
130
  tools accept inline content, and touch host paths only once
113
131
  `TM1_LOCAL_FILE_ROOT` names an allowed directory (paths outside it, and `..`
@@ -200,7 +218,7 @@ security notes and the `autoApprove` allowlist:
200
218
 
201
219
  <!-- TOOLS-AUTOGEN:START -->
202
220
 
203
- ## Tools (115)
221
+ ## Tools (112)
204
222
 
205
223
  Names and one-line descriptions: [docs/TOOLS.md](docs/TOOLS.md).
206
224
 
@@ -212,13 +230,13 @@ Names and one-line descriptions: [docs/TOOLS.md](docs/TOOLS.md).
212
230
  | fileops | 5 |
213
231
  | metadata | 9 |
214
232
  | model-building | 9 |
215
- | operations | 15 |
233
+ | operations | 16 |
216
234
  | scheduling | 5 |
217
235
  | security | 8 |
218
236
  | subsets | 5 |
219
- | ti-development | 21 |
237
+ | ti-development | 17 |
220
238
  | views | 4 |
221
- | **Total** | **115** |
239
+ | **Total** | **112** |
222
240
 
223
241
  <!-- TOOLS-AUTOGEN:END -->
224
242
 
package/dist/config.d.ts CHANGED
@@ -33,5 +33,14 @@ export interface TM1Config {
33
33
  iamUrl?: string | undefined;
34
34
  }
35
35
  export declare const DEFAULT_MAX_RESPONSE_CHARS = 80000;
36
+ export type ServerSettings = Pick<TM1Config, "logLevel" | "logFile" | "transport" | "httpHost" | "httpPort" | "httpAllowedOrigins" | "httpToken" | "responseMode" | "maxResponseChars">;
37
+ export declare function loadServerSettings(env?: NodeJS.ProcessEnv): ServerSettings;
36
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;
37
46
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -23,53 +23,12 @@ function parseIntEnv(name, raw, def) {
23
23
  }
24
24
  return n;
25
25
  }
26
- // `env` defaults to the process environment. The multi-connection registry
27
- // passes one record per connection folder instead (see ./connections.ts).
28
- export function loadConfig(env = process.env) {
29
- const baseUrl = env.TM1_BASE_URL;
30
- const user = env.TM1_USER;
31
- const password = env.TM1_PASSWORD;
32
- // CAM auth (mirrors TM1py's RestService._build_authorization_token):
33
- // TM1_CAM_PASSPORT set → "CAMPassport <token>" (no user/password round-trip)
34
- // TM1_NAMESPACE set → "CAMNamespace b64(u:p:ns)" (needs user + password + namespace)
35
- // neither → "Basic b64(u:p)" (native TM1)
36
- // SSO/gateway (Windows SSPI) is intentionally unsupported here: TM1py only does
37
- // it via the Windows-only requests_negotiate_sspi package. Supply a passport
38
- // obtained out-of-band via TM1_CAM_PASSPORT instead.
39
- const namespace = env.TM1_NAMESPACE || undefined;
40
- const camPassport = env.TM1_CAM_PASSPORT || undefined;
41
- // Required: baseUrl always. user/password only when NOT using a passport — a
42
- // passport carries the authenticated identity, so TM1 needs no credentials.
43
- // Empty strings are rejected (treated as unset). Password may be empty — some
44
- // TM1 setups allow a blank password for the admin account — so we warn but
45
- // don't block, letting the real 401 (if any) surface with context.
46
- const missing = [];
47
- if (!baseUrl)
48
- missing.push("TM1_BASE_URL");
49
- if (!camPassport) {
50
- if (!user)
51
- missing.push("TM1_USER");
52
- if (password === undefined)
53
- missing.push("TM1_PASSWORD");
54
- }
55
- if (missing.length > 0) {
56
- throw new Error(`Missing or empty required environment variables: ${missing.join(", ")}. ` +
57
- `Set them in your shell or .env file before starting the server.`);
58
- }
59
- if (!camPassport && password === "") {
60
- process.stderr.write("[tm1-mcp-server] WARNING: TM1_PASSWORD is empty. " +
61
- "If TM1 rejects with 401, check whether the account actually allows blank passwords.\n");
62
- }
63
- const sslRaw = env.TM1_SSL_REJECT_UNAUTHORIZED;
64
- const rejectUnauthorized = sslRaw === undefined ? true : sslRaw !== "false";
65
- const keepAliveIntervalMs = parseIntEnv("TM1_KEEP_ALIVE_INTERVAL", env.TM1_KEEP_ALIVE_INTERVAL, 60000);
66
- const requestTimeoutMs = parseIntEnv("TM1_REQUEST_TIMEOUT", env.TM1_REQUEST_TIMEOUT, 30000);
26
+ export function loadServerSettings(env = process.env) {
67
27
  const logLevelRaw = env.TM1_LOG_LEVEL ?? "info";
68
28
  const logLevel = VALID_LOG_LEVELS.includes(logLevelRaw)
69
29
  ? logLevelRaw
70
30
  : "info";
71
31
  const logFile = env.TM1_LOG_FILE || undefined;
72
- const tm1Version = env.TM1_VERSION || "11.8";
73
32
  const transportRaw = env.TM1_MCP_TRANSPORT ?? "stdio";
74
33
  const transport = VALID_TRANSPORTS.includes(transportRaw)
75
34
  ? transportRaw
@@ -114,6 +73,74 @@ export function loadConfig(env = process.env) {
114
73
  `Refusing to expose an unauthenticated /mcp endpoint beyond localhost — set ` +
115
74
  `TM1_MCP_HTTP_TOKEN, or bind to 127.0.0.1/localhost.`);
116
75
  }
76
+ // Same parse shape as TM1_MODE: case-insensitive, unknown value throws at
77
+ // startup rather than silently picking a wire format the operator did not ask
78
+ // for.
79
+ const responseModeRaw = (env.TM1_RESPONSE_MODE ?? "legacy")
80
+ .trim()
81
+ .toLowerCase();
82
+ if (!VALID_RESPONSE_MODES.includes(responseModeRaw)) {
83
+ throw new Error(`Invalid TM1_RESPONSE_MODE: "${env.TM1_RESPONSE_MODE}". Expected "legacy" or "structured".`);
84
+ }
85
+ const responseMode = responseModeRaw;
86
+ // ~80k characters sits under Claude Code's default MCP output cap (25k
87
+ // tokens) with room for the envelope; 23 recorded results overflowed it.
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";
117
144
  // Case-insensitive so a `TM1_MODE=ReadWrite` typo resolves to readwrite rather
118
145
  // than silently falling back to readonly (dropping every write tool without a
119
146
  // word). A genuinely-unknown value throws at startup — parity with the numeric
@@ -136,19 +163,6 @@ export function loadConfig(env = process.env) {
136
163
  modeReason =
137
164
  "TM1_ENVIRONMENT=prod forces readonly; set TM1_ALLOW_PROD_WRITES=true to allow writes.";
138
165
  }
139
- // Same parse shape as TM1_MODE: case-insensitive, unknown value throws at
140
- // startup rather than silently picking a wire format the operator did not ask
141
- // for.
142
- const responseModeRaw = (env.TM1_RESPONSE_MODE ?? "legacy")
143
- .trim()
144
- .toLowerCase();
145
- if (!VALID_RESPONSE_MODES.includes(responseModeRaw)) {
146
- throw new Error(`Invalid TM1_RESPONSE_MODE: "${env.TM1_RESPONSE_MODE}". Expected "legacy" or "structured".`);
147
- }
148
- const responseMode = responseModeRaw;
149
- // ~80k characters sits under Claude Code's default MCP output cap (25k
150
- // tokens) with room for the envelope; 23 recorded results overflowed it.
151
- const maxResponseChars = parseIntEnv("TM1_MAX_RESPONSE_CHARS", env.TM1_MAX_RESPONSE_CHARS, DEFAULT_MAX_RESPONSE_CHARS);
152
166
  // --- v12 (Planning Analytics Engine) connection ---------------------------
153
167
  const instance = env.TM1_INSTANCE || undefined;
154
168
  const database = env.TM1_DATABASE || undefined;
@@ -217,6 +231,7 @@ export function loadConfig(env = process.env) {
217
231
  }
218
232
  }
219
233
  return {
234
+ ...server,
220
235
  baseUrl: baseUrl,
221
236
  // In passport mode user/password are unused; default to "" so the type stays
222
237
  // a plain string and the Authorization header is built from the passport.
@@ -227,19 +242,10 @@ export function loadConfig(env = process.env) {
227
242
  ssl: { rejectUnauthorized },
228
243
  keepAliveIntervalMs,
229
244
  requestTimeoutMs,
230
- logLevel,
231
- logFile,
232
245
  tm1Version: effectiveTm1Version,
233
- transport,
234
- httpHost,
235
- httpPort,
236
- httpAllowedOrigins,
237
- httpToken,
238
246
  mode,
239
247
  ...(environment !== undefined ? { environment } : {}),
240
248
  ...(modeReason !== undefined ? { modeReason } : {}),
241
- responseMode,
242
- maxResponseChars,
243
249
  version,
244
250
  instance,
245
251
  database,
@@ -251,4 +257,23 @@ export function loadConfig(env = process.env) {
251
257
  iamUrl,
252
258
  };
253
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
+ }
254
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
@@ -0,0 +1,262 @@
1
+ // Named TM1 connections served by ONE server process.
2
+ //
3
+ // Before v7 every TM1 connection was its own server: one `.env` per folder
4
+ // under ~/.tm1/mcp-servers/, one process, one full copy of the tool list. With
5
+ // N connections a client carried N × ~113 tool names in context every turn,
6
+ // spawned N processes and held N sessions + keepalives open. The registry keeps
7
+ // the same folders as the source of truth (they are also what the tm1-api
8
+ // skill reads) but serves them all from one tool list: every tool takes a
9
+ // `connection` argument, and each connection's TM1Client is built on first use.
10
+ //
11
+ // Selection:
12
+ // TM1_CONNECTIONS_DIR set → discover that folder
13
+ // TM1_BASE_URL set (and no dir) → legacy single connection "default"
14
+ // neither → discover ~/.tm1/mcp-servers
15
+ // TM1_CONNECTIONS (comma-separated) narrows discovery to the named folders.
16
+ //
17
+ // Per connection, only the folder's own `.env` decides mode and version. Mode
18
+ // defaults to readonly: a connection-level key (TM1_MODE, TM1_BASE_URL, …) in
19
+ // the server's own environment is NOT inherited, so a TM1_MODE=readwrite in
20
+ // the launching shell cannot silently arm every connection.
21
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { parse as parseDotenv } from "dotenv";
25
+ import pino from "pino";
26
+ import { connectionIdOf, loadConfig } from "./config.js";
27
+ import { SessionManager } from "./session-manager.js";
28
+ import { TM1Client } from "./tm1-client.js";
29
+ import { TM1Error, TM1ErrorCode } from "./types.js";
30
+ /** Settings that belong to the server process, not to one TM1 connection. */
31
+ const SERVER_LEVEL_KEYS = new Set([
32
+ "TM1_LOG_LEVEL",
33
+ "TM1_LOG_FILE",
34
+ "TM1_RESPONSE_MODE",
35
+ "TM1_MAX_RESPONSE_CHARS",
36
+ "TM1_CONNECTIONS_DIR",
37
+ "TM1_CONNECTIONS",
38
+ ]);
39
+ function isServerLevelKey(key) {
40
+ return SERVER_LEVEL_KEYS.has(key) || key.startsWith("TM1_MCP_");
41
+ }
42
+ export class ConnectionRegistry {
43
+ logger;
44
+ entries = new Map();
45
+ constructor(logger) {
46
+ this.logger = logger;
47
+ }
48
+ /** Discover connections from the environment (see the header comment). */
49
+ static fromEnvironment(env, logger) {
50
+ const registry = new ConnectionRegistry(logger);
51
+ const explicitDir = env.TM1_CONNECTIONS_DIR;
52
+ if (!explicitDir && env.TM1_BASE_URL) {
53
+ registry.addConfig("default", loadConfig(env));
54
+ return registry;
55
+ }
56
+ const dir = explicitDir || join(homedir(), ".tm1", "mcp-servers");
57
+ registry.discover(dir, env);
58
+ if (registry.entries.size === 0) {
59
+ throw new Error(`No TM1 connections found. Set TM1_BASE_URL (single connection) or ` +
60
+ `create <name>/.env folders under ${dir} (or point TM1_CONNECTIONS_DIR at them).`);
61
+ }
62
+ // Folders exist but none is usable: fail at startup with every reason,
63
+ // rather than serve tools whose `connection` enum is empty.
64
+ if (registry.usableNames.length === 0) {
65
+ const reasons = registry
66
+ .status()
67
+ .map((c) => ` ${c.name}: ${c.configError}`)
68
+ .join("\n");
69
+ throw new Error(`No usable TM1 connection under ${dir}:\n${reasons}`);
70
+ }
71
+ return registry;
72
+ }
73
+ /** Wrap one prebuilt client (tests, embedders). */
74
+ static single(client, logger = pino({ level: "silent" })) {
75
+ return ConnectionRegistry.of([{ name: "default", client }], logger);
76
+ }
77
+ /**
78
+ * Prebuilt clients under explicit names. The owner keeps the clients'
79
+ * lifecycle: disconnectAll() leaves them alone. Mode defaults to readwrite
80
+ * because the registration-time gate (TM1_MODE via withAnnotations) already
81
+ * decided what an embedder may call.
82
+ */
83
+ static of(clients, logger = pino({ level: "silent" })) {
84
+ const registry = new ConnectionRegistry(logger);
85
+ for (const { name, client, mode } of clients) {
86
+ registry.entries.set(name, {
87
+ info: {
88
+ name,
89
+ version: client.version,
90
+ mode: mode ?? "readwrite",
91
+ connectionId: client.connectionId,
92
+ },
93
+ client,
94
+ });
95
+ }
96
+ return registry;
97
+ }
98
+ addConfig(name, config, dir) {
99
+ this.entries.set(name, {
100
+ info: {
101
+ name,
102
+ dir,
103
+ mode: config.mode,
104
+ environment: config.environment,
105
+ modeReason: config.modeReason,
106
+ version: config.version,
107
+ tm1Version: config.tm1Version,
108
+ baseUrl: config.baseUrl,
109
+ connectionId: connectionIdOf(config),
110
+ },
111
+ config,
112
+ });
113
+ }
114
+ discover(dir, env) {
115
+ if (!existsSync(dir))
116
+ return;
117
+ const only = env.TM1_CONNECTIONS
118
+ ? new Set(env.TM1_CONNECTIONS.split(",")
119
+ .map((s) => s.trim())
120
+ .filter(Boolean))
121
+ : 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
+ const names = readdirSync(dir, { withFileTypes: true })
130
+ .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, ".env")))
131
+ .map((d) => d.name)
132
+ .filter((name) => !only || only.has(name))
133
+ .sort((a, b) => a.localeCompare(b));
134
+ for (const name of names) {
135
+ const folder = join(dir, name);
136
+ 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;
144
+ }
145
+ this.addConfig(name, loadConfig(connEnv), folder);
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ this.logger.warn({ connection: name, err: message }, "skipping connection");
150
+ this.entries.set(name, {
151
+ info: { name, dir: folder, configError: message },
152
+ });
153
+ }
154
+ }
155
+ }
156
+ /** Every connection name, including ones whose config failed to load. */
157
+ get names() {
158
+ return [...this.entries.keys()];
159
+ }
160
+ /** Connections that can actually be used. */
161
+ get usableNames() {
162
+ return [...this.entries.values()]
163
+ .filter((e) => e.config || e.client)
164
+ .map((e) => e.info.name);
165
+ }
166
+ /** True when tools should not expose a `connection` argument at all. */
167
+ get isSingle() {
168
+ return this.usableNames.length === 1;
169
+ }
170
+ get anyReadwrite() {
171
+ return [...this.entries.values()].some((e) => e.info.mode === "readwrite");
172
+ }
173
+ hasVersion(version) {
174
+ return [...this.entries.values()].some((e) => e.info.version === version);
175
+ }
176
+ /** A client exists and is not mid-login. Never triggers a login itself. */
177
+ isConnected(name) {
178
+ const entry = this.entries.get(name);
179
+ return entry?.client !== undefined && entry.connecting === undefined;
180
+ }
181
+ info(name) {
182
+ return this.entries.get(name)?.info;
183
+ }
184
+ status() {
185
+ return [...this.entries.values()].map((e) => ({
186
+ ...e.info,
187
+ connected: e.client !== undefined && e.connecting === undefined && !e.lastError,
188
+ lastError: e.lastError,
189
+ }));
190
+ }
191
+ /** Resolve a connection name (optional when there is only one). */
192
+ entryFor(name) {
193
+ if (name === undefined) {
194
+ if (this.isSingle)
195
+ return this.entries.get(this.usableNames[0]);
196
+ throw new TM1Error({
197
+ code: TM1ErrorCode.VALIDATION_ERROR,
198
+ message: `connection is required. One of: ${this.names.join(", ")}.`,
199
+ });
200
+ }
201
+ const entry = this.entries.get(name);
202
+ if (!entry) {
203
+ throw new TM1Error({
204
+ code: TM1ErrorCode.VALIDATION_ERROR,
205
+ message: `Unknown connection "${name}". One of: ${this.names.join(", ")}.`,
206
+ });
207
+ }
208
+ if (entry.info.configError) {
209
+ throw new TM1Error({
210
+ code: TM1ErrorCode.VALIDATION_ERROR,
211
+ message: `Connection "${name}" is misconfigured: ${entry.info.configError}`,
212
+ });
213
+ }
214
+ return entry;
215
+ }
216
+ /**
217
+ * The client for a connection, built and logged in on first use. A failed
218
+ * login does not poison the entry: the client's own request path retries
219
+ * authentication, so the next call gets a fresh attempt.
220
+ */
221
+ async get(name) {
222
+ const entry = this.entryFor(name);
223
+ if (!entry.client) {
224
+ const config = entry.config;
225
+ const logger = this.logger.child({ connection: entry.info.name });
226
+ const sessionManager = new SessionManager(config, logger);
227
+ entry.client = new TM1Client(config, sessionManager, logger);
228
+ entry.connecting = entry.client
229
+ .connect()
230
+ .then(() => {
231
+ entry.lastError = undefined;
232
+ })
233
+ .catch((err) => {
234
+ entry.lastError = err instanceof Error ? err.message : String(err);
235
+ logger.warn({ err }, "initial TM1 connection failed — will retry on request");
236
+ })
237
+ .finally(() => {
238
+ entry.connecting = undefined;
239
+ });
240
+ }
241
+ if (entry.connecting)
242
+ await entry.connecting;
243
+ return entry.client;
244
+ }
245
+ /** Connection metadata for a resolved call (mode/version gates). */
246
+ resolveInfo(name) {
247
+ return this.entryFor(name).info;
248
+ }
249
+ async disconnectAll() {
250
+ await Promise.all([...this.entries.values()].map(async (e) => {
251
+ if (!e.client || !e.config)
252
+ return; // prebuilt clients are the owner's
253
+ try {
254
+ await e.client.disconnect();
255
+ }
256
+ catch (err) {
257
+ this.logger.error({ err, connection: e.info.name }, "disconnect failed");
258
+ }
259
+ }));
260
+ }
261
+ }
262
+ //# sourceMappingURL=connections.js.map
@@ -1,8 +1,8 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type pino from "pino";
3
- import type { TM1Config } from "./config.js";
3
+ import type { ServerSettings } from "./config.js";
4
4
  export declare function startHttpTransport(buildServer: () => {
5
5
  server: McpServer;
6
6
  dispose: () => void;
7
- }, config: TM1Config, logger: pino.Logger): Promise<() => Promise<void>>;
7
+ }, config: ServerSettings, logger: pino.Logger): Promise<() => Promise<void>>;
8
8
  //# sourceMappingURL=http-transport.d.ts.map