@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.
- package/CHANGELOG.md +35 -1
- package/README.md +23 -5
- package/dist/config.d.ts +9 -0
- package/dist/config.js +89 -64
- package/dist/connections.d.ts +69 -0
- package/dist/connections.js +262 -0
- package/dist/http-transport.d.ts +2 -2
- package/dist/index.js +45 -40
- package/dist/lib/callgraph/tm1-adapter.d.ts +4 -1
- package/dist/lib/callgraph/tm1-adapter.js +5 -2
- package/dist/lib/slim-json-schema.d.ts +8 -0
- package/dist/lib/slim-json-schema.js +34 -0
- package/dist/lib/strip-comments.js +1 -1
- package/dist/lib/tm1-events.d.ts +2 -0
- package/dist/prompts/index.js +2 -2
- package/dist/resources/index.d.ts +4 -2
- package/dist/resources/index.js +93 -53
- package/dist/resources/subscriptions.d.ts +2 -1
- package/dist/resources/subscriptions.js +16 -7
- package/dist/server-instructions.d.ts +3 -0
- package/dist/server-instructions.js +12 -0
- package/dist/tm1-client/http.d.ts +1 -1
- package/dist/tm1-client/http.js +21 -4
- package/dist/tm1-client/services/process-service.d.ts +7 -1
- package/dist/tm1-client/services/process-service.js +7 -5
- package/dist/tm1-client.d.ts +1 -1
- package/dist/tm1-client.js +2 -11
- package/dist/tools/analysis/audit-naming.js +1 -1
- package/dist/tools/analysis/invalidate-callgraph-cache.js +2 -1
- package/dist/tools/analysis/trace-data-flow.js +1 -1
- package/dist/tools/confirm.js +1 -1
- package/dist/tools/define-tool.d.ts +35 -11
- package/dist/tools/define-tool.js +51 -4
- package/dist/tools/index.d.ts +2 -2
- package/dist/tools/index.js +6 -12
- package/dist/tools/metadata/list-cubes.js +3 -3
- package/dist/tools/metadata/list-processes.js +11 -7
- package/dist/tools/model-building/unload-cube.js +1 -1
- package/dist/tools/operations/get-audit-log.js +1 -1
- package/dist/tools/operations/get-jobs.js +2 -2
- package/dist/tools/operations/get-message-log.js +1 -1
- package/dist/tools/operations/get-server-state.js +1 -1
- package/dist/tools/operations/get-threads.js +2 -2
- package/dist/tools/operations/get-transaction-log.js +1 -1
- package/dist/tools/operations/list-connections.d.ts +2 -0
- package/dist/tools/operations/list-connections.js +35 -0
- package/dist/tools/operations/save-data.js +1 -1
- package/dist/tools/ti-development/execute-process.js +2 -2
- package/dist/tools/ti-development/import-pro-file.js +1 -1
- package/dist/tools/ti-development/search-code.js +1 -1
- package/dist/tools/ti-development/upsert-process.js +2 -2
- package/dist/tools/with-annotations.d.ts +10 -0
- package/dist/tools/with-annotations.js +58 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -3
- package/dist/tools/ti-development/get-process-code.d.ts +0 -2
- package/dist/tools/ti-development/get-process-code.js +0 -100
- package/dist/tools/ti-development/get-process-datasource.d.ts +0 -2
- package/dist/tools/ti-development/get-process-datasource.js +0 -20
- package/dist/tools/ti-development/get-process-parameters.d.ts +0 -2
- package/dist/tools/ti-development/get-process-parameters.js +0 -25
- package/dist/tools/ti-development/get-process-variables.d.ts +0 -2
- 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/
|
|
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
|
-
|
|
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 (
|
|
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 |
|
|
233
|
+
| operations | 16 |
|
|
216
234
|
| scheduling | 5 |
|
|
217
235
|
| security | 8 |
|
|
218
236
|
| subsets | 5 |
|
|
219
|
-
| ti-development |
|
|
237
|
+
| ti-development | 17 |
|
|
220
238
|
| views | 4 |
|
|
221
|
-
| **Total** | **
|
|
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
|
-
|
|
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
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -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 {
|
|
3
|
+
import type { ServerSettings } from "./config.js";
|
|
4
4
|
export declare function startHttpTransport(buildServer: () => {
|
|
5
5
|
server: McpServer;
|
|
6
6
|
dispose: () => void;
|
|
7
|
-
}, config:
|
|
7
|
+
}, config: ServerSettings, logger: pino.Logger): Promise<() => Promise<void>>;
|
|
8
8
|
//# sourceMappingURL=http-transport.d.ts.map
|