diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d0a14a..7c4e208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Read MCP saved-history files through verified descriptors, filtering candidates before reading and stopping once the newest requested results fill the response. - Restrict the generated MCP configuration to owner read/write permissions on POSIX systems before persisting authentication credentials. - Include source-backed JSON fact documents in `!storage` profile file counts. - Refuse to save pending Discord messages through symbolic-link destinations. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f4244cd..2a80ccc 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -14,6 +14,7 @@ import { MCP_FETCH_MESSAGES_MAX_LINKS, MCP_READ_MESSAGES_MAX_CHARS, MCP_HISTORY_MAX_CHARS, + MESSAGES_DIR, PENDING_DIR, } from "../config.js"; import { client } from "../discord/client.js"; @@ -28,6 +29,7 @@ import { isCalendarDate, } from "./historyFiles.js"; import { parseChannelHistoryFileName } from "../storage/historyPaths.js"; +import { readVerifiedUtf8File } from "../storage/safeRead.js"; const ReactToMessageSchema = z.object({ server: z @@ -55,12 +57,12 @@ function isWithinDiscordMessageLimit(message: string): boolean { return true; } -function readPendingMetadata(filePath: string): { +function readPendingMetadata(text: string): { channelId: string | undefined; channelName: string | undefined; date: string | undefined; } { - const lines = fs.readFileSync(filePath, "utf-8").split("\n"); + const lines = text.split("\n"); const separatorIndex = lines.indexOf("---"); const headerLines = lines.slice(0, separatorIndex === -1 ? 4 : separatorIndex); const channelLine = headerLines.find((line) => line.startsWith("Channel: #")); @@ -86,6 +88,26 @@ function readPendingMetadata(filePath: string): { }; } +interface StoredHistoryFile { + displayName: string; + filePath: string; + directory: string; + channelId: string | undefined; + channelName: string | undefined; +} + +function readStoredHistoryFile( + filePath: string, + expectedDirectory: string, +): string | undefined { + const result = readVerifiedUtf8File( + filePath, + MESSAGES_DIR, + expectedDirectory, + ); + return result.state === "valid" ? result.text : undefined; +} + const HISTORY_RESPONSE_SEPARATOR = "\n\n===\n\n"; function takeUtf16Suffix(text: string, maxChars: number): string { @@ -532,112 +554,123 @@ export function createMcpServer(): Server { "_", ) : undefined; - let files = fs - .readdirSync(dir, { withFileTypes: true }) - .filter( - (entry) => - entry.isFile() && entry.name.endsWith(".txt"), - ) - .map((entry) => { - const filePath = path.join(dir, entry.name); - const pendingMetadata = - type === "pending" - ? readPendingMetadata(filePath) - : undefined; - return { - displayName: entry.name, - filePath, - channelId: pendingMetadata?.channelId, - channelName: - type === "history" - ? getLegacyHistoryChannel(entry.name) - : pendingMetadata?.channelName, - date: pendingMetadata?.date, - }; + let files: StoredHistoryFile[] = []; + for (const entry of fs.readdirSync(dir, { + withFileTypes: true, + })) { + if (!entry.isFile() || !entry.name.endsWith(".txt")) { + continue; + } + const filePath = path.join(dir, entry.name); + files.push({ + displayName: entry.name, + filePath, + directory: dir, + channelId: undefined, + channelName: + type === "history" + ? getLegacyHistoryChannel(entry.name) + : undefined, }); + } if (type === "history") { - const channelFiles = fs - .readdirSync(HISTORY_V2_DIR, { withFileTypes: true }) - .filter( - (entry) => - entry.isFile() && - entry.name.endsWith(".txt"), - ) - .map((entry) => { - const parsed = parseChannelHistoryFileName( - entry.name, - ); - return { - displayName: `v2/${entry.name}`, - filePath: path.join( - HISTORY_V2_DIR, - entry.name, - ), - channelId: parsed?.channelId, - channelName: parsed?.channelName, - date: undefined, - }; + for (const entry of fs.readdirSync(HISTORY_V2_DIR, { + withFileTypes: true, + })) { + if ( + !entry.isFile() + || !entry.name.endsWith(".txt") + ) continue; + const filePath = path.join( + HISTORY_V2_DIR, + entry.name, + ); + const parsed = parseChannelHistoryFileName( + entry.name, + ); + files.push({ + displayName: `v2/${entry.name}`, + filePath, + directory: HISTORY_V2_DIR, + channelId: parsed?.channelId, + channelName: parsed?.channelName, }); - files.push(...channelFiles); + } files.sort((left, right) => compareHistoryFilenames( left.displayName, right.displayName, ), ); + } else { + files.sort((left, right) => + left.displayName.localeCompare(right.displayName), + ); } - if (safeChannel) { + // History metadata is encoded in filenames. Do not read + // unrelated bodies merely to select a channel or date. + if (type === "history" && safeChannel) { files = files.filter((file) => - file.channelId !== undefined || - file.channelName !== undefined - ? file.channelId === safeChannel || - file.channelName === safeChannel - : type === "history" - ? file.channelName === safeChannel - : file.displayName.startsWith(`${safeChannel}_`), + file.channelId === safeChannel || + file.channelName === safeChannel, ); } - if (date) { + if (type === "history" && date) { files = files.filter((file) => - type === "pending" - ? file.date === date - : file.displayName.endsWith(`_${date}.txt`), + file.displayName.endsWith(`_${date}.txt`), ); } const searchNormalized = search?.normalize("NFC").toLowerCase(); - const candidateFiles = searchNormalized - ? files - : files.slice(-limit); - let matchingFiles = candidateFiles - .map((file) => { - let lines = fs - .readFileSync(file.filePath, "utf-8") - .split("\n") - .filter((line) => line.trim().length > 0); - - if (searchNormalized) { - lines = lines.filter((line) => - line - .normalize("NFC") - .toLowerCase() - .includes(searchNormalized), - ); + const messages: string[] = []; + let retainedChars = 0; + for (let index = files.length - 1; index >= 0; index--) { + const file = files[index]; + const text = readStoredHistoryFile(file.filePath, file.directory); + if (text === undefined) continue; + + // Pending metadata and returned content must come from + // the same verified snapshot, including legacy headers. + if (type === "pending") { + const metadata = readPendingMetadata(text); + if (safeChannel) { + const matches = metadata.channelId !== undefined || + metadata.channelName !== undefined + ? metadata.channelId === safeChannel || + metadata.channelName === safeChannel + : file.displayName.startsWith(`${safeChannel}_`); + if (!matches) continue; } + if (date && metadata.date !== date) continue; + } - return { file: file.displayName, lines }; - }) - .filter(({ lines }) => - !searchNormalized || lines.length > 0, - ); + let lines = text.split("\n") + .filter((line) => line.trim().length > 0); + if (searchNormalized) { + lines = lines.filter((line) => + line.normalize("NFC").toLowerCase().includes(searchNormalized), + ); + if (lines.length === 0) continue; + } - if (searchNormalized) { - matchingFiles = matchingFiles.slice(-limit); + const omitted = Math.max(0, lines.length - maxLines); + const selected = lines.slice(-maxLines); + const note = omitted > 0 + ? ` (${selected.length} of ${lines.length} matching lines; ${omitted} older omitted)` + : ` (${selected.length} matching lines)`; + const rendered = `=== ${file.displayName}${note} ===\n${selected.join("\n")}`; + // Preserve enough overflow for boundHistoryResponse to + // report truncation, without retaining entire archives. + const message = takeUtf16Suffix(rendered, MCP_HISTORY_MAX_CHARS + 2); + retainedChars += message.length + + (messages.length ? HISTORY_RESPONSE_SEPARATOR.length : 0); + messages.push(message); + if (messages.length >= limit || retainedChars > MCP_HISTORY_MAX_CHARS) break; } - if (matchingFiles.length === 0) + if (messages.length === 0) return { content: [ { @@ -647,21 +680,11 @@ export function createMcpServer(): Server { ], }; - const messages = matchingFiles.map(({ file, lines }) => { - const omitted = Math.max(0, lines.length - maxLines); - const selected = lines.slice(-maxLines); - const note = omitted > 0 - ? ` (${selected.length} of ${lines.length} matching lines; ${omitted} older omitted)` - : ` (${selected.length} matching lines)`; - - return `=== ${file}${note} ===\n${selected.join("\n")}`; - }); - return { content: [ { type: "text", - text: boundHistoryResponse(messages), + text: boundHistoryResponse(messages.reverse()), }, ], }; diff --git a/tests/mcpHistory.test.mjs b/tests/mcpHistory.test.mjs index b103812..5f20abc 100644 --- a/tests/mcpHistory.test.mjs +++ b/tests/mcpHistory.test.mjs @@ -61,6 +61,11 @@ test("MCP history filters matching regular files and preserves pending indentati path.join(historyDir, "linked_2026-08-01.txt"), ); fs.mkdirSync(path.join(historyDir, "directory_2026-08-01.txt")); + const swappedHistoryFile = path.join( + historyDir, + "swapped_2026-08-01.txt", + ); + fs.writeFileSync(swappedHistoryFile, "ordinary history entry\n", "utf8"); fs.symlinkSync( outsideFile, path.join( @@ -124,6 +129,31 @@ test("MCP history filters matching regular files and preserves pending indentati assert.doesNotMatch(text, /directory_2026-08-01\.txt/); assert.doesNotMatch(text, /symlinked secret/); + const originalReaddirSync = fs.readdirSync; + fs.readdirSync = function patchedReaddirSync(directory, options) { + const entries = originalReaddirSync.call(fs, directory, options); + if (path.resolve(directory.toString()) === path.resolve(historyDir)) { + fs.rmSync(swappedHistoryFile, { force: true }); + fs.symlinkSync(outsideFile, swappedHistoryFile); + } + return entries; + }; + let swappedResult; + try { + swappedResult = await client.callTool({ + name: "read-message-history", + arguments: { date: "2026-08-01" }, + }); + } finally { + fs.readdirSync = originalReaddirSync; + } + const swappedText = swappedResult.content.find( + (item) => item.type === "text", + )?.text; + assert.equal(typeof swappedText, "string"); + assert.doesNotMatch(swappedText, /swapped_2026-08-01\.txt/); + assert.doesNotMatch(swappedText, /symlinked secret/); + const pendingResult = await client.callTool({ name: "read-message-history", arguments: { type: "pending" }, diff --git a/tests/mcpHistorySelection.test.mjs b/tests/mcpHistorySelection.test.mjs new file mode 100644 index 0000000..b4ef585 --- /dev/null +++ b/tests/mcpHistorySelection.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test, { after } from "node:test"; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), "claudify-history-selection-")); +process.env.MESSAGES_DIR = root; +const [{ Client }, { InMemoryTransport }, { createMcpServer }] = await Promise.all([ + import("@modelcontextprotocol/sdk/client/index.js"), + import("@modelcontextprotocol/sdk/inMemory.js"), + import("../build/mcp/server.js"), +]); +after(() => fs.rmSync(root, { recursive: true, force: true })); + +function writeFile(relativePath, text) { + const filePath = path.join(root, relativePath); + fs.writeFileSync(filePath, text, "utf8"); + return filePath; +} + +async function callHistory(t, args, afterEnumeration) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + const client = new Client({ name: "history-selection-test", version: "1.0.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + t.after(async () => { + await client.close(); + await server.close(); + }); + + const original = { + openSync: fs.openSync, + readFileSync: fs.readFileSync, + readdirSync: fs.readdirSync, + }; + const descriptors = new Map(); + const reads = []; + fs.openSync = function (...parameters) { + const descriptor = original.openSync.apply(this, parameters); + descriptors.set(descriptor, path.resolve(parameters[0].toString())); + return descriptor; + }; + fs.readFileSync = function (target, ...parameters) { + const file = typeof target === "number" + ? descriptors.get(target) + : path.resolve(target.toString()); + if (file?.endsWith(".txt")) reads.push(file); + return original.readFileSync.call(this, target, ...parameters); + }; + fs.readdirSync = function (...parameters) { + const entries = original.readdirSync.apply(this, parameters); + afterEnumeration?.(parameters[0]); + return entries; + }; + try { + const result = await client.callTool({ name: "read-message-history", arguments: args }); + assert.notEqual(result.isError, true); + return { text: result.content[0].text, reads }; + } finally { + Object.assign(fs, original); + } +} + +test("history filters channel and date before reading only the newest requested body", async (t) => { + writeFile("history/general_2026-09-05.txt", "wrong date"); + writeFile("history/unrelated_2026-09-06.txt", "wrong channel"); + writeFile("history/general_2026-09-06.txt", "older legacy entry"); + const newest = writeFile( + "history/v2/v2_111111111111111111__general_2026-09-06.txt", + "newest selected entry", + ); + const { text, reads } = await callHistory(t, { + channel: "general", date: "2026-09-06", limit: 1, + }); + assert.deepEqual(reads, [newest]); + assert.match(text, /newest selected entry/); + assert.doesNotMatch(text, /wrong|older legacy/); +}); + +test("history searches newest first and stops after enough matching files", async (t) => { + writeFile("history/search_2026-09-04.txt", "needle in older match"); + const match = writeFile("history/search_2026-09-05.txt", "needle in newest match"); + const nonmatch = writeFile("history/search_2026-09-06.txt", "no relevant text"); + const { text, reads } = await callHistory(t, { channel: "search", search: "needle", limit: 1 }); + assert.deepEqual(reads, [nonmatch, match]); + assert.match(text, /needle in newest match/); + assert.doesNotMatch(text, /older match/); +}); + +test("pending metadata and output share one verified body read", async (t) => { + const pending = writeFile("pending/222222222222222222.txt", [ + "Author: user#0001", "Channel: #snapshot", "Channel ID: 111111111111111111", + "Timestamp: 2026-09-06T12:00:00.000Z", "---", " indented body", + ].join("\n")); + const { text, reads } = await callHistory(t, { + type: "pending", channel: "111111111111111111", date: "2026-09-06", limit: 1, + }); + assert.deepEqual(reads, [pending]); + assert.match(text, /\n indented body/); +}); + +test("an unsafe newest candidate does not consume the requested file limit", async (t) => { + const older = writeFile("history/fallback_2026-09-05.txt", "safe older entry"); + const newest = writeFile("history/fallback_2026-09-06.txt", "replaced entry"); + let swapped = false; + const { text, reads } = await callHistory(t, { channel: "fallback", limit: 1 }, (directory) => { + if (!swapped && path.resolve(directory) === path.join(root, "history")) { + swapped = true; + fs.renameSync(newest, `${newest}.original`); + fs.mkdirSync(newest); + } + }); + assert.equal(swapped, true); + assert.deepEqual(reads, [older]); + assert.match(text, /safe older entry/); + assert.doesNotMatch(text, /replaced entry/); +}); + +test("a full response budget prevents reading older file bodies", async (t) => { + writeFile("history/budget_2026-09-05.txt", "older excluded entry"); + const newest = writeFile("history/budget_2026-09-06.txt", "😀".repeat(70000) + " newest-marker"); + const { text, reads } = await callHistory(t, { channel: "budget", limit: 100 }); + assert.deepEqual(reads, [newest]); + assert.ok(text.length <= 120000); + assert.ok(text.isWellFormed()); + assert.match(text, /newest-marker/); + assert.match(text, /truncated/); + assert.doesNotMatch(text, /older excluded/); +});