@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
@@ -0,0 +1,218 @@
1
+ // `tm1-mcp-server secrets <command>` — manage the keychain entries of a
2
+ // connection (see ./secrets.ts). No command ever prints a secret value.
3
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { parse as parseDotenv } from "dotenv";
6
+ import { connectionsDir } from "./connections.js";
7
+ import { isSecretKey, keychainAccount, keychainStore, plaintextSecretKeys, SECRET_KEYS, } from "./secrets.js";
8
+ const USAGE = `Usage: tm1-mcp-server secrets <command> <connection> [KEY]
9
+
10
+ set <connection> [KEY] store a secret (prompted, or read from stdin)
11
+ list <connection> show which secrets are stored (never the values)
12
+ delete <connection> [KEY] remove one secret, or all of the connection's
13
+ migrate <connection> move the plaintext secrets of <connection>/.env
14
+ into the keychain and set TM1_SECRETS=keychain
15
+
16
+ KEY is one of ${SECRET_KEYS.join(", ")} (default TM1_PASSWORD).
17
+ <connection> is a folder under ~/.tm1/mcp-servers (or $TM1_CONNECTIONS_DIR).`;
18
+ export async function runSecretsCli(args, env, io = processIo, store) {
19
+ const [command, connection, keyArg, ...extra] = args;
20
+ if (!command || command === "help" || command === "--help") {
21
+ io.out(USAGE);
22
+ return command ? 0 : 2;
23
+ }
24
+ if (!connection || extra.length > 0) {
25
+ io.err(USAGE);
26
+ return 2;
27
+ }
28
+ if (keyArg !== undefined && !isSecretKey(keyArg)) {
29
+ io.err(`Unknown KEY "${keyArg}". One of: ${SECRET_KEYS.join(", ")}.`);
30
+ return 2;
31
+ }
32
+ const folder = join(connectionsDir(env), connection);
33
+ const envFile = join(folder, ".env");
34
+ if (!existsSync(envFile)) {
35
+ io.err(`No connection "${connection}": ${envFile} does not exist.`);
36
+ return 2;
37
+ }
38
+ let keychain;
39
+ try {
40
+ keychain = store ?? (await keychainStore());
41
+ }
42
+ catch (err) {
43
+ io.err(err instanceof Error ? err.message : String(err));
44
+ return 1;
45
+ }
46
+ switch (command) {
47
+ case "set": {
48
+ const key = keyArg ?? "TM1_PASSWORD";
49
+ const value = await io.readSecret(`${key} for ${connection}: `);
50
+ if (value === "") {
51
+ io.err("Empty value — nothing stored.");
52
+ return 1;
53
+ }
54
+ // Pasted or piped junk becomes a wrong password, and wrong passwords
55
+ // lock TM1 accounts.
56
+ if (/[\r\n]/.test(value)) {
57
+ io.err("The value spans several lines — nothing stored.");
58
+ return 1;
59
+ }
60
+ keychain.set(connection, key, value);
61
+ io.out(`Stored ${keychainAccount(connection, key)}.`);
62
+ const marker = parseDotenv(readFileSync(envFile)).TM1_SECRETS;
63
+ if (marker?.trim().toLowerCase() !== "keychain") {
64
+ io.out(`The .env does not set TM1_SECRETS=keychain yet, so the server ` +
65
+ `ignores this entry. Run \`secrets migrate ${connection}\` or add it.`);
66
+ }
67
+ return 0;
68
+ }
69
+ case "list": {
70
+ for (const key of SECRET_KEYS) {
71
+ const stored = keychain.get(connection, key) !== undefined;
72
+ io.out(`${key.padEnd(18)} ${stored ? "stored" : "-"}`);
73
+ }
74
+ return 0;
75
+ }
76
+ case "delete": {
77
+ const keys = keyArg ? [keyArg] : SECRET_KEYS;
78
+ for (const key of keys) {
79
+ if (keychain.delete(connection, key)) {
80
+ io.out(`Deleted ${keychainAccount(connection, key)}.`);
81
+ }
82
+ else if (keyArg) {
83
+ io.out(`No entry ${keychainAccount(connection, key)}.`);
84
+ }
85
+ }
86
+ return 0;
87
+ }
88
+ case "migrate":
89
+ return migrate(connection, envFile, keychain, io);
90
+ default:
91
+ io.err(`Unknown command "${command}".\n${USAGE}`);
92
+ return 2;
93
+ }
94
+ }
95
+ /**
96
+ * Move the plaintext secrets of one `.env` into the keychain. Order matters:
97
+ * every secret is stored and read back BEFORE the file changes, and the new
98
+ * file is checked to parse to exactly the old settings minus the secrets. No
99
+ * plaintext backup is kept — that would defeat the point.
100
+ */
101
+ function migrate(connection, envFile, keychain, io) {
102
+ const text = readFileSync(envFile, "utf8");
103
+ const before = parseDotenv(text);
104
+ const keys = plaintextSecretKeys(before);
105
+ if (keys.length === 0) {
106
+ io.out(`${envFile} holds no plaintext secret.`);
107
+ return 0;
108
+ }
109
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
110
+ const kept = [];
111
+ for (const line of text.split(/\r?\n/)) {
112
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
113
+ const name = match?.[1];
114
+ if (name && (keys.includes(name) || name === "TM1_SECRETS")) {
115
+ const value = match[2].trim();
116
+ const quote = value[0];
117
+ if ((quote === '"' || quote === "'" || quote === "`") &&
118
+ value.indexOf(quote, 1) === -1) {
119
+ io.err(`${name} in ${envFile} is a multi-line value; move it by hand.`);
120
+ return 1;
121
+ }
122
+ continue;
123
+ }
124
+ kept.push(line);
125
+ }
126
+ while (kept.length > 0 && kept[kept.length - 1] === "")
127
+ kept.pop();
128
+ kept.push("TM1_SECRETS=keychain", "");
129
+ const next = kept.join(eol);
130
+ const after = parseDotenv(next);
131
+ // A blank value (TM1_PASSWORD= for a blank-password account) is no secret
132
+ // and stays in the file.
133
+ const expected = {
134
+ ...before,
135
+ TM1_SECRETS: "keychain",
136
+ };
137
+ for (const key of keys)
138
+ delete expected[key];
139
+ if (JSON.stringify(sorted(after)) !== JSON.stringify(sorted(expected))) {
140
+ io.err(`Could not rewrite ${envFile} safely; nothing changed.`);
141
+ return 1;
142
+ }
143
+ for (const key of keys) {
144
+ const value = before[key];
145
+ keychain.set(connection, key, value);
146
+ if (keychain.get(connection, key) !== value) {
147
+ io.err(`Reading ${keychainAccount(connection, key)} back returned a different ` +
148
+ `value; ${envFile} left unchanged.`);
149
+ return 1;
150
+ }
151
+ io.out(`Stored ${keychainAccount(connection, key)}.`);
152
+ }
153
+ const tmp = `${envFile}.tmp`;
154
+ writeFileSync(tmp, next);
155
+ renameSync(tmp, envFile);
156
+ io.out(`Removed ${keys.join(", ")} from ${envFile} and set TM1_SECRETS=keychain. ` +
157
+ `Restart the MCP client to pick up the change.`);
158
+ return 0;
159
+ }
160
+ function sorted(obj) {
161
+ return Object.entries(obj).sort(([a], [b]) => a.localeCompare(b));
162
+ }
163
+ const processIo = {
164
+ out: (line) => process.stdout.write(`${line}\n`),
165
+ err: (line) => process.stderr.write(`${line}\n`),
166
+ readSecret: (prompt) => process.stdin.isTTY ? promptHidden(prompt) : readPipedStdin(),
167
+ };
168
+ /**
169
+ * `echo secret | tm1-mcp-server secrets set …` — a leading BOM (PowerShell
170
+ * pipes) and one trailing newline dropped. Git Bash's mintty is no TTY to
171
+ * Node either, so typed input lands here too, echoed: say so.
172
+ */
173
+ async function readPipedStdin() {
174
+ process.stderr.write("Reading the secret from stdin until EOF. In a terminal that is no TTY to " +
175
+ "Node (Git Bash/mintty) the input is echoed; pipe the value or use " +
176
+ "PowerShell/cmd instead.\n");
177
+ const chunks = [];
178
+ for await (const chunk of process.stdin)
179
+ chunks.push(chunk);
180
+ return stripPipedInput(Buffer.concat(chunks).toString("utf8"));
181
+ }
182
+ export function stripPipedInput(raw) {
183
+ return raw.replace(/^\uFEFF/, "").replace(/\r?\n$/, "");
184
+ }
185
+ function promptHidden(prompt) {
186
+ return new Promise((resolve, reject) => {
187
+ const stdin = process.stdin;
188
+ process.stderr.write(prompt);
189
+ stdin.setRawMode(true);
190
+ stdin.setEncoding("utf8");
191
+ stdin.resume();
192
+ let value = "";
193
+ const done = (err) => {
194
+ stdin.setRawMode(false);
195
+ stdin.pause();
196
+ stdin.off("data", onData);
197
+ process.stderr.write("\n");
198
+ if (err)
199
+ reject(err);
200
+ else
201
+ resolve(value);
202
+ };
203
+ const onData = (chunk) => {
204
+ for (const ch of chunk) {
205
+ if (ch === "\r" || ch === "\n")
206
+ return done();
207
+ if (ch === "\u0003")
208
+ return done(new Error("Aborted."));
209
+ if (ch === "\u007f" || ch === "\b")
210
+ value = value.slice(0, -1);
211
+ else
212
+ value += ch;
213
+ }
214
+ };
215
+ stdin.on("data", onData);
216
+ });
217
+ }
218
+ //# sourceMappingURL=secrets-cli.js.map
@@ -0,0 +1,39 @@
1
+ /** Every `.env` key that holds a credential. */
2
+ export declare const SECRET_KEYS: readonly ["TM1_PASSWORD", "TM1_CLIENT_SECRET", "TM1_ACCESS_TOKEN", "TM1_API_KEY", "TM1_CAM_PASSPORT"];
3
+ export type SecretKey = (typeof SECRET_KEYS)[number];
4
+ export declare const KEYCHAIN_SERVICE = "tm1-mcp-server";
5
+ export declare function isSecretKey(key: string): key is SecretKey;
6
+ export declare function keychainAccount(connection: string, key: SecretKey): string;
7
+ /** The Windows Credential Manager target name of one entry. */
8
+ export declare function keychainTarget(connection: string, key: SecretKey): string;
9
+ /** Secret keys that carry a non-empty plaintext value in `env`. */
10
+ export declare function plaintextSecretKeys(env: NodeJS.ProcessEnv): SecretKey[];
11
+ /**
12
+ * True when the connection keeps its secrets in the keychain. Throws on an
13
+ * unknown TM1_SECRETS value, and when a keychain connection still carries a
14
+ * plaintext secret — which of the two should win is not guessable, and a
15
+ * stale one is exactly the kind of wrong password that locks an account.
16
+ * An empty value (`TM1_PASSWORD=`, a blank-password account) is no conflict.
17
+ */
18
+ export declare function usesKeychain(env: NodeJS.ProcessEnv): boolean;
19
+ /** The OS store, narrowed to what the server and the CLI need. */
20
+ export interface SecretStore {
21
+ get(connection: string, key: SecretKey): string | undefined;
22
+ set(connection: string, key: SecretKey, value: string): void;
23
+ /** True when an entry existed. */
24
+ delete(connection: string, key: SecretKey): boolean;
25
+ }
26
+ /**
27
+ * The OS keychain. The native binding is imported on first use, so a
28
+ * platform without a prebuilt binary only breaks connections that ask for it.
29
+ */
30
+ export declare function keychainStore(): Promise<SecretStore>;
31
+ /**
32
+ * `env` with every secret the keychain holds for `connection` filled in. The
33
+ * result goes straight into loadConfig and is never written to process.env,
34
+ * so child processes do not inherit it.
35
+ */
36
+ export declare function withKeychainSecrets(connection: string, env: NodeJS.ProcessEnv, store?: SecretStore): Promise<NodeJS.ProcessEnv>;
37
+ /** Appended to a config error on a keychain connection: how to fix it. */
38
+ export declare function keychainHint(connection: string): string;
39
+ //# sourceMappingURL=secrets.d.ts.map
@@ -0,0 +1,128 @@
1
+ // Connection secrets kept in the OS keychain instead of the `.env`.
2
+ //
3
+ // A connection folder opts in with `TM1_SECRETS=keychain`. Its `.env` then
4
+ // carries no secret; each one lives in the OS store (Windows Credential
5
+ // Manager, macOS Keychain, Linux Secret Service) under
6
+ //
7
+ // service "tm1-mcp-server", account "<connection>/<KEY>"
8
+ // Windows target name "<connection>/<KEY>.tm1-mcp-server"
9
+ //
10
+ // The Windows target name is the library default ("<account>.<service>"),
11
+ // pinned by keychainTarget() because a stdlib-only reader — the tm1-api
12
+ // skill's tm1.py calls CredReadW via ctypes — looks the entry up by it. The
13
+ // blob is UTF-16LE. Entry.withTarget() is deliberately not used: in
14
+ // @napi-rs/keyring 2.1.0 on Windows a fresh Entry reads back "" for it.
15
+ //
16
+ // Discovery never reads the store: loadConfig(env, { deferSecrets: true })
17
+ // accepts the folder, and the secrets are read when the connection is first
18
+ // used (ConnectionRegistry.get). Listing connections therefore never touches
19
+ // the keychain, and a missing entry fails that one connection, not startup.
20
+ //
21
+ // What this protects: the secret no longer sits in a file that gets synced,
22
+ // backed up, committed, pasted or read into an agent's context. What it does
23
+ // not: any process running as the same OS user can read the store too.
24
+ import { TM1Error, TM1ErrorCode } from "./types.js";
25
+ /** Every `.env` key that holds a credential. */
26
+ export const SECRET_KEYS = [
27
+ "TM1_PASSWORD",
28
+ "TM1_CLIENT_SECRET",
29
+ "TM1_ACCESS_TOKEN",
30
+ "TM1_API_KEY",
31
+ "TM1_CAM_PASSPORT",
32
+ ];
33
+ export const KEYCHAIN_SERVICE = "tm1-mcp-server";
34
+ export function isSecretKey(key) {
35
+ return SECRET_KEYS.includes(key);
36
+ }
37
+ export function keychainAccount(connection, key) {
38
+ return `${connection}/${key}`;
39
+ }
40
+ /** The Windows Credential Manager target name of one entry. */
41
+ export function keychainTarget(connection, key) {
42
+ return `${keychainAccount(connection, key)}.${KEYCHAIN_SERVICE}`;
43
+ }
44
+ /** Secret keys that carry a non-empty plaintext value in `env`. */
45
+ export function plaintextSecretKeys(env) {
46
+ return SECRET_KEYS.filter((key) => Boolean(env[key]));
47
+ }
48
+ /**
49
+ * True when the connection keeps its secrets in the keychain. Throws on an
50
+ * unknown TM1_SECRETS value, and when a keychain connection still carries a
51
+ * plaintext secret — which of the two should win is not guessable, and a
52
+ * stale one is exactly the kind of wrong password that locks an account.
53
+ * An empty value (`TM1_PASSWORD=`, a blank-password account) is no conflict.
54
+ */
55
+ export function usesKeychain(env) {
56
+ const raw = env.TM1_SECRETS?.trim().toLowerCase();
57
+ if (!raw)
58
+ return false;
59
+ if (raw !== "keychain") {
60
+ throw new Error(`Invalid TM1_SECRETS: "${env.TM1_SECRETS}". Expected "keychain" or unset.`);
61
+ }
62
+ const plain = plaintextSecretKeys(env);
63
+ if (plain.length > 0) {
64
+ throw new Error(`TM1_SECRETS=keychain, but the .env still sets ${plain.join(", ")}. ` +
65
+ `Remove ${plain.length === 1 ? "it" : "them"} from the .env ` +
66
+ `(\`tm1-mcp-server secrets migrate <connection>\` does both steps).`);
67
+ }
68
+ return true;
69
+ }
70
+ let osStore;
71
+ /**
72
+ * The OS keychain. The native binding is imported on first use, so a
73
+ * platform without a prebuilt binary only breaks connections that ask for it.
74
+ */
75
+ export function keychainStore() {
76
+ osStore ??= import("@napi-rs/keyring").then(({ Entry }) => {
77
+ const entry = (connection, key) => new Entry(KEYCHAIN_SERVICE, keychainAccount(connection, key));
78
+ return {
79
+ get: (connection, key) => entry(connection, key).getPassword() ?? undefined,
80
+ set: (connection, key, value) => entry(connection, key).setPassword(value),
81
+ delete: (connection, key) => entry(connection, key).deletePassword(),
82
+ };
83
+ }, (err) => {
84
+ osStore = undefined;
85
+ const reason = err instanceof Error ? err.message : String(err);
86
+ throw new Error(`The OS keychain is not available on this platform (${reason}).`);
87
+ });
88
+ return osStore;
89
+ }
90
+ /**
91
+ * `env` with every secret the keychain holds for `connection` filled in. The
92
+ * result goes straight into loadConfig and is never written to process.env,
93
+ * so child processes do not inherit it.
94
+ */
95
+ export async function withKeychainSecrets(connection, env, store) {
96
+ let resolved;
97
+ try {
98
+ resolved = store ?? (await keychainStore());
99
+ }
100
+ catch (err) {
101
+ throw new TM1Error({
102
+ code: TM1ErrorCode.VALIDATION_ERROR,
103
+ message: `Connection "${connection}" keeps its secrets in the OS keychain: ${err instanceof Error ? err.message : String(err)}`,
104
+ });
105
+ }
106
+ const out = { ...env };
107
+ for (const key of SECRET_KEYS) {
108
+ let value;
109
+ try {
110
+ value = resolved.get(connection, key);
111
+ }
112
+ catch (err) {
113
+ throw new TM1Error({
114
+ code: TM1ErrorCode.VALIDATION_ERROR,
115
+ message: `Reading ${keychainAccount(connection, key)} from the OS keychain failed: ${err instanceof Error ? err.message : String(err)}`,
116
+ });
117
+ }
118
+ if (value !== undefined)
119
+ out[key] = value;
120
+ }
121
+ return out;
122
+ }
123
+ /** Appended to a config error on a keychain connection: how to fix it. */
124
+ export function keychainHint(connection) {
125
+ return (` The secrets of "${connection}" are read from the OS keychain; store the ` +
126
+ `missing one with \`npx tm1-mcp-server secrets set ${connection} <KEY>\`.`);
127
+ }
128
+ //# sourceMappingURL=secrets.js.map
@@ -8,6 +8,7 @@ export declare class SessionManager {
8
8
  private sessionCookie;
9
9
  private authInFlight;
10
10
  private keepAliveTimer;
11
+ private rejectedLogin;
11
12
  private readonly config;
12
13
  private readonly logger;
13
14
  private readonly profile;
@@ -2,6 +2,7 @@ import { createLogger } from "./logger.js";
2
2
  import { createConnectionProfile, } from "./tm1-client/connection/profile.js";
3
3
  import { getTm1Dispatcher, tm1Fetch } from "./tm1-client/dispatcher.js";
4
4
  import { PRODUCT, VERSION } from "./version.js";
5
+ import { TM1Error, TM1ErrorCode } from "./types.js";
5
6
  const USER_AGENT = `${PRODUCT}/${VERSION}`;
6
7
  export class TimeoutError extends Error {
7
8
  timeoutMs;
@@ -31,6 +32,13 @@ export class SessionManager {
31
32
  sessionCookie = null;
32
33
  authInFlight = null;
33
34
  keepAliveTimer = null;
35
+ // Set once TM1 rejects the configured credentials (401/403 on login).
36
+ // Credentials come from env and cannot change without a restart, so every
37
+ // later attempt would be rejected the same way — and each one counts
38
+ // against MaximumLoginAttempts. Latching here keeps one failed login from
39
+ // becoming dozens (fan-out tools, the 401 retry, the keep-alive timer)
40
+ // and locking the account.
41
+ rejectedLogin = null;
34
42
  config;
35
43
  logger;
36
44
  profile;
@@ -61,6 +69,9 @@ export class SessionManager {
61
69
  * requests are using — the "staggered-401 re-auth churn" cascade.
62
70
  */
63
71
  async authenticate(staleCookie) {
72
+ if (this.rejectedLogin) {
73
+ throw this.rejectedLogin;
74
+ }
64
75
  if (staleCookie !== undefined &&
65
76
  this.sessionCookie !== null &&
66
77
  this.sessionCookie !== staleCookie) {
@@ -105,6 +116,19 @@ export class SessionManager {
105
116
  }));
106
117
  // Always consume body to release connection
107
118
  await response.text();
119
+ if (response.status === 401 || response.status === 403) {
120
+ this.rejectedLogin = new TM1Error({
121
+ code: TM1ErrorCode.AUTH_FAILED,
122
+ message: `Authentication failed with status ${response.status}: ${response.statusText}. ` +
123
+ "No further login attempts are made, so the account is not locked out; " +
124
+ "fix the credentials and restart the MCP server.",
125
+ httpStatus: response.status,
126
+ endpoint: loginReq.url,
127
+ });
128
+ this.logger.error({ endpoint: loginReq.url, status: response.status }, "Authentication rejected; login attempts stopped until restart");
129
+ this.stopKeepAlive();
130
+ throw this.rejectedLogin;
131
+ }
108
132
  if (!response.ok) {
109
133
  this.logger.error({ endpoint: loginReq.url, status: response.status }, "Authentication failed");
110
134
  throw new Error(`Authentication failed with status ${response.status}: ${response.statusText}`);
@@ -1,6 +1,4 @@
1
- // OData single-quote escaping for a key segment (double the apostrophes),
2
- // then URL-encode. Mirrors the `enc` helper used across the service layer.
3
- const enc = (s) => encodeURIComponent(String(s).replace(/'/g, "''"));
1
+ import { odataKey } from "../services/odata-page.js";
4
2
  function buildBasicToken(user, password) {
5
3
  return "Basic " + Buffer.from(`${user}:${password}`).toString("base64");
6
4
  }
@@ -79,7 +77,7 @@ function createV12Profile(config) {
79
77
  const database = config.database ?? "";
80
78
  // encodeURIComponent (not `enc`) for the instance: it's a bare path segment,
81
79
  // not an OData quoted key — no apostrophe-doubling needed, just percent-encoding.
82
- const dbRoot = `/${encodeURIComponent(instance)}/api/v1/Databases('${enc(database)}')`;
80
+ const dbRoot = `/${encodeURIComponent(instance)}/api/v1/Databases('${odataKey(database)}')`;
83
81
  return {
84
82
  // Replacement FUNCTION, not a string: String.replace treats "$&"/"$$"/"$1"
85
83
  // in a string replacement specially, which would corrupt dbRoot if instance
@@ -1,5 +1,5 @@
1
1
  import { Agent } from "undici";
2
2
  import type { TM1Config } from "../config.js";
3
- export declare function getTm1Dispatcher(config: TM1Config): Agent | undefined;
3
+ export declare function getTm1Dispatcher(config: TM1Config): Agent;
4
4
  export declare function tm1Fetch(url: string, init: RequestInit): Promise<Response>;
5
5
  //# sourceMappingURL=dispatcher.d.ts.map
@@ -6,14 +6,26 @@
6
6
  //
7
7
  // The Agent is cached so connection-pooling stays effective across requests.
8
8
  import { Agent, fetch as undiciFetch } from "undici";
9
- let cachedAgent;
9
+ //
10
+ // headersTimeout/bodyTimeout are switched off on purpose. undici's defaults
11
+ // (300 s each) end any request that has not answered after five minutes with
12
+ // UND_ERR_HEADERS_TIMEOUT — measured: a 400 s budget died at 300.8 s on both
13
+ // fetch paths. A TI process runs as long as it runs and TM1 has no timeout
14
+ // for it, so the only limit is our own per-request AbortSignal (timeoutMs),
15
+ // which http.ts maps to LOCK_TIMEOUT.
16
+ const agents = new Map();
10
17
  export function getTm1Dispatcher(config) {
11
- if (config.ssl.rejectUnauthorized)
12
- return undefined;
13
- if (!cachedAgent) {
14
- cachedAgent = new Agent({ connect: { rejectUnauthorized: false } });
18
+ const verify = config.ssl.rejectUnauthorized;
19
+ let agent = agents.get(verify);
20
+ if (!agent) {
21
+ agent = new Agent({
22
+ connect: { rejectUnauthorized: verify },
23
+ headersTimeout: 0,
24
+ bodyTimeout: 0,
25
+ });
26
+ agents.set(verify, agent);
15
27
  }
16
- return cachedAgent;
28
+ return agent;
17
29
  }
18
30
  // Node's BUILT-IN fetch silently drops Set-Cookie headers when handed a
19
31
  // dispatcher from the npm undici package (cross-instance Headers handling;
@@ -2,6 +2,7 @@ import type pino from "pino";
2
2
  import { type TM1Config } from "../config.js";
3
3
  import type { SessionManager } from "../session-manager.js";
4
4
  import { TM1Error } from "../types.js";
5
+ import { type Tm1MutationEvent } from "../lib/tm1-events.js";
5
6
  export interface RequestOptions {
6
7
  timeoutMs?: number;
7
8
  signal?: AbortSignal;
@@ -13,6 +14,15 @@ export declare class TM1HttpClient {
13
14
  protected readonly sessionManager: SessionManager;
14
15
  private readonly profile;
15
16
  constructor(config: TM1Config, sessionManager: SessionManager, logger: pino.Logger);
17
+ private readonly mutationListeners;
18
+ /**
19
+ * @internal — subscribe to THIS connection's successful mutations. Unlike
20
+ * the process-global `tm1Events` bus, listeners die with the client, so a
21
+ * per-connection cache needs no unsubscribe and never sees another
22
+ * connection's writes.
23
+ */
24
+ onMutation(listener: (e: Tm1MutationEvent) => void): void;
25
+ private emitMutation;
16
26
  /**
17
27
  * TM1 major version (11 | 12) — the single numeric source of truth for
18
28
  * version-conditional service code. Branch on this, never on the display
@@ -45,6 +55,17 @@ export declare class TM1HttpClient {
45
55
  */
46
56
  /** @internal — for Service-layer use; not part of the public consumer API. */
47
57
  requestRaw(method: string, path: string, opts?: RequestOptions): Promise<string>;
58
+ /**
59
+ * Same request as requestRaw, but hands back the bytes instead of a string.
60
+ *
61
+ * requestRaw decodes the body as UTF-8, which silently destroys anything
62
+ * that is not text — a spreadsheet read that way comes back as replacement
63
+ * characters. Callers that may face binary content use this and decide how
64
+ * to present it.
65
+ */
66
+ /** @internal — for Service-layer use; not part of the public consumer API. */
67
+ requestRawBytes(method: string, path: string, opts?: RequestOptions): Promise<Buffer>;
68
+ private fetchRaw;
48
69
  /**
49
70
  * Make an authenticated HTTP request with a binary body (Buffer/Uint8Array).
50
71
  * Used for blob/file uploads where the body is raw bytes (not JSON).
@@ -51,6 +51,24 @@ export class TM1HttpClient {
51
51
  this.logger = logger;
52
52
  this.profile = createConnectionProfile(config);
53
53
  }
54
+ mutationListeners = [];
55
+ /**
56
+ * @internal — subscribe to THIS connection's successful mutations. Unlike
57
+ * the process-global `tm1Events` bus, listeners die with the client, so a
58
+ * per-connection cache needs no unsubscribe and never sees another
59
+ * connection's writes.
60
+ */
61
+ onMutation(listener) {
62
+ this.mutationListeners.push(listener);
63
+ }
64
+ emitMutation(method, path) {
65
+ if (isSafeHttpMethod(method))
66
+ return;
67
+ const e = { method, path, connectionId: connectionIdOf(this.config) };
68
+ tm1Events.emit("mutation", e);
69
+ for (const listener of this.mutationListeners)
70
+ listener(e);
71
+ }
54
72
  /**
55
73
  * TM1 major version (11 | 12) — the single numeric source of truth for
56
74
  * version-conditional service code. Branch on this, never on the display
@@ -110,23 +128,11 @@ export class TM1HttpClient {
110
128
  });
111
129
  }
112
130
  const retryResult = await this.handleResponse(retryResponse, path);
113
- if (!isSafeMethod) {
114
- tm1Events.emit("mutation", {
115
- method,
116
- path,
117
- connectionId: connectionIdOf(this.config),
118
- });
119
- }
131
+ this.emitMutation(method, path);
120
132
  return retryResult;
121
133
  }
122
134
  const result = await this.handleResponse(response, path);
123
- if (!isSafeMethod) {
124
- tm1Events.emit("mutation", {
125
- method,
126
- path,
127
- connectionId: connectionIdOf(this.config),
128
- });
129
- }
135
+ this.emitMutation(method, path);
130
136
  return result;
131
137
  }
132
138
  catch (error) {
@@ -174,6 +180,22 @@ export class TM1HttpClient {
174
180
  */
175
181
  /** @internal — for Service-layer use; not part of the public consumer API. */
176
182
  async requestRaw(method, path, opts) {
183
+ return (await this.fetchRaw(method, path, opts)).text();
184
+ }
185
+ /**
186
+ * Same request as requestRaw, but hands back the bytes instead of a string.
187
+ *
188
+ * requestRaw decodes the body as UTF-8, which silently destroys anything
189
+ * that is not text — a spreadsheet read that way comes back as replacement
190
+ * characters. Callers that may face binary content use this and decide how
191
+ * to present it.
192
+ */
193
+ /** @internal — for Service-layer use; not part of the public consumer API. */
194
+ async requestRawBytes(method, path, opts) {
195
+ const res = await this.fetchRaw(method, path, opts);
196
+ return Buffer.from(await res.arrayBuffer());
197
+ }
198
+ async fetchRaw(method, path, opts) {
177
199
  const url = `${this.config.baseUrl}${this.profile.resolveApiPath(path)}`;
178
200
  const effectiveTimeout = opts?.timeoutMs ?? this.config.requestTimeoutMs;
179
201
  const cookie = await this.sessionManager.ensureSession();
@@ -198,15 +220,8 @@ export class TM1HttpClient {
198
220
  }
199
221
  throw this.classifyHttpError(response.status, path, body || undefined);
200
222
  }
201
- const text = await response.text();
202
- if (!isSafeHttpMethod(method)) {
203
- tm1Events.emit("mutation", {
204
- method,
205
- path,
206
- connectionId: connectionIdOf(this.config),
207
- });
208
- }
209
- return text;
223
+ this.emitMutation(method, path);
224
+ return response;
210
225
  }
211
226
  /**
212
227
  * Make an authenticated HTTP request with a binary body (Buffer/Uint8Array).
@@ -237,13 +252,7 @@ export class TM1HttpClient {
237
252
  }
238
253
  throw this.classifyHttpError(response.status, path, errBody || undefined);
239
254
  }
240
- if (!isSafeHttpMethod(method)) {
241
- tm1Events.emit("mutation", {
242
- method,
243
- path,
244
- connectionId: connectionIdOf(this.config),
245
- });
246
- }
255
+ this.emitMutation(method, path);
247
256
  }
248
257
  /**
249
258
  * Low-level single fetch with timeout + AbortSignal wiring.