@kalera/munin-runtime 1.2.0 → 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.0 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 ADDED
@@ -0,0 +1,37 @@
1
+ export interface EnvVar {
2
+ key: string;
3
+ value: string;
4
+ }
5
+ /**
6
+ * Resolve the encryption key for E2EE projects from MUNIN_ENCRYPTION_KEY env var.
7
+ */
8
+ export declare function resolveEncryptionKey(): string | undefined;
9
+ /**
10
+ * Upsert key=value pairs into a .env file.
11
+ * - Existing keys are replaced.
12
+ * - New keys are appended.
13
+ * - Keys are matched via string split (not regex) to prevent injection.
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 ADDED
@@ -0,0 +1,201 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ const REDACT_KEYS = ["apiKey", "authorization", "token", "secret", "password"];
4
+ /**
5
+ * Resolve the encryption key for E2EE projects from MUNIN_ENCRYPTION_KEY env var.
6
+ */
7
+ export function resolveEncryptionKey() {
8
+ return process.env.MUNIN_ENCRYPTION_KEY;
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
+ }
40
+ /**
41
+ * Upsert key=value pairs into a .env file.
42
+ * - Existing keys are replaced.
43
+ * - New keys are appended.
44
+ * - Keys are matched via string split (not regex) to prevent injection.
45
+ */
46
+ export function writeEnvFile(filename, vars, cwd = process.cwd()) {
47
+ const filePath = path.resolve(cwd, filename);
48
+ const lines = [];
49
+ if (fs.existsSync(filePath)) {
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
+ }
58
+ }
59
+ for (const { key, value } of vars) {
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
+ }
97
+ }
98
+ catch (error) {
99
+ if (error.code !== "ENOENT") {
100
+ console.error(`[munin-runtime] Failed to read ${filePath}:`, error);
101
+ }
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");
199
+ }
200
+ }
201
+ export * from "./mcp-server.js";
package/dist/index.d.ts CHANGED
@@ -11,6 +11,14 @@ export interface CliEnv {
11
11
  }
12
12
  export declare function parseCliArgs(argv: string[], usage: string): ParsedCliArgs;
13
13
  export declare function loadCliEnv(): CliEnv;
14
+ /**
15
+ * Resolve MUNIN_PROJECT from multiple sources, in priority order:
16
+ * 1. Explicit environment variable
17
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
18
+ * 3. .env in any ancestor directory (walk-up from CWD)
19
+ */
20
+ export declare function resolveProjectId(): string | undefined;
14
21
  export declare function executeWithRetry<T>(task: () => Promise<T>, retries: number, backoffMs: number): Promise<T>;
15
22
  export declare function safeError(error: unknown): Record<string, unknown>;
16
23
  export * from "./mcp-server.js";
24
+ export * from "./env.js";
package/dist/index.js CHANGED
@@ -19,28 +19,90 @@ export function parseCliArgs(argv, usage) {
19
19
  }
20
20
  export function loadCliEnv() {
21
21
  return {
22
- baseUrl: "https://munin.kalera.dev",
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
+ }
71
+ /**
72
+ * Resolve MUNIN_PROJECT from multiple sources, in priority order:
73
+ * 1. Explicit environment variable
74
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
75
+ * 3. .env in any ancestor directory (walk-up from CWD)
76
+ */
77
+ export function resolveProjectId() {
78
+ // 1. Explicit env var (highest priority)
79
+ if (process.env.MUNIN_PROJECT) {
80
+ return process.env.MUNIN_PROJECT;
81
+ }
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;
90
+ return undefined;
91
+ }
29
92
  export async function executeWithRetry(task, retries, backoffMs) {
30
93
  let attempt = 0;
31
94
  let lastError;
32
- while (attempt <= retries) {
95
+ while (attempt < retries) {
33
96
  try {
34
97
  return await task();
35
98
  }
36
99
  catch (error) {
37
100
  lastError = error;
38
- if (attempt === retries) {
101
+ attempt += 1;
102
+ if (attempt >= retries)
39
103
  break;
40
- }
41
104
  const jitter = Math.floor(Math.random() * 100);
42
105
  await sleep(backoffMs * 2 ** attempt + jitter);
43
- attempt += 1;
44
106
  }
45
107
  }
46
108
  throw lastError;
@@ -64,3 +126,4 @@ function sleep(ms) {
64
126
  return new Promise((resolve) => setTimeout(resolve, ms));
65
127
  }
66
128
  export * from "./mcp-server.js";
129
+ export * from "./env.js";
@@ -1 +1,39 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { loadCliEnv } from "./index.js";
3
+ export declare function createMcpServerInstance(env: ReturnType<typeof loadCliEnv>, opts?: {
4
+ allowMissingApiKey?: boolean;
5
+ }): Server<{
6
+ method: string;
7
+ params?: {
8
+ [x: string]: unknown;
9
+ _meta?: {
10
+ [x: string]: unknown;
11
+ progressToken?: string | number | undefined;
12
+ "io.modelcontextprotocol/related-task"?: {
13
+ taskId: string;
14
+ } | undefined;
15
+ } | undefined;
16
+ } | undefined;
17
+ }, {
18
+ method: string;
19
+ params?: {
20
+ [x: string]: unknown;
21
+ _meta?: {
22
+ [x: string]: unknown;
23
+ progressToken?: string | number | undefined;
24
+ "io.modelcontextprotocol/related-task"?: {
25
+ taskId: string;
26
+ } | undefined;
27
+ } | undefined;
28
+ } | undefined;
29
+ }, {
30
+ [x: string]: unknown;
31
+ _meta?: {
32
+ [x: string]: unknown;
33
+ progressToken?: string | number | undefined;
34
+ "io.modelcontextprotocol/related-task"?: {
35
+ taskId: string;
36
+ } | undefined;
37
+ } | undefined;
38
+ }>;
1
39
  export declare function startMcpServer(): Promise<void>;
@@ -2,9 +2,11 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  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
- import { loadCliEnv, safeError } from "./index.js";
6
- export async function startMcpServer() {
7
- const env = loadCliEnv();
5
+ import { loadCliEnv, resolveProjectId, resolveEncryptionKey, safeError } from "./index.js";
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
+ }
8
10
  const client = new MuninClient({
9
11
  baseUrl: env.baseUrl,
10
12
  apiKey: env.apiKey,
@@ -54,27 +56,29 @@ export async function startMcpServer() {
54
56
  },
55
57
  {
56
58
  name: "munin_search_memories",
57
- description: "Search for memories using semantic search or keywords. Returns formatted, token-efficient GraphRAG context. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
59
+ description: "Search for memories using semantic search or keywords. Returns formatted, token-efficient GraphRAG context. Supports pagination with topK/offset and optional total count. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
58
60
  inputSchema: {
59
61
  type: "object",
60
62
  properties: {
61
63
  projectId: { type: "string", description: "Optional. The Munin Context Core ID." },
62
64
  query: { type: "string", description: "Search query" },
63
65
  tags: { type: "array", items: { type: "string" } },
64
- limit: { type: "number", description: "Max results (default: 10)" },
66
+ topK: { type: "number", description: "Max results to return (default: 10, max: 50)" },
67
+ offset: { type: "number", description: "Pagination offset for fetching more results (default: 0)" },
68
+ includeTotal: { type: "boolean", description: "If true, includes total count in response (default: false)" },
65
69
  },
66
70
  required: ["query"],
67
71
  },
68
72
  },
69
73
  {
70
74
  name: "munin_list_memories",
71
- description: "List all memories with pagination. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
75
+ description: "List all memories with pagination support. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
72
76
  inputSchema: {
73
77
  type: "object",
74
78
  properties: {
75
79
  projectId: { type: "string", description: "Optional. The Munin Context Core ID." },
76
- limit: { type: "number" },
77
- offset: { type: "number" },
80
+ limit: { type: "number", description: "Max results to return (default: 10, max: 100)" },
81
+ offset: { type: "number", description: "Pagination offset (default: 0)" },
78
82
  },
79
83
  required: [],
80
84
  },
@@ -91,35 +95,83 @@ export async function startMcpServer() {
91
95
  required: [],
92
96
  },
93
97
  },
98
+ {
99
+ name: "munin_share_memory",
100
+ description: "Share one or more memories to other projects owned by the same user. The target project must share the same Hash Key to read encrypted content. Requires Pro or Elite tier. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ projectId: { type: "string", description: "Optional. The source project ID." },
105
+ memoryIds: {
106
+ type: "array",
107
+ items: { type: "string" },
108
+ description: "Array of memory IDs to share",
109
+ },
110
+ targetProjectIds: {
111
+ type: "array",
112
+ items: { type: "string" },
113
+ description: "Array of target project IDs to share memories into",
114
+ },
115
+ },
116
+ required: ["memoryIds", "targetProjectIds"],
117
+ },
118
+ },
119
+ {
120
+ name: "munin_get_project_info",
121
+ description: "Get current project metadata including E2EE status, tier features, and limits. CRITICAL: Before storing or retrieving memories in an E2EE project, verify the encryption key is set correctly. Shows whether MUNIN_ENCRYPTION_KEY is configured. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
122
+ inputSchema: {
123
+ type: "object",
124
+ properties: {
125
+ projectId: { type: "string", description: "Optional. Defaults to active project." },
126
+ },
127
+ required: [],
128
+ },
129
+ },
94
130
  ],
95
131
  };
96
132
  });
97
133
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
98
134
  try {
99
135
  const args = request.params.arguments || {};
100
- const projectId = args.projectId || process.env.MUNIN_PROJECT;
136
+ // Priority: explicit arg > env var > CWD .env file
137
+ const projectId = args.projectId || resolveProjectId();
101
138
  if (!projectId) {
102
- throw new Error("projectId is required in arguments or MUNIN_PROJECT environment variable");
139
+ throw new Error("projectId is required. Ensure MUNIN_PROJECT is set in .env or .env.local in your project directory, or passed as an argument.");
103
140
  }
104
141
  // Remove projectId from args before sending as payload
105
142
  const { projectId: _ignored, ...payload } = args;
143
+ // Auto-inject encryptionKey from env if not explicitly provided
144
+ const encryptionKey = args.encryptionKey || resolveEncryptionKey();
145
+ const enrichedPayload = encryptionKey ? { ...payload, encryptionKey } : payload;
106
146
  let result;
107
147
  switch (request.params.name) {
108
148
  case "munin_store_memory":
109
- result = await client.store(projectId, payload);
149
+ result = await client.store(projectId, enrichedPayload);
110
150
  break;
111
151
  case "munin_retrieve_memory":
112
- result = await client.retrieve(projectId, payload);
152
+ result = await client.retrieve(projectId, enrichedPayload);
113
153
  break;
114
154
  case "munin_search_memories":
115
- result = await client.search(projectId, payload);
155
+ result = await client.search(projectId, enrichedPayload);
116
156
  break;
117
157
  case "munin_list_memories":
118
- result = await client.list(projectId, payload);
158
+ result = await client.list(projectId, enrichedPayload);
119
159
  break;
120
160
  case "munin_recent_memories":
121
- result = await client.recent(projectId, payload);
161
+ result = await client.recent(projectId, enrichedPayload);
162
+ break;
163
+ case "munin_share_memory":
164
+ result = await client.invoke(projectId, "share", enrichedPayload);
165
+ break;
166
+ case "munin_get_project_info": {
167
+ const caps = await client.capabilities();
168
+ result = {
169
+ ok: true,
170
+ encryptionKeyConfigured: !!encryptionKey,
171
+ ...caps,
172
+ };
122
173
  break;
174
+ }
123
175
  default:
124
176
  throw new Error(`Unknown tool: ${request.params.name}`);
125
177
  }
@@ -145,6 +197,11 @@ export async function startMcpServer() {
145
197
  };
146
198
  }
147
199
  });
200
+ return server;
201
+ }
202
+ export async function startMcpServer() {
203
+ const env = loadCliEnv();
204
+ const server = createMcpServerInstance(env);
148
205
  const transport = new StdioServerTransport();
149
206
  await server.connect(transport);
150
207
  console.error("Munin MCP Server running on stdio");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kalera/munin-runtime",
3
- "version": "1.2.0",
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.0"
15
+ "@kalera/munin-sdk": "1.2.6"
16
16
  },
17
17
  "scripts": {
18
18
  "build": "tsc -p tsconfig.json",
package/src/env.ts ADDED
@@ -0,0 +1,236 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ export interface EnvVar {
5
+ key: string;
6
+ value: string;
7
+ }
8
+
9
+ const REDACT_KEYS = ["apiKey", "authorization", "token", "secret", "password"];
10
+
11
+ /**
12
+ * Resolve the encryption key for E2EE projects from MUNIN_ENCRYPTION_KEY env var.
13
+ */
14
+ export function resolveEncryptionKey(): string | undefined {
15
+ return process.env.MUNIN_ENCRYPTION_KEY;
16
+ }
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
+
51
+ /**
52
+ * Upsert key=value pairs into a .env file.
53
+ * - Existing keys are replaced.
54
+ * - New keys are appended.
55
+ * - Keys are matched via string split (not regex) to prevent injection.
56
+ */
57
+ export function writeEnvFile(
58
+ filename: string,
59
+ vars: EnvVar[],
60
+ cwd: string = process.cwd(),
61
+ ): void {
62
+ const filePath = path.resolve(cwd, filename);
63
+ const lines: string[] = [];
64
+
65
+ if (fs.existsSync(filePath)) {
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
+ }
73
+ }
74
+
75
+ for (const { key, value } of vars) {
76
+ const escapedValue = escapeEnvValue(value);
77
+ lines.push(`${key}="${escapedValue}"`);
78
+ }
79
+
80
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
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
@@ -35,14 +35,82 @@ export function parseCliArgs(argv: string[], usage: string): ParsedCliArgs {
35
35
 
36
36
  export function loadCliEnv(): CliEnv {
37
37
  return {
38
- baseUrl: "https://munin.kalera.dev",
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
+
90
+ /**
91
+ * Resolve MUNIN_PROJECT from multiple sources, in priority order:
92
+ * 1. Explicit environment variable
93
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
94
+ * 3. .env in any ancestor directory (walk-up from CWD)
95
+ */
96
+ export function resolveProjectId(): string | undefined {
97
+ // 1. Explicit env var (highest priority)
98
+ if (process.env.MUNIN_PROJECT) {
99
+ return process.env.MUNIN_PROJECT;
100
+ }
101
+
102
+ // 2. Walk upward from CWD — find .env.local in any ancestor dir
103
+ const fromLocal = resolveEnvFileUpward(".env.local");
104
+ if (fromLocal) return fromLocal;
105
+
106
+ // 3. Walk upward from CWD — find .env in any ancestor dir
107
+ const fromEnv = resolveEnvFileUpward(".env");
108
+ if (fromEnv) return fromEnv;
109
+
110
+ return undefined;
111
+ }
112
+
113
+
46
114
  export async function executeWithRetry<T>(
47
115
  task: () => Promise<T>,
48
116
  retries: number,
@@ -51,17 +119,15 @@ export async function executeWithRetry<T>(
51
119
  let attempt = 0;
52
120
  let lastError: unknown;
53
121
 
54
- while (attempt <= retries) {
122
+ while (attempt < retries) {
55
123
  try {
56
124
  return await task();
57
125
  } catch (error) {
58
126
  lastError = error;
59
- if (attempt === retries) {
60
- break;
61
- }
127
+ attempt += 1;
128
+ if (attempt >= retries) break;
62
129
  const jitter = Math.floor(Math.random() * 100);
63
130
  await sleep(backoffMs * 2 ** attempt + jitter);
64
- attempt += 1;
65
131
  }
66
132
  }
67
133
 
@@ -90,3 +156,4 @@ function sleep(ms: number): Promise<void> {
90
156
  return new Promise((resolve) => setTimeout(resolve, ms));
91
157
  }
92
158
  export * from "./mcp-server.js";
159
+ export * from "./env.js";
package/src/mcp-server.ts CHANGED
@@ -5,10 +5,17 @@ import {
5
5
  ListToolsRequestSchema,
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
7
  import { MuninClient } from "@kalera/munin-sdk";
8
- import { loadCliEnv, safeError } from "./index.js";
8
+ import { loadCliEnv, resolveProjectId, resolveEncryptionKey, safeError } from "./index.js";
9
9
 
10
- export async function startMcpServer() {
11
- const env = 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
+ }
12
19
 
13
20
  const client = new MuninClient({
14
21
  baseUrl: env.baseUrl,
@@ -64,27 +71,29 @@ export async function startMcpServer() {
64
71
  },
65
72
  {
66
73
  name: "munin_search_memories",
67
- description: "Search for memories using semantic search or keywords. Returns formatted, token-efficient GraphRAG context. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
74
+ description: "Search for memories using semantic search or keywords. Returns formatted, token-efficient GraphRAG context. Supports pagination with topK/offset and optional total count. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
68
75
  inputSchema: {
69
76
  type: "object",
70
77
  properties: {
71
78
  projectId: { type: "string", description: "Optional. The Munin Context Core ID." },
72
79
  query: { type: "string", description: "Search query" },
73
80
  tags: { type: "array", items: { type: "string" } },
74
- limit: { type: "number", description: "Max results (default: 10)" },
81
+ topK: { type: "number", description: "Max results to return (default: 10, max: 50)" },
82
+ offset: { type: "number", description: "Pagination offset for fetching more results (default: 0)" },
83
+ includeTotal: { type: "boolean", description: "If true, includes total count in response (default: false)" },
75
84
  },
76
85
  required: ["query"],
77
86
  },
78
87
  },
79
88
  {
80
89
  name: "munin_list_memories",
81
- description: "List all memories with pagination. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
90
+ description: "List all memories with pagination support. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
82
91
  inputSchema: {
83
92
  type: "object",
84
93
  properties: {
85
94
  projectId: { type: "string", description: "Optional. The Munin Context Core ID." },
86
- limit: { type: "number" },
87
- offset: { type: "number" },
95
+ limit: { type: "number", description: "Max results to return (default: 10, max: 100)" },
96
+ offset: { type: "number", description: "Pagination offset (default: 0)" },
88
97
  },
89
98
  required: [],
90
99
  },
@@ -101,6 +110,38 @@ export async function startMcpServer() {
101
110
  required: [],
102
111
  },
103
112
  },
113
+ {
114
+ name: "munin_share_memory",
115
+ description: "Share one or more memories to other projects owned by the same user. The target project must share the same Hash Key to read encrypted content. Requires Pro or Elite tier. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ projectId: { type: "string", description: "Optional. The source project ID." },
120
+ memoryIds: {
121
+ type: "array",
122
+ items: { type: "string" },
123
+ description: "Array of memory IDs to share",
124
+ },
125
+ targetProjectIds: {
126
+ type: "array",
127
+ items: { type: "string" },
128
+ description: "Array of target project IDs to share memories into",
129
+ },
130
+ },
131
+ required: ["memoryIds", "targetProjectIds"],
132
+ },
133
+ },
134
+ {
135
+ name: "munin_get_project_info",
136
+ description: "Get current project metadata including E2EE status, tier features, and limits. CRITICAL: Before storing or retrieving memories in an E2EE project, verify the encryption key is set correctly. Shows whether MUNIN_ENCRYPTION_KEY is configured. IMPORTANT: Call this as an MCP tool, NOT as a shell command.",
137
+ inputSchema: {
138
+ type: "object",
139
+ properties: {
140
+ projectId: { type: "string", description: "Optional. Defaults to active project." },
141
+ },
142
+ required: [],
143
+ },
144
+ },
104
145
  ],
105
146
  };
106
147
  });
@@ -108,33 +149,52 @@ export async function startMcpServer() {
108
149
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
109
150
  try {
110
151
  const args = request.params.arguments || {};
111
- const projectId = (args.projectId as string) || process.env.MUNIN_PROJECT;
112
-
152
+ // Priority: explicit arg > env var > CWD .env file
153
+ const projectId = (args.projectId as string) || resolveProjectId();
154
+
113
155
  if (!projectId) {
114
- throw new Error("projectId is required in arguments or MUNIN_PROJECT environment variable");
156
+ throw new Error(
157
+ "projectId is required. Ensure MUNIN_PROJECT is set in .env or .env.local in your project directory, or passed as an argument."
158
+ );
115
159
  }
116
160
 
117
161
  // Remove projectId from args before sending as payload
118
162
  const { projectId: _ignored, ...payload } = args;
119
163
 
164
+ // Auto-inject encryptionKey from env if not explicitly provided
165
+ const encryptionKey = (args.encryptionKey as string) || resolveEncryptionKey();
166
+ const enrichedPayload = encryptionKey ? { ...payload, encryptionKey } : payload;
167
+
120
168
  let result;
121
169
 
122
170
  switch (request.params.name) {
123
171
  case "munin_store_memory":
124
- result = await client.store(projectId, payload);
172
+ result = await client.store(projectId, enrichedPayload);
125
173
  break;
126
174
  case "munin_retrieve_memory":
127
- result = await client.retrieve(projectId, payload);
175
+ result = await client.retrieve(projectId, enrichedPayload);
128
176
  break;
129
177
  case "munin_search_memories":
130
- result = await client.search(projectId, payload);
178
+ result = await client.search(projectId, enrichedPayload);
131
179
  break;
132
180
  case "munin_list_memories":
133
- result = await client.list(projectId, payload);
181
+ result = await client.list(projectId, enrichedPayload);
134
182
  break;
135
183
  case "munin_recent_memories":
136
- result = await client.recent(projectId, payload);
184
+ result = await client.recent(projectId, enrichedPayload);
137
185
  break;
186
+ case "munin_share_memory":
187
+ result = await client.invoke(projectId, "share", enrichedPayload);
188
+ break;
189
+ case "munin_get_project_info": {
190
+ const caps = await client.capabilities();
191
+ result = {
192
+ ok: true,
193
+ encryptionKeyConfigured: !!encryptionKey,
194
+ ...caps,
195
+ };
196
+ break;
197
+ }
138
198
  default:
139
199
  throw new Error(`Unknown tool: ${request.params.name}`);
140
200
  }
@@ -161,6 +221,13 @@ export async function startMcpServer() {
161
221
  }
162
222
  });
163
223
 
224
+ return server;
225
+ }
226
+
227
+ export async function startMcpServer() {
228
+ const env = loadCliEnv();
229
+ const server = createMcpServerInstance(env);
230
+
164
231
  const transport = new StdioServerTransport();
165
232
  await server.connect(transport);
166
233
  console.error("Munin MCP Server running on stdio");