fix(memory): expand leading ~ in MEMORY_FILE_PATH to the home directory (#4447)

* fix(memory): expand leading ~ in MEMORY_FILE_PATH to the home directory

MCP clients pass MEMORY_FILE_PATH from JSON config, where no shell
expands a leading "~". Because path.isAbsolute("~/memory.jsonl") is
false, the value was joined onto the package directory, so the server
persisted to a literal "~" folder inside the install instead of the
user's intended location.

Add an expandHome() helper that mirrors the one already used by the
filesystem server (src/filesystem/path-utils.ts) and apply it in
ensureMemoryFilePath() before the existing absolute/relative resolution.
Absolute paths, relative paths, and a "~" not at the start are
unaffected. Adds unit tests for expandHome plus an end-to-end test
through ensureMemoryFilePath.

Addresses #1600

* test(memory): use a literal ~/ path in the tilde expansion test

path.join('~', ...) produces a backslash on Windows, which expandHome does
not match, so the test failed there.

---------

Co-authored-by: olaservo <olahungerford@gmail.com>
This commit is contained in:
Amey Pawar 2026-09-03 06:45:44 +05:30 • committed by GitHub
commit 649af5856e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 56 additions and 5 deletions

View file

@ -1,8 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { ensureMemoryFilePath, defaultMemoryPath } from '../index.js';
import { ensureMemoryFilePath, defaultMemoryPath, expandHome } from '../index.js';
describe('ensureMemoryFilePath', () => {
const testDir = path.dirname(fileURLToPath(import.meta.url));
@ -72,6 +73,15 @@ describe('ensureMemoryFilePath', () => {
expect(path.isAbsolute(result)).toBe(true);
}
});
it('should expand a leading "~/" to the home directory', async () => {
process.env.MEMORY_FILE_PATH = '~/custom-memory.jsonl';
const result = await ensureMemoryFilePath();
expect(result).toBe(path.join(os.homedir(), 'custom-memory.jsonl'));
expect(path.isAbsolute(result)).toBe(true);
});
});
describe('without MEMORY_FILE_PATH environment variable', () => {
@ -154,3 +164,29 @@ describe('ensureMemoryFilePath', () => {
});
});
});
describe('expandHome', () => {
it('expands a bare "~" to the home directory', () => {
expect(expandHome('~')).toBe(os.homedir());
});
it('expands a leading "~/" to the home directory', () => {
expect(expandHome('~/notes/memory.jsonl')).toBe(
path.join(os.homedir(), 'notes/memory.jsonl')
);
});
it('leaves a "~" not followed by a separator unchanged', () => {
expect(expandHome('~backup.jsonl')).toBe('~backup.jsonl');
});
it('leaves absolute paths unchanged', () => {
expect(expandHome('/var/data/memory.jsonl')).toBe('/var/data/memory.jsonl');
});
it('leaves relative paths unchanged', () => {
expect(expandHome(path.join('data', 'memory.jsonl'))).toBe(
path.join('data', 'memory.jsonl')
);
});
});

View file

@ -6,6 +6,7 @@ import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextp
import { z } from "zod";
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { randomBytes } from 'crypto';
import { fileURLToPath } from 'url';
import { SERVER_VERSION } from './version.js';
@ -13,13 +14,27 @@ import { SERVER_VERSION } from './version.js';
// Define memory file path using environment variable with fallback
export const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.jsonl');
// Expand a leading "~" to the user's home directory. MCP clients pass
// MEMORY_FILE_PATH from JSON config, where no shell performs tilde expansion,
// so an unexpanded "~" would otherwise be treated as a relative path and
// joined onto the package directory. Mirrors the helper of the same name in
// the filesystem server (src/filesystem/path-utils.ts).
export function expandHome(filepath: string): string {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1));
}
return filepath;
}
// Handle backward compatibility: migrate memory.json to memory.jsonl if needed
export async function ensureMemoryFilePath(): Promise<string> {
if (process.env.MEMORY_FILE_PATH) {
// Custom path provided, use it as-is (with absolute path resolution)
return path.isAbsolute(process.env.MEMORY_FILE_PATH)
? process.env.MEMORY_FILE_PATH
: path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH);
// Custom path provided. Expand a leading "~" first, then resolve relative
// paths against the package directory (absolute paths are used as-is).
const customPath = expandHome(process.env.MEMORY_FILE_PATH);
return path.isAbsolute(customPath)
? customPath
: path.join(path.dirname(fileURLToPath(import.meta.url)), customPath);
}
// No custom path set, check for backward compatibility migration