@kalera/munin-runtime 1.2.5 → 1.2.6

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.
@@ -1,4 +1,4 @@
1
1
 
2
- > @kalera/munin-runtime@1.2.5 build /home/runner/work/munin-for-agents/munin-for-agents/packages/runtime
2
+ > @kalera/munin-runtime@1.2.6 build /home/runner/work/munin-for-agents/munin-for-agents/packages/runtime
3
3
  > tsc -p tsconfig.json
4
4
 
package/dist/env.d.ts CHANGED
@@ -10,5 +10,28 @@ export declare function resolveEncryptionKey(): string | undefined;
10
10
  * Upsert key=value pairs into a .env file.
11
11
  * - Existing keys are replaced.
12
12
  * - New keys are appended.
13
+ * - Keys are matched via string split (not regex) to prevent injection.
13
14
  */
14
15
  export declare function writeEnvFile(filename: string, vars: EnvVar[], cwd?: string): void;
16
+ /**
17
+ * Resolve MUNIN_PROJECT from multiple sources, in priority order:
18
+ * 1. Explicit environment variable
19
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
20
+ * 3. .env in any ancestor directory (walk-up from CWD)
21
+ */
22
+ export declare function resolveProjectId(): string | undefined;
23
+ export interface CliEnv {
24
+ baseUrl: string;
25
+ apiKey: string | undefined;
26
+ timeoutMs: number;
27
+ retries: number;
28
+ backoffMs: number;
29
+ }
30
+ export declare function loadCliEnv(): CliEnv;
31
+ export declare function executeWithRetry<T>(task: () => Promise<T>, retries: number, backoffMs: number): Promise<T>;
32
+ export declare function safeError(error: unknown): Record<string, unknown>;
33
+ export declare function parseCliArgs(argv: string[], usage: string): {
34
+ action: string;
35
+ payload: Record<string, unknown>;
36
+ };
37
+ export * from "./mcp-server.js";
package/dist/env.js CHANGED
@@ -1,34 +1,201 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ const REDACT_KEYS = ["apiKey", "authorization", "token", "secret", "password"];
3
4
  /**
4
5
  * Resolve the encryption key for E2EE projects from MUNIN_ENCRYPTION_KEY env var.
5
6
  */
6
7
  export function resolveEncryptionKey() {
7
8
  return process.env.MUNIN_ENCRYPTION_KEY;
8
9
  }
10
+ /**
11
+ * Escape shell/metacharacters that could inject into a .env value.
12
+ * Escapes: $, backticks, \, and newlines — in addition to double quotes.
13
+ */
14
+ function escapeEnvValue(value) {
15
+ return value
16
+ .replace(/\\/g, "\\\\") // escape backslashes first
17
+ .replace(/\n/g, "\\n") // escape newlines
18
+ .replace(/\r/g, "\\r") // escape carriage returns
19
+ .replace(/`/g, "\\`") // escape backticks
20
+ .replace(/\$/g, "\\$") // escape dollar signs
21
+ .replace(/"/g, '\\"'); // escape double quotes
22
+ }
23
+ /**
24
+ * Parse a .env line, returning { key, value } or null if not a valid assignment.
25
+ * Uses a string-based split instead of regex to avoid injection from .env content.
26
+ */
27
+ function parseEnvLine(line) {
28
+ // Skip empty lines and comments
29
+ const trimmed = line.trim();
30
+ if (!trimmed || trimmed.startsWith("#"))
31
+ return null;
32
+ const eqIndex = trimmed.indexOf("=");
33
+ if (eqIndex === -1)
34
+ return null;
35
+ const key = trimmed.slice(0, eqIndex).trim();
36
+ // Value is everything after the first '=' (unquoted, no regex)
37
+ const rawValue = trimmed.slice(eqIndex + 1);
38
+ return { key, value: rawValue };
39
+ }
9
40
  /**
10
41
  * Upsert key=value pairs into a .env file.
11
42
  * - Existing keys are replaced.
12
43
  * - New keys are appended.
44
+ * - Keys are matched via string split (not regex) to prevent injection.
13
45
  */
14
46
  export function writeEnvFile(filename, vars, cwd = process.cwd()) {
15
47
  const filePath = path.resolve(cwd, filename);
16
- let lines = [];
48
+ const lines = [];
17
49
  if (fs.existsSync(filePath)) {
18
- lines = fs.readFileSync(filePath, "utf8").split("\n");
50
+ const content = fs.readFileSync(filePath, "utf8");
51
+ for (const line of content.split("\n")) {
52
+ const parsed = parseEnvLine(line);
53
+ // Remove lines that match any key we're about to upsert
54
+ if (parsed && vars.some((v) => v.key === parsed.key))
55
+ continue;
56
+ lines.push(line);
57
+ }
19
58
  }
20
59
  for (const { key, value } of vars) {
21
- const escapedValue = value.replace(/"/g, '\\"');
22
- const newLine = `${key}="${escapedValue}"`;
23
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
- const regex = new RegExp(`^${escapedKey}\\s*=.+$`, "m");
25
- const idx = lines.findIndex((l) => regex.test(l));
26
- if (idx !== -1) {
27
- lines[idx] = newLine;
60
+ const escapedValue = escapeEnvValue(value);
61
+ lines.push(`${key}="${escapedValue}"`);
62
+ }
63
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
64
+ }
65
+ /**
66
+ * Safe numeric env parser: returns defaultVal if value is missing or NaN.
67
+ */
68
+ function safeParseInt(envVal, defaultVal) {
69
+ if (envVal === undefined)
70
+ return defaultVal;
71
+ const parsed = Number(envVal);
72
+ if (isNaN(parsed))
73
+ return defaultVal;
74
+ return parsed;
75
+ }
76
+ /**
77
+ * Walk up the directory tree from `startDir` (default CWD) toward root,
78
+ * searching each ancestor for `filename`. Returns the value of the first match.
79
+ * Stops at filesystem root or when a `.git` dir is found (project boundary).
80
+ */
81
+ function resolveEnvFileUpward(filename, startDir) {
82
+ let current = startDir ?? process.cwd();
83
+ let last = "";
84
+ while (current !== last) {
85
+ last = current;
86
+ const filePath = path.join(current, filename);
87
+ try {
88
+ if (fs.existsSync(filePath)) {
89
+ const content = fs.readFileSync(filePath, "utf8");
90
+ for (const line of content.split("\n")) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed.startsWith("MUNIN_PROJECT="))
93
+ continue;
94
+ return trimmed.slice("MUNIN_PROJECT=".length).trim();
95
+ }
96
+ }
28
97
  }
29
- else {
30
- lines.push(newLine);
98
+ catch (error) {
99
+ if (error.code !== "ENOENT") {
100
+ console.error(`[munin-runtime] Failed to read ${filePath}:`, error);
101
+ }
31
102
  }
103
+ // Stop at git root (project boundary)
104
+ if (fs.existsSync(path.join(current, ".git")))
105
+ break;
106
+ current = path.dirname(current);
107
+ }
108
+ return undefined;
109
+ }
110
+ /**
111
+ * Resolve MUNIN_PROJECT from multiple sources, in priority order:
112
+ * 1. Explicit environment variable
113
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
114
+ * 3. .env in any ancestor directory (walk-up from CWD)
115
+ */
116
+ export function resolveProjectId() {
117
+ // 1. Explicit env var (highest priority)
118
+ if (process.env.MUNIN_PROJECT) {
119
+ return process.env.MUNIN_PROJECT;
120
+ }
121
+ // 2. Walk upward from CWD — find .env.local in any ancestor dir
122
+ const fromLocal = resolveEnvFileUpward(".env.local");
123
+ if (fromLocal)
124
+ return fromLocal;
125
+ // 3. Walk upward from CWD — find .env in any ancestor dir
126
+ const fromEnv = resolveEnvFileUpward(".env");
127
+ if (fromEnv)
128
+ return fromEnv;
129
+ return undefined;
130
+ }
131
+ export function loadCliEnv() {
132
+ return {
133
+ baseUrl: process.env.MUNIN_BASE_URL || "https://munin.kalera.dev",
134
+ apiKey: process.env.MUNIN_API_KEY,
135
+ timeoutMs: safeParseInt(process.env.MUNIN_TIMEOUT_MS, 15_000),
136
+ retries: safeParseInt(process.env.MUNIN_RETRIES, 3),
137
+ backoffMs: safeParseInt(process.env.MUNIN_BACKOFF_MS, 300),
138
+ };
139
+ }
140
+ export async function executeWithRetry(task, retries, backoffMs) {
141
+ let attempt = 0;
142
+ let lastError;
143
+ while (attempt < retries) {
144
+ try {
145
+ return await task();
146
+ }
147
+ catch (error) {
148
+ lastError = error;
149
+ attempt += 1;
150
+ if (attempt >= retries)
151
+ break;
152
+ const jitter = Math.floor(Math.random() * 100);
153
+ await sleep(backoffMs * 2 ** attempt + jitter);
154
+ }
155
+ }
156
+ throw lastError;
157
+ }
158
+ export function safeError(error) {
159
+ if (error instanceof Error) {
160
+ return {
161
+ name: error.name,
162
+ message: redactText(error.message),
163
+ };
164
+ }
165
+ return { message: redactText(String(error)) };
166
+ }
167
+ function redactText(text) {
168
+ return REDACT_KEYS.reduce((acc, key) => {
169
+ // Replace key=value patterns with key=[REDACTED] using simple string split
170
+ let result = acc;
171
+ let idx = result.indexOf(`${key}=`);
172
+ while (idx !== -1) {
173
+ const endIdx = result.indexOf(/[\s,;]/.test(result[idx + key.length + 1] ?? "")
174
+ ? result[idx + key.length + 1]
175
+ : "");
176
+ const end = endIdx === -1 ? result.length : endIdx;
177
+ const before = result.slice(0, idx + key.length + 1);
178
+ const after = result.slice(end);
179
+ result = `${before}[REDACTED]${after}`;
180
+ idx = result.indexOf(`${key}=`, idx + key.length + 1);
181
+ }
182
+ return result;
183
+ }, text);
184
+ }
185
+ function sleep(ms) {
186
+ return new Promise((resolve) => setTimeout(resolve, ms));
187
+ }
188
+ export function parseCliArgs(argv, usage) {
189
+ const [action, payloadRaw] = argv;
190
+ if (!action)
191
+ throw new Error(usage);
192
+ if (!payloadRaw)
193
+ return { action, payload: {} };
194
+ try {
195
+ return { action, payload: JSON.parse(payloadRaw) };
196
+ }
197
+ catch {
198
+ throw new Error("Payload must be valid JSON");
32
199
  }
33
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
34
200
  }
201
+ export * from "./mcp-server.js";
package/dist/index.d.ts CHANGED
@@ -14,8 +14,8 @@ export declare function loadCliEnv(): CliEnv;
14
14
  /**
15
15
  * Resolve MUNIN_PROJECT from multiple sources, in priority order:
16
16
  * 1. Explicit environment variable
17
- * 2. .env.local in current working directory
18
- * 3. .env in current working directory
17
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
18
+ * 3. .env in any ancestor directory (walk-up from CWD)
19
19
  */
20
20
  export declare function resolveProjectId(): string | undefined;
21
21
  export declare function executeWithRetry<T>(task: () => Promise<T>, retries: number, backoffMs: number): Promise<T>;
package/dist/index.js CHANGED
@@ -21,68 +21,88 @@ export function loadCliEnv() {
21
21
  return {
22
22
  baseUrl: process.env.MUNIN_BASE_URL || "https://munin.kalera.dev",
23
23
  apiKey: process.env.MUNIN_API_KEY,
24
- timeoutMs: Number(process.env.MUNIN_TIMEOUT_MS ?? 15000),
25
- retries: Number(process.env.MUNIN_RETRIES ?? 3),
26
- backoffMs: Number(process.env.MUNIN_BACKOFF_MS ?? 300),
24
+ timeoutMs: safeParseInt(process.env.MUNIN_TIMEOUT_MS, 15_000),
25
+ retries: safeParseInt(process.env.MUNIN_RETRIES, 3),
26
+ backoffMs: safeParseInt(process.env.MUNIN_BACKOFF_MS, 300),
27
27
  };
28
28
  }
29
+ function safeParseInt(envVal, defaultVal) {
30
+ if (envVal === undefined)
31
+ return defaultVal;
32
+ const parsed = Number(envVal);
33
+ return isNaN(parsed) ? defaultVal : parsed;
34
+ }
35
+ /**
36
+ * Walk up the directory tree from `startDir` (default CWD) toward root,
37
+ * searching each ancestor for `filename`. Returns the value of the first match.
38
+ * Stops at filesystem root or when a `.git` dir is found (project boundary).
39
+ */
40
+ function resolveEnvFileUpward(filename, startDir) {
41
+ const fs = require("fs");
42
+ const path = require("path");
43
+ let current = startDir ?? process.cwd();
44
+ let last = "";
45
+ while (current !== last) {
46
+ last = current;
47
+ const filePath = path.join(current, filename);
48
+ try {
49
+ if (fs.existsSync(filePath)) {
50
+ const content = fs.readFileSync(filePath, "utf8");
51
+ for (const line of content.split("\n")) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed.startsWith("MUNIN_PROJECT="))
54
+ continue;
55
+ return trimmed.slice("MUNIN_PROJECT=".length).trim();
56
+ }
57
+ }
58
+ }
59
+ catch (error) {
60
+ if (error.code !== "ENOENT") {
61
+ console.error(`[munin-runtime] Failed to read ${filePath}:`, error);
62
+ }
63
+ }
64
+ // Stop at git root (project boundary)
65
+ if (fs.existsSync(path.join(current, ".git")))
66
+ break;
67
+ current = path.dirname(current);
68
+ }
69
+ return undefined;
70
+ }
29
71
  /**
30
72
  * Resolve MUNIN_PROJECT from multiple sources, in priority order:
31
73
  * 1. Explicit environment variable
32
- * 2. .env.local in current working directory
33
- * 3. .env in current working directory
74
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
75
+ * 3. .env in any ancestor directory (walk-up from CWD)
34
76
  */
35
77
  export function resolveProjectId() {
36
78
  // 1. Explicit env var (highest priority)
37
79
  if (process.env.MUNIN_PROJECT) {
38
80
  return process.env.MUNIN_PROJECT;
39
81
  }
40
- // 2. .env.local in CWD
41
- const envLocal = resolveEnvFile(".env.local");
42
- if (envLocal)
43
- return envLocal;
44
- // 3. .env in CWD
45
- const envFile = resolveEnvFile(".env");
46
- if (envFile)
47
- return envFile;
48
- return undefined;
49
- }
50
- /**
51
- * Read a .env file and extract MUNIN_PROJECT value.
52
- * Returns undefined if file doesn't exist or value not found.
53
- */
54
- function resolveEnvFile(filename) {
55
- try {
56
- const path = `${process.cwd()}/${filename}`;
57
- const fs = require("fs");
58
- if (!fs.existsSync(path))
59
- return undefined;
60
- const content = fs.readFileSync(path, "utf8");
61
- const match = content.match(/^MUNIN_PROJECT\s*=\s*(.+)$/m);
62
- if (match) {
63
- return match[1].trim();
64
- }
65
- }
66
- catch {
67
- // Ignore errors — file might be unreadable
68
- }
82
+ // 2. Walk upward from CWD — find .env.local in any ancestor dir
83
+ const fromLocal = resolveEnvFileUpward(".env.local");
84
+ if (fromLocal)
85
+ return fromLocal;
86
+ // 3. Walk upward from CWD — find .env in any ancestor dir
87
+ const fromEnv = resolveEnvFileUpward(".env");
88
+ if (fromEnv)
89
+ return fromEnv;
69
90
  return undefined;
70
91
  }
71
92
  export async function executeWithRetry(task, retries, backoffMs) {
72
93
  let attempt = 0;
73
94
  let lastError;
74
- while (attempt <= retries) {
95
+ while (attempt < retries) {
75
96
  try {
76
97
  return await task();
77
98
  }
78
99
  catch (error) {
79
100
  lastError = error;
80
- if (attempt === retries) {
101
+ attempt += 1;
102
+ if (attempt >= retries)
81
103
  break;
82
- }
83
104
  const jitter = Math.floor(Math.random() * 100);
84
105
  await sleep(backoffMs * 2 ** attempt + jitter);
85
- attempt += 1;
86
106
  }
87
107
  }
88
108
  throw lastError;
@@ -1,6 +1,8 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { loadCliEnv } from "./index.js";
3
- export declare function createMcpServerInstance(env: ReturnType<typeof loadCliEnv>): Server<{
3
+ export declare function createMcpServerInstance(env: ReturnType<typeof loadCliEnv>, opts?: {
4
+ allowMissingApiKey?: boolean;
5
+ }): Server<{
4
6
  method: string;
5
7
  params?: {
6
8
  [x: string]: unknown;
@@ -3,10 +3,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { MuninClient } from "@kalera/munin-sdk";
5
5
  import { loadCliEnv, resolveProjectId, resolveEncryptionKey, safeError } from "./index.js";
6
- export function createMcpServerInstance(env) {
6
+ export function createMcpServerInstance(env, opts) {
7
+ if (!env.apiKey && !opts?.allowMissingApiKey) {
8
+ throw new Error("MUNIN_API_KEY is required. Set it in your environment or .env file.");
9
+ }
7
10
  const client = new MuninClient({
8
11
  baseUrl: env.baseUrl,
9
- apiKey: env.apiKey || 'test-key',
12
+ apiKey: env.apiKey,
10
13
  timeoutMs: env.timeoutMs,
11
14
  });
12
15
  const server = new Server({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kalera/munin-runtime",
3
- "version": "1.2.5",
3
+ "version": "1.2.6",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@modelcontextprotocol/sdk": "^1.27.1",
15
- "@kalera/munin-sdk": "1.2.5"
15
+ "@kalera/munin-sdk": "1.2.6"
16
16
  },
17
17
  "scripts": {
18
18
  "build": "tsc -p tsconfig.json",
package/src/env.ts CHANGED
@@ -6,6 +6,8 @@ export interface EnvVar {
6
6
  value: string;
7
7
  }
8
8
 
9
+ const REDACT_KEYS = ["apiKey", "authorization", "token", "secret", "password"];
10
+
9
11
  /**
10
12
  * Resolve the encryption key for E2EE projects from MUNIN_ENCRYPTION_KEY env var.
11
13
  */
@@ -13,10 +15,44 @@ export function resolveEncryptionKey(): string | undefined {
13
15
  return process.env.MUNIN_ENCRYPTION_KEY;
14
16
  }
15
17
 
18
+ /**
19
+ * Escape shell/metacharacters that could inject into a .env value.
20
+ * Escapes: $, backticks, \, and newlines — in addition to double quotes.
21
+ */
22
+ function escapeEnvValue(value: string): string {
23
+ return value
24
+ .replace(/\\/g, "\\\\") // escape backslashes first
25
+ .replace(/\n/g, "\\n") // escape newlines
26
+ .replace(/\r/g, "\\r") // escape carriage returns
27
+ .replace(/`/g, "\\`") // escape backticks
28
+ .replace(/\$/g, "\\$") // escape dollar signs
29
+ .replace(/"/g, '\\"'); // escape double quotes
30
+ }
31
+
32
+ /**
33
+ * Parse a .env line, returning { key, value } or null if not a valid assignment.
34
+ * Uses a string-based split instead of regex to avoid injection from .env content.
35
+ */
36
+ function parseEnvLine(line: string): EnvVar | null {
37
+ // Skip empty lines and comments
38
+ const trimmed = line.trim();
39
+ if (!trimmed || trimmed.startsWith("#")) return null;
40
+
41
+ const eqIndex = trimmed.indexOf("=");
42
+ if (eqIndex === -1) return null;
43
+
44
+ const key = trimmed.slice(0, eqIndex).trim();
45
+ // Value is everything after the first '=' (unquoted, no regex)
46
+ const rawValue = trimmed.slice(eqIndex + 1);
47
+
48
+ return { key, value: rawValue };
49
+ }
50
+
16
51
  /**
17
52
  * Upsert key=value pairs into a .env file.
18
53
  * - Existing keys are replaced.
19
54
  * - New keys are appended.
55
+ * - Keys are matched via string split (not regex) to prevent injection.
20
56
  */
21
57
  export function writeEnvFile(
22
58
  filename: string,
@@ -24,25 +60,177 @@ export function writeEnvFile(
24
60
  cwd: string = process.cwd(),
25
61
  ): void {
26
62
  const filePath = path.resolve(cwd, filename);
27
- let lines: string[] = [];
63
+ const lines: string[] = [];
28
64
 
29
65
  if (fs.existsSync(filePath)) {
30
- lines = fs.readFileSync(filePath, "utf8").split("\n");
66
+ const content = fs.readFileSync(filePath, "utf8");
67
+ for (const line of content.split("\n")) {
68
+ const parsed = parseEnvLine(line);
69
+ // Remove lines that match any key we're about to upsert
70
+ if (parsed && vars.some((v) => v.key === parsed.key)) continue;
71
+ lines.push(line);
72
+ }
31
73
  }
32
74
 
33
75
  for (const { key, value } of vars) {
34
- const escapedValue = value.replace(/"/g, '\\"');
35
- const newLine = `${key}="${escapedValue}"`;
36
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
- const regex = new RegExp(`^${escapedKey}\\s*=.+$`, "m");
38
-
39
- const idx = lines.findIndex((l) => regex.test(l));
40
- if (idx !== -1) {
41
- lines[idx] = newLine;
42
- } else {
43
- lines.push(newLine);
44
- }
76
+ const escapedValue = escapeEnvValue(value);
77
+ lines.push(`${key}="${escapedValue}"`);
45
78
  }
46
79
 
47
80
  fs.writeFileSync(filePath, lines.join("\n"), "utf8");
48
81
  }
82
+
83
+ /**
84
+ * Safe numeric env parser: returns defaultVal if value is missing or NaN.
85
+ */
86
+ function safeParseInt(envVal: string | undefined, defaultVal: number): number {
87
+ if (envVal === undefined) return defaultVal;
88
+ const parsed = Number(envVal);
89
+ if (isNaN(parsed)) return defaultVal;
90
+ return parsed;
91
+ }
92
+
93
+ /**
94
+ * Walk up the directory tree from `startDir` (default CWD) toward root,
95
+ * searching each ancestor for `filename`. Returns the value of the first match.
96
+ * Stops at filesystem root or when a `.git` dir is found (project boundary).
97
+ */
98
+ function resolveEnvFileUpward(filename: string, startDir?: string): string | undefined {
99
+ let current = startDir ?? process.cwd();
100
+ let last = "";
101
+
102
+ while (current !== last) {
103
+ last = current;
104
+ const filePath = path.join(current, filename);
105
+ try {
106
+ if (fs.existsSync(filePath)) {
107
+ const content = fs.readFileSync(filePath, "utf8");
108
+ for (const line of content.split("\n")) {
109
+ const trimmed = line.trim();
110
+ if (!trimmed.startsWith("MUNIN_PROJECT=")) continue;
111
+ return trimmed.slice("MUNIN_PROJECT=".length).trim();
112
+ }
113
+ }
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
116
+ console.error(`[munin-runtime] Failed to read ${filePath}:`, error);
117
+ }
118
+ }
119
+
120
+ // Stop at git root (project boundary)
121
+ if (fs.existsSync(path.join(current, ".git"))) break;
122
+
123
+ current = path.dirname(current);
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /**
129
+ * Resolve MUNIN_PROJECT from multiple sources, in priority order:
130
+ * 1. Explicit environment variable
131
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
132
+ * 3. .env in any ancestor directory (walk-up from CWD)
133
+ */
134
+ export function resolveProjectId(): string | undefined {
135
+ // 1. Explicit env var (highest priority)
136
+ if (process.env.MUNIN_PROJECT) {
137
+ return process.env.MUNIN_PROJECT;
138
+ }
139
+
140
+ // 2. Walk upward from CWD — find .env.local in any ancestor dir
141
+ const fromLocal = resolveEnvFileUpward(".env.local");
142
+ if (fromLocal) return fromLocal;
143
+
144
+ // 3. Walk upward from CWD — find .env in any ancestor dir
145
+ const fromEnv = resolveEnvFileUpward(".env");
146
+ if (fromEnv) return fromEnv;
147
+
148
+ return undefined;
149
+ }
150
+
151
+ export interface CliEnv {
152
+ baseUrl: string;
153
+ apiKey: string | undefined;
154
+ timeoutMs: number;
155
+ retries: number;
156
+ backoffMs: number;
157
+ }
158
+
159
+ export function loadCliEnv(): CliEnv {
160
+ return {
161
+ baseUrl: process.env.MUNIN_BASE_URL || "https://munin.kalera.dev",
162
+ apiKey: process.env.MUNIN_API_KEY,
163
+ timeoutMs: safeParseInt(process.env.MUNIN_TIMEOUT_MS, 15_000),
164
+ retries: safeParseInt(process.env.MUNIN_RETRIES, 3),
165
+ backoffMs: safeParseInt(process.env.MUNIN_BACKOFF_MS, 300),
166
+ };
167
+ }
168
+
169
+ export async function executeWithRetry<T>(
170
+ task: () => Promise<T>,
171
+ retries: number,
172
+ backoffMs: number,
173
+ ): Promise<T> {
174
+ let attempt = 0;
175
+ let lastError: unknown;
176
+
177
+ while (attempt < retries) {
178
+ try {
179
+ return await task();
180
+ } catch (error) {
181
+ lastError = error;
182
+ attempt += 1;
183
+ if (attempt >= retries) break;
184
+ const jitter = Math.floor(Math.random() * 100);
185
+ await sleep(backoffMs * 2 ** attempt + jitter);
186
+ }
187
+ }
188
+
189
+ throw lastError;
190
+ }
191
+
192
+ export function safeError(error: unknown): Record<string, unknown> {
193
+ if (error instanceof Error) {
194
+ return {
195
+ name: error.name,
196
+ message: redactText(error.message),
197
+ };
198
+ }
199
+ return { message: redactText(String(error)) };
200
+ }
201
+
202
+ function redactText(text: string): string {
203
+ return REDACT_KEYS.reduce((acc, key) => {
204
+ // Replace key=value patterns with key=[REDACTED] using simple string split
205
+ let result = acc;
206
+ let idx = result.indexOf(`${key}=`);
207
+ while (idx !== -1) {
208
+ const endIdx = result.indexOf(/[\s,;]/.test(result[idx + key.length + 1] ?? "")
209
+ ? result[idx + key.length + 1]
210
+ : "");
211
+ const end = endIdx === -1 ? result.length : endIdx;
212
+ const before = result.slice(0, idx + key.length + 1);
213
+ const after = result.slice(end);
214
+ result = `${before}[REDACTED]${after}`;
215
+ idx = result.indexOf(`${key}=`, idx + key.length + 1);
216
+ }
217
+ return result;
218
+ }, text);
219
+ }
220
+
221
+ function sleep(ms: number): Promise<void> {
222
+ return new Promise((resolve) => setTimeout(resolve, ms));
223
+ }
224
+
225
+ export function parseCliArgs(argv: string[], usage: string): { action: string; payload: Record<string, unknown> } {
226
+ const [action, payloadRaw] = argv;
227
+ if (!action) throw new Error(usage);
228
+ if (!payloadRaw) return { action, payload: {} };
229
+ try {
230
+ return { action, payload: JSON.parse(payloadRaw) as Record<string, unknown> };
231
+ } catch {
232
+ throw new Error("Payload must be valid JSON");
233
+ }
234
+ }
235
+
236
+ export * from "./mcp-server.js";
package/src/index.ts CHANGED
@@ -37,17 +37,61 @@ export function loadCliEnv(): CliEnv {
37
37
  return {
38
38
  baseUrl: process.env.MUNIN_BASE_URL || "https://munin.kalera.dev",
39
39
  apiKey: process.env.MUNIN_API_KEY,
40
- timeoutMs: Number(process.env.MUNIN_TIMEOUT_MS ?? 15000),
41
- retries: Number(process.env.MUNIN_RETRIES ?? 3),
42
- backoffMs: Number(process.env.MUNIN_BACKOFF_MS ?? 300),
40
+ timeoutMs: safeParseInt(process.env.MUNIN_TIMEOUT_MS, 15_000),
41
+ retries: safeParseInt(process.env.MUNIN_RETRIES, 3),
42
+ backoffMs: safeParseInt(process.env.MUNIN_BACKOFF_MS, 300),
43
43
  };
44
44
  }
45
45
 
46
+ function safeParseInt(envVal: string | undefined, defaultVal: number): number {
47
+ if (envVal === undefined) return defaultVal;
48
+ const parsed = Number(envVal);
49
+ return isNaN(parsed) ? defaultVal : parsed;
50
+ }
51
+
52
+ /**
53
+ * Walk up the directory tree from `startDir` (default CWD) toward root,
54
+ * searching each ancestor for `filename`. Returns the value of the first match.
55
+ * Stops at filesystem root or when a `.git` dir is found (project boundary).
56
+ */
57
+ function resolveEnvFileUpward(filename: string, startDir?: string): string | undefined {
58
+ const fs = require("fs") as typeof import("fs");
59
+ const path = require("path") as typeof import("path");
60
+
61
+ let current = startDir ?? process.cwd();
62
+ let last = "";
63
+
64
+ while (current !== last) {
65
+ last = current;
66
+ const filePath = path.join(current, filename);
67
+ try {
68
+ if (fs.existsSync(filePath)) {
69
+ const content = fs.readFileSync(filePath, "utf8");
70
+ for (const line of content.split("\n")) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed.startsWith("MUNIN_PROJECT=")) continue;
73
+ return trimmed.slice("MUNIN_PROJECT=".length).trim();
74
+ }
75
+ }
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
78
+ console.error(`[munin-runtime] Failed to read ${filePath}:`, error);
79
+ }
80
+ }
81
+
82
+ // Stop at git root (project boundary)
83
+ if (fs.existsSync(path.join(current, ".git"))) break;
84
+
85
+ current = path.dirname(current);
86
+ }
87
+ return undefined;
88
+ }
89
+
46
90
  /**
47
91
  * Resolve MUNIN_PROJECT from multiple sources, in priority order:
48
92
  * 1. Explicit environment variable
49
- * 2. .env.local in current working directory
50
- * 3. .env in current working directory
93
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
94
+ * 3. .env in any ancestor directory (walk-up from CWD)
51
95
  */
52
96
  export function resolveProjectId(): string | undefined {
53
97
  // 1. Explicit env var (highest priority)
@@ -55,37 +99,17 @@ export function resolveProjectId(): string | undefined {
55
99
  return process.env.MUNIN_PROJECT;
56
100
  }
57
101
 
58
- // 2. .env.local in CWD
59
- const envLocal = resolveEnvFile(".env.local");
60
- if (envLocal) return envLocal;
102
+ // 2. Walk upward from CWD — find .env.local in any ancestor dir
103
+ const fromLocal = resolveEnvFileUpward(".env.local");
104
+ if (fromLocal) return fromLocal;
61
105
 
62
- // 3. .env in CWD
63
- const envFile = resolveEnvFile(".env");
64
- if (envFile) return envFile;
106
+ // 3. Walk upward from CWD — find .env in any ancestor dir
107
+ const fromEnv = resolveEnvFileUpward(".env");
108
+ if (fromEnv) return fromEnv;
65
109
 
66
110
  return undefined;
67
111
  }
68
112
 
69
- /**
70
- * Read a .env file and extract MUNIN_PROJECT value.
71
- * Returns undefined if file doesn't exist or value not found.
72
- */
73
- function resolveEnvFile(filename: string): string | undefined {
74
- try {
75
- const path = `${process.cwd()}/${filename}`;
76
- const fs = require("fs") as typeof import("fs");
77
- if (!fs.existsSync(path)) return undefined;
78
-
79
- const content = fs.readFileSync(path, "utf8");
80
- const match = content.match(/^MUNIN_PROJECT\s*=\s*(.+)$/m);
81
- if (match) {
82
- return match[1].trim();
83
- }
84
- } catch {
85
- // Ignore errors — file might be unreadable
86
- }
87
- return undefined;
88
- }
89
113
 
90
114
  export async function executeWithRetry<T>(
91
115
  task: () => Promise<T>,
@@ -95,17 +119,15 @@ export async function executeWithRetry<T>(
95
119
  let attempt = 0;
96
120
  let lastError: unknown;
97
121
 
98
- while (attempt <= retries) {
122
+ while (attempt < retries) {
99
123
  try {
100
124
  return await task();
101
125
  } catch (error) {
102
126
  lastError = error;
103
- if (attempt === retries) {
104
- break;
105
- }
127
+ attempt += 1;
128
+ if (attempt >= retries) break;
106
129
  const jitter = Math.floor(Math.random() * 100);
107
130
  await sleep(backoffMs * 2 ** attempt + jitter);
108
- attempt += 1;
109
131
  }
110
132
  }
111
133
 
package/src/mcp-server.ts CHANGED
@@ -7,10 +7,19 @@ import {
7
7
  import { MuninClient } from "@kalera/munin-sdk";
8
8
  import { loadCliEnv, resolveProjectId, resolveEncryptionKey, safeError } from "./index.js";
9
9
 
10
- export function createMcpServerInstance(env: ReturnType<typeof loadCliEnv>) {
10
+ export function createMcpServerInstance(
11
+ env: ReturnType<typeof loadCliEnv>,
12
+ opts?: { allowMissingApiKey?: boolean },
13
+ ) {
14
+ if (!env.apiKey && !opts?.allowMissingApiKey) {
15
+ throw new Error(
16
+ "MUNIN_API_KEY is required. Set it in your environment or .env file.",
17
+ );
18
+ }
19
+
11
20
  const client = new MuninClient({
12
21
  baseUrl: env.baseUrl,
13
- apiKey: env.apiKey || 'test-key',
22
+ apiKey: env.apiKey,
14
23
  timeoutMs: env.timeoutMs,
15
24
  });
16
25