Skip to content

Commit e459233

Browse files
committed
fix(memory): read raw mcporter config to preserve lifecycle/logging for configured stdio
1 parent b900bb7 commit e459233

2 files changed

Lines changed: 338 additions & 3 deletions

File tree

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4037,6 +4037,241 @@ describe("QmdMemoryManager", () => {
40374037
await manager.close();
40384038
});
40394039

4040+
it("keeps configured stdio qmd with keep-alive lifecycle external", async () => {
4041+
const userMcporterConfig = path.join(tmpRoot, "stdio-lifecycle-user-mcporter.json");
4042+
process.env.MCPORTER_CONFIG = userMcporterConfig;
4043+
await fs.writeFile(
4044+
userMcporterConfig,
4045+
JSON.stringify({
4046+
mcpServers: {
4047+
"custom-qmd": {
4048+
command: "qmd",
4049+
args: ["mcp"],
4050+
lifecycle: { mode: "keep-alive", idleTimeoutMs: 60_000 },
4051+
},
4052+
},
4053+
}),
4054+
"utf8",
4055+
);
4056+
4057+
cfg = {
4058+
...cfg,
4059+
memory: {
4060+
backend: "qmd",
4061+
qmd: {
4062+
includeDefaultMemory: false,
4063+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
4064+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
4065+
mcporter: { enabled: true, serverName: "custom-qmd", startDaemon: false },
4066+
},
4067+
},
4068+
} as OpenClawConfig;
4069+
4070+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
4071+
const child = createMockChild({ autoClose: false });
4072+
if (isMcporterCommand(cmd) && args[0] === "config") {
4073+
emitAndClose(
4074+
child,
4075+
"stdout",
4076+
JSON.stringify({
4077+
name: "custom-qmd",
4078+
source: "user",
4079+
command: "qmd",
4080+
args: ["mcp"],
4081+
}),
4082+
);
4083+
return child;
4084+
}
4085+
if (isMcporterCommand(cmd) && args[0] === "call") {
4086+
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
4087+
return child;
4088+
}
4089+
emitAndClose(child, "stdout", "[]");
4090+
return child;
4091+
});
4092+
4093+
const { manager } = await createManager();
4094+
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
4095+
4096+
const mcporterCall = spawnMock.mock.calls.find(
4097+
(call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])[0] === "call",
4098+
);
4099+
const args = (requireValue(mcporterCall, "mcporter search call missing")[1] ?? []) as string[];
4100+
expect(args).not.toContain("--config");
4101+
const callOpts = mcporterCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined;
4102+
expect(callOpts?.env?.MCPORTER_CONFIG).toBe(userMcporterConfig);
4103+
await expect(
4104+
fs.stat(path.join(stateDir, "agents", "main", "qmd", "mcporter", "mcporter.json")),
4105+
).rejects.toMatchObject({ code: "ENOENT" });
4106+
4107+
await manager.close();
4108+
});
4109+
4110+
it("keeps configured stdio qmd with daemon logging external", async () => {
4111+
const userMcporterConfig = path.join(tmpRoot, "stdio-logging-user-mcporter.json");
4112+
process.env.MCPORTER_CONFIG = userMcporterConfig;
4113+
await fs.writeFile(
4114+
userMcporterConfig,
4115+
JSON.stringify({
4116+
mcpServers: {
4117+
"custom-qmd": {
4118+
command: "qmd",
4119+
args: ["mcp"],
4120+
logging: { enabled: true, level: "info" },
4121+
},
4122+
},
4123+
}),
4124+
"utf8",
4125+
);
4126+
4127+
cfg = {
4128+
...cfg,
4129+
memory: {
4130+
backend: "qmd",
4131+
qmd: {
4132+
includeDefaultMemory: false,
4133+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
4134+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
4135+
mcporter: { enabled: true, serverName: "custom-qmd", startDaemon: false },
4136+
},
4137+
},
4138+
} as OpenClawConfig;
4139+
4140+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
4141+
const child = createMockChild({ autoClose: false });
4142+
if (isMcporterCommand(cmd) && args[0] === "config") {
4143+
emitAndClose(
4144+
child,
4145+
"stdout",
4146+
JSON.stringify({
4147+
name: "custom-qmd",
4148+
source: "user",
4149+
command: "qmd",
4150+
args: ["mcp"],
4151+
}),
4152+
);
4153+
return child;
4154+
}
4155+
if (isMcporterCommand(cmd) && args[0] === "call") {
4156+
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
4157+
return child;
4158+
}
4159+
emitAndClose(child, "stdout", "[]");
4160+
return child;
4161+
});
4162+
4163+
const { manager } = await createManager();
4164+
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
4165+
4166+
const mcporterCall = spawnMock.mock.calls.find(
4167+
(call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])[0] === "call",
4168+
);
4169+
const args = (requireValue(mcporterCall, "mcporter search call missing")[1] ?? []) as string[];
4170+
expect(args).not.toContain("--config");
4171+
const callOpts = mcporterCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined;
4172+
expect(callOpts?.env?.MCPORTER_CONFIG).toBe(userMcporterConfig);
4173+
await expect(
4174+
fs.stat(path.join(stateDir, "agents", "main", "qmd", "mcporter", "mcporter.json")),
4175+
).rejects.toMatchObject({ code: "ENOENT" });
4176+
4177+
await manager.close();
4178+
});
4179+
4180+
it("tolerates warm-daemon log lines before JSON", async () => {
4181+
cfg = {
4182+
...cfg,
4183+
memory: {
4184+
backend: "qmd",
4185+
qmd: {
4186+
includeDefaultMemory: false,
4187+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
4188+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
4189+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
4190+
},
4191+
},
4192+
} as OpenClawConfig;
4193+
4194+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
4195+
const child = createMockChild({ autoClose: false });
4196+
if (isMcporterCommand(cmd) && args[0] === "call") {
4197+
emitAndClose(child, "stdout", '[info] daemon warm\n{"results": []}');
4198+
return child;
4199+
}
4200+
emitAndClose(child, "stdout", "[]");
4201+
return child;
4202+
});
4203+
4204+
const { manager } = await createManager();
4205+
const results = await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
4206+
expect(results).toEqual([]);
4207+
4208+
await manager.close();
4209+
});
4210+
4211+
it("does not misparse log lines containing { and [", async () => {
4212+
cfg = {
4213+
...cfg,
4214+
memory: {
4215+
backend: "qmd",
4216+
qmd: {
4217+
includeDefaultMemory: false,
4218+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
4219+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
4220+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
4221+
},
4222+
},
4223+
} as OpenClawConfig;
4224+
4225+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
4226+
const child = createMockChild({ autoClose: false });
4227+
if (isMcporterCommand(cmd) && args[0] === "call") {
4228+
emitAndClose(child, "stdout", 'log: {foo: [bar]}\n{"results": []}');
4229+
return child;
4230+
}
4231+
emitAndClose(child, "stdout", "[]");
4232+
return child;
4233+
});
4234+
4235+
const { manager } = await createManager();
4236+
const results = await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
4237+
expect(results).toEqual([]);
4238+
4239+
await manager.close();
4240+
});
4241+
4242+
it("parses JSON at end of multi-line prefix", async () => {
4243+
cfg = {
4244+
...cfg,
4245+
memory: {
4246+
backend: "qmd",
4247+
qmd: {
4248+
includeDefaultMemory: false,
4249+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
4250+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
4251+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
4252+
},
4253+
},
4254+
} as OpenClawConfig;
4255+
4256+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
4257+
const child = createMockChild({ autoClose: false });
4258+
if (isMcporterCommand(cmd) && args[0] === "call") {
4259+
emitAndClose(child, "stdout", 'line 1\nline 2\n{"results": []}');
4260+
return child;
4261+
}
4262+
emitAndClose(child, "stdout", "[]");
4263+
return child;
4264+
});
4265+
4266+
const { manager } = await createManager();
4267+
// Should not throw despite the log prefix before the JSON body.
4268+
await expect(
4269+
manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }),
4270+
).resolves.toEqual([]);
4271+
4272+
await manager.close();
4273+
});
4274+
40404275
it("generates agent-scoped config for absolute qmd stdio servers", async () => {
40414276
const absoluteQmdCommand = path.join(tmpRoot, "node", "v24.15.0", "bin", "qmd");
40424277

extensions/memory-core/src/memory/qmd-manager.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ type ConfiguredMcporterServer =
145145
| { mode: "generated"; server: Record<string, unknown> }
146146
| { mode: "external" };
147147

148+
type RawMcporterEntry = {
149+
lifecycle?: unknown;
150+
logging?: unknown;
151+
};
152+
148153
const MCPORTER_REMOTE_AUTH_KEYS = new Set(
149154
[
150155
"auth",
@@ -374,6 +379,92 @@ function extractFirstJsonValue(raw: string): JsonExtractionResult {
374379
return { found: false };
375380
}
376381

382+
function expandMcporterHome(input: string): string {
383+
if (!input.startsWith("~")) {
384+
return input;
385+
}
386+
const home = os.homedir();
387+
if (input === "~") {
388+
return home;
389+
}
390+
if (input.startsWith("~/") || input.startsWith("~\\")) {
391+
return path.join(home, input.slice(2));
392+
}
393+
return input;
394+
}
395+
396+
function resolveMcporterConfigCandidates(env: NodeJS.ProcessEnv): string[] {
397+
const candidates: string[] = [];
398+
const explicitConfig = env.MCPORTER_CONFIG;
399+
if (explicitConfig && explicitConfig.trim().length > 0) {
400+
candidates.push(path.resolve(expandMcporterHome(explicitConfig.trim())));
401+
}
402+
403+
const xdgConfigHome = env.XDG_CONFIG_HOME;
404+
let baseDir: string;
405+
if (xdgConfigHome && xdgConfigHome.trim().length > 0) {
406+
const resolved = expandMcporterHome(xdgConfigHome.trim());
407+
if (path.isAbsolute(resolved)) {
408+
baseDir = path.join(resolved, "mcporter");
409+
} else {
410+
baseDir = path.join(os.homedir(), ".mcporter");
411+
}
412+
} else {
413+
baseDir = path.join(os.homedir(), ".mcporter");
414+
}
415+
candidates.push(path.join(baseDir, "mcporter.json"));
416+
candidates.push(path.join(baseDir, "mcporter.jsonc"));
417+
return candidates;
418+
}
419+
420+
async function readRawMcporterEntry(
421+
serverName: string,
422+
env: NodeJS.ProcessEnv,
423+
): Promise<RawMcporterEntry | null> {
424+
for (const candidate of resolveMcporterConfigCandidates(env)) {
425+
let text: string;
426+
try {
427+
text = await fs.readFile(candidate, "utf8");
428+
} catch (err) {
429+
if (isFileMissingError(err)) {
430+
continue;
431+
}
432+
throw err;
433+
}
434+
// Strip trailing commas and comments for JSONC files; mcporter accepts
435+
// both JSON and JSONC. A minimal cleanup is enough for our read-only
436+
// lifecycle/logging probe.
437+
const cleaned = candidate.endsWith(".jsonc")
438+
? text
439+
.replace(/\/\*[\s\S]*?\*\//g, "")
440+
.replace(/\/\/.*$/gm, "")
441+
.replace(/,(\s*[}\]])/g, "$1")
442+
: text;
443+
let parsed: unknown;
444+
try {
445+
parsed = JSON.parse(cleaned) as unknown;
446+
} catch {
447+
continue;
448+
}
449+
const record = asRecord(parsed);
450+
const servers = record ? asRecord(record.mcpServers) : null;
451+
const entry = servers ? asRecord(servers[serverName]) : null;
452+
if (entry) {
453+
return entry as RawMcporterEntry;
454+
}
455+
}
456+
return null;
457+
}
458+
459+
function hasMcporterStdioLifecycleOrLogging(server: RawMcporterEntry): boolean {
460+
return (
461+
server.lifecycle !== undefined ||
462+
(typeof server.logging === "object" &&
463+
server.logging !== null &&
464+
(server.logging as Record<string, unknown>).enabled === true)
465+
);
466+
}
467+
377468
function getQmdEmbedQueueState(): QmdEmbedQueueState {
378469
return resolveGlobalSingleton<QmdEmbedQueueState>(QMD_EMBED_QUEUE_KEY, () => ({
379470
tail: Promise.resolve(),
@@ -2459,7 +2550,8 @@ export class QmdMemoryManager implements MemorySearchManager {
24592550
throw new Error(`mcporter server "${serverName}" returned an invalid JSON definition`);
24602551
}
24612552

2462-
const server = this.toMcporterRawServerEntry(serialized);
2553+
const rawEntry = await readRawMcporterEntry(serverName, this.mcporterEnv);
2554+
const server = this.toMcporterRawServerEntry(serialized, rawEntry);
24632555
if (!server) {
24642556
if (serverName === "qmd") {
24652557
return null;
@@ -2471,6 +2563,7 @@ export class QmdMemoryManager implements MemorySearchManager {
24712563

24722564
private toMcporterRawServerEntry(
24732565
serialized: Record<string, unknown>,
2566+
rawEntry: RawMcporterEntry | null,
24742567
): ConfiguredMcporterServer | null {
24752568
const server: Record<string, unknown> = {};
24762569
for (const [key, value] of Object.entries(serialized)) {
@@ -2481,7 +2574,11 @@ export class QmdMemoryManager implements MemorySearchManager {
24812574
}
24822575

24832576
if (typeof server.command === "string" && server.command.length > 0) {
2484-
if (!isGeneratedMcporterQmdStdioServer(server) || hasMcporterStdioUserOwnedMaterial(server)) {
2577+
if (
2578+
!isGeneratedMcporterQmdStdioServer(server) ||
2579+
hasMcporterStdioUserOwnedMaterial(server) ||
2580+
(rawEntry !== null && hasMcporterStdioLifecycleOrLogging(rawEntry))
2581+
) {
24852582
return { mode: "external" };
24862583
}
24872584
return { mode: "generated", server: this.toGeneratedMcporterStdioServer(server) };
@@ -2495,7 +2592,10 @@ export class QmdMemoryManager implements MemorySearchManager {
24952592
// The generated per-agent config is persisted under OpenClaw state. Do not
24962593
// copy remote auth material from a user's mcporter config into that file;
24972594
// keep using the original mcporter config for authenticated remotes.
2498-
if (hasMcporterRemoteAuthMaterial(server)) {
2595+
if (
2596+
hasMcporterRemoteAuthMaterial(server) ||
2597+
(rawEntry !== null && hasMcporterStdioLifecycleOrLogging(rawEntry))
2598+
) {
24992599
return { mode: "external" };
25002600
}
25012601
return { mode: "generated", server };

0 commit comments

Comments
 (0)