From 671a9e0151b3bfad9e4c7d25a2fbd0f1e8d68850 Mon Sep 17 00:00:00 2001 From: Behzat Can Acele <61169260+bezata@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:25:58 +0300 Subject: [PATCH] fix stats tool validation failures --- src/domain/statistics.ts | 16 +++++-- src/server/tools/notes.ts | 15 ++++++- tests/server-integration.test.ts | 74 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/domain/statistics.ts b/src/domain/statistics.ts index fe4ebf7..3b23b4a 100644 --- a/src/domain/statistics.ts +++ b/src/domain/statistics.ts @@ -83,13 +83,20 @@ export async function getVaultStatistics(context: DomainContext, args: { vaultPa let totalWords = 0; let totalLinks = 0; const tags = new Set(); + const skippedNotes: string[] = []; for (const absolutePath of await walkMarkdownFiles(vaultRoot)) { const relativePath = absolutePath.slice(vaultRoot.length + 1).replaceAll("\\", "/"); - const noteStats = await getNoteStatistics(context, { - filePath: relativePath, - vaultPath: vaultRoot, - }); + let noteStats: Awaited>; + try { + noteStats = await getNoteStatistics(context, { + filePath: relativePath, + vaultPath: vaultRoot, + }); + } catch { + skippedNotes.push(relativePath); + continue; + } totalNotes += 1; totalWords += noteStats.wordCount; totalLinks += noteStats.links.totalLinks; @@ -105,5 +112,6 @@ export async function getVaultStatistics(context: DomainContext, args: { vaultPa uniqueTags: tags.size, allTags: [...tags].sort((left, right) => left.localeCompare(right)), avgWordsPerNote: Number((totalNotes > 0 ? totalWords / totalNotes : 0).toFixed(2)), + skippedNotes, }; } diff --git a/src/server/tools/notes.ts b/src/server/tools/notes.ts index c50ce96..a060afe 100644 --- a/src/server/tools/notes.ts +++ b/src/server/tools/notes.ts @@ -51,6 +51,8 @@ import { mutationResultSchema, } from "../tool-schemas.js"; +type NoteStatistics = Awaited>; + async function handleEdit(context: DomainContext, args: NotesEditArgs) { if (args.mode === "replace") { return updateNote(context, { @@ -93,6 +95,16 @@ async function handleEdit(context: DomainContext, args: NotesEditArgs) { }); } +function toNotesReadStats(stats: NoteStatistics) { + return { + ...stats, + words: stats.wordCount, + characters: stats.characterCount, + headings: stats.headings.count, + links: stats.links.totalLinks, + }; +} + export const noteTools: ToolDefinition[] = [ { name: "notes.read", @@ -127,10 +139,11 @@ export const noteTools: ToolDefinition[] = [ } if (wantStats) { - result.stats = await getNoteStatistics(context, { + const stats = await getNoteStatistics(context, { filePath: args.path, vaultPath: args.vaultPath, }); + result.stats = toNotesReadStats(stats); } return result; diff --git a/tests/server-integration.test.ts b/tests/server-integration.test.ts index 8b0d142..e553076 100644 --- a/tests/server-integration.test.ts +++ b/tests/server-integration.test.ts @@ -1,3 +1,5 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; @@ -46,6 +48,78 @@ describe("server integration", () => { await server.close(); }); + it("returns schema-compatible numeric note stats over MCP", async () => { + const vault = await makeTempVault(); + const context = createDomainContext( + getEnv({ + ...process.env, + OBSIDIAN_VAULT_PATH: vault, + KOBSIDIAN_ALLOWED_ORIGINS: "http://localhost", + }), + ); + const server = createServer(context); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "stats-client", version: "1.0.0" }, { capabilities: {} }); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: "notes.read", + arguments: { path: "note1.md", include: ["stats"] }, + }); + + expect(result.structuredContent).toMatchObject({ + path: "note1.md", + stats: { + headings: expect.any(Number), + links: expect.any(Number), + }, + }); + + await client.close(); + await server.close(); + }); + + it("keeps vault stats working when one note has unparseable frontmatter-like content", async () => { + const vault = await makeTempVault(); + const windowsPath = String.raw`C:\Users\username\Documents\file.txt`; + await fs.writeFile( + path.join(vault, "confluence-export.md"), + [ + "---json", + `{"source":"confluence","body":"\`\`\`powershell\\naws s3 cp \`'${windowsPath}'\` s3://bucket/\\n\`\`\`"}`, + "---", + "# Exported note", + ].join("\n"), + "utf8", + ); + const context = createDomainContext( + getEnv({ + ...process.env, + OBSIDIAN_VAULT_PATH: vault, + KOBSIDIAN_ALLOWED_ORIGINS: "http://localhost", + }), + ); + const server = createServer(context); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client( + { name: "vault-stats-client", version: "1.0.0" }, + { capabilities: {} }, + ); + await client.connect(clientTransport); + + const result = await client.callTool({ name: "stats.vault", arguments: {} }); + + expect(result.structuredContent).toMatchObject({ + totalNotes: expect.any(Number), + skippedNotes: expect.arrayContaining(["confluence-export.md"]), + }); + + await client.close(); + await server.close(); + }); + it("serves the MCP endpoint over Streamable HTTP with Hono", async () => { const vault = await makeTempVault(); const env = getEnv({