@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/env.d.ts +37 -0
- package/dist/env.js +201 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +71 -8
- package/dist/mcp-server.d.ts +38 -0
- package/dist/mcp-server.js +72 -15
- package/package.json +2 -2
- package/src/env.ts +236 -0
- package/src/index.ts +76 -9
- package/src/mcp-server.ts +83 -16
package/.turbo/turbo-build.log
CHANGED
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:
|
|
25
|
-
retries:
|
|
26
|
-
backoffMs:
|
|
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
|
|
95
|
+
while (attempt < retries) {
|
|
33
96
|
try {
|
|
34
97
|
return await task();
|
|
35
98
|
}
|
|
36
99
|
catch (error) {
|
|
37
100
|
lastError = error;
|
|
38
|
-
|
|
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";
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -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>;
|
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
149
|
+
result = await client.store(projectId, enrichedPayload);
|
|
110
150
|
break;
|
|
111
151
|
case "munin_retrieve_memory":
|
|
112
|
-
result = await client.retrieve(projectId,
|
|
152
|
+
result = await client.retrieve(projectId, enrichedPayload);
|
|
113
153
|
break;
|
|
114
154
|
case "munin_search_memories":
|
|
115
|
-
result = await client.search(projectId,
|
|
155
|
+
result = await client.search(projectId, enrichedPayload);
|
|
116
156
|
break;
|
|
117
157
|
case "munin_list_memories":
|
|
118
|
-
result = await client.list(projectId,
|
|
158
|
+
result = await client.list(projectId, enrichedPayload);
|
|
119
159
|
break;
|
|
120
160
|
case "munin_recent_memories":
|
|
121
|
-
result = await client.recent(projectId,
|
|
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.
|
|
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.
|
|
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:
|
|
41
|
-
retries:
|
|
42
|
-
backoffMs:
|
|
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
|
|
122
|
+
while (attempt < retries) {
|
|
55
123
|
try {
|
|
56
124
|
return await task();
|
|
57
125
|
} catch (error) {
|
|
58
126
|
lastError = error;
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
172
|
+
result = await client.store(projectId, enrichedPayload);
|
|
125
173
|
break;
|
|
126
174
|
case "munin_retrieve_memory":
|
|
127
|
-
result = await client.retrieve(projectId,
|
|
175
|
+
result = await client.retrieve(projectId, enrichedPayload);
|
|
128
176
|
break;
|
|
129
177
|
case "munin_search_memories":
|
|
130
|
-
result = await client.search(projectId,
|
|
178
|
+
result = await client.search(projectId, enrichedPayload);
|
|
131
179
|
break;
|
|
132
180
|
case "munin_list_memories":
|
|
133
|
-
result = await client.list(projectId,
|
|
181
|
+
result = await client.list(projectId, enrichedPayload);
|
|
134
182
|
break;
|
|
135
183
|
case "munin_recent_memories":
|
|
136
|
-
result = await client.recent(projectId,
|
|
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");
|