From f7d6601a25b2407e7cf7b79a556476f63ab4eff3 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 8 Jul 2026 02:30:39 +0800 Subject: [PATCH] fix(mcp): return tagged text results --- .changeset/soft-mcp-text-results.md | 5 + packages/mcp/README.md | 53 +- packages/mcp/src/__tests__/mcp.test.ts | 660 ++++++++++++++++--- packages/mcp/src/index.ts | 54 +- packages/mcp/src/tool-result-formatter.ts | 752 ++++++++++++++++++++++ 5 files changed, 1395 insertions(+), 129 deletions(-) create mode 100644 .changeset/soft-mcp-text-results.md create mode 100644 packages/mcp/src/tool-result-formatter.ts diff --git a/.changeset/soft-mcp-text-results.md b/.changeset/soft-mcp-text-results.md new file mode 100644 index 0000000..39a867a --- /dev/null +++ b/.changeset/soft-mcp-text-results.md @@ -0,0 +1,5 @@ +--- +'@ontos-ai/knowhere-mcp': patch +--- + +Return Knowhere MCP tool results as single tagged text payloads without structuredContent or output schemas. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index fb8c84b..b0a5b69 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -204,20 +204,53 @@ When logged in with Read only permission, the MCP server exposes only - `knowhere_read_chunks`: read exact chunks from one parsed document by passing `localDocumentId`, published `documentId`, or completed `jobId`. Use `page`/`pageSize` for display reads; asset URLs are returned when the source - or configured storage provides them. Page screenshots are returned under chunk - `metadata.pageAssets`, not as image chunks. + or configured storage provides them. Page screenshots are listed as primary + `` entries before chunk preview text. - `knowhere_grep_chunks`: run literal or regex grep over one parsed document by passing `localDocumentId`, published `documentId`, or completed `jobId`. Broad workspace search belongs to `knowhere_search`. - `knowhere_search`: search published documents through the Knowhere API - retrieval query. Use follow-up `knowhere_read_chunks` calls to inspect page - screenshots under chunk `metadata.pageAssets`. - -Local-cache tool responses include `document.resultDirectoryPath`; expanded -chunks are stored at `/chunks.json` for direct filesystem -reads when a local cache entry exists. Remote fallback responses use -`remote:` or `parsed-storage:` markers instead of -claiming a local expanded result directory. + retrieval query. Page results and references are marked + `hasPageAssets="true"` when a follow-up `knowhere_read_chunks` call should be + used to inspect readable page asset URLs and chunk storage locations. + +## Response Contract + +All tools return a single MCP text content item: + +```json +{ + "content": [{ "type": "text", "text": "..." }] +} +``` + +The MCP package does not expose `structuredContent` or tool `outputSchema` +fields. Each response is tagged text rooted at +``, using SDK-native camelCase field names such as +`documentId`, `jobId`, `localDocumentId`, `chunkId`, `assetUrl`, +`chunkPath`, `filePath`, and `storageRoot`. + +Document tags include `storageRoot` when the SDK response has +`document.resultDirectoryPath`. Local cache roots remain local directory paths. +Remote fallback roots use marker values such as `remote:` and +configured parsed-storage roots use `parsed-storage:`. + +Chunk tags include `chunkPath` from `sourceChunkPath` and a display-only +`storageLocation` when it can be derived. Media and table chunks prefer +`filePath` for that location and include `assetUrl` when available. Text and +page chunks use `chunkPath`, so marker roots render as logical paths such as +`parsed-storage:doc_x/chunks/page-1`. + +For page chunks, `` appears immediately before +``. Each `` carries `pageNum`, `artifactRef`, +`assetUrl`, `contentType`, `width`, and `height` when the SDK provides them. +The following `` tells callers to open or fetch the listed +`assetUrl` before relying on preview text. If a page asset exists without an +`assetUrl`, the text says that the asset is not directly readable. + +Blocking parse responses summarize document/job identifiers, chunk counts, and +asset URL mappings. They intentionally do not dump the full parse result, ZIP +bytes, or every chunk. ## Package Boundary diff --git a/packages/mcp/src/__tests__/mcp.test.ts b/packages/mcp/src/__tests__/mcp.test.ts index e12c450..fb323d4 100644 --- a/packages/mcp/src/__tests__/mcp.test.ts +++ b/packages/mcp/src/__tests__/mcp.test.ts @@ -3,10 +3,15 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createKnowhereMcpServer } from '../index.js'; -import type { Knowhere, Knowledge } from '@ontos-ai/knowhere-sdk'; +import type { + Knowhere, + Knowledge, + KnowledgeChunkType, + LocalKnowledgeDocument, +} from '@ontos-ai/knowhere-sdk'; describe('knowhere MCP wrapper', () => { - it('should register SDK-backed knowledge tools', async () => { + it('should register SDK-backed text-output knowledge tools', async () => { const { client, server } = await connectTestClient(createClient()); const tools = await client.listTools(); const toolNames = tools.tools.map((tool) => tool.name).sort(); @@ -32,6 +37,9 @@ describe('knowhere MCP wrapper', () => { 'knowhere_read_chunks', 'knowhere_search', ]); + for (const tool of tools.tools) { + expect(tool).not.toHaveProperty('outputSchema'); + } expect(statusTool?.description).toContain('5s, 10s, 20s, 40s, 80s'); expect(statusTool?.description).toContain('Large PDFs or OCR-heavy files can take 10+ minutes'); expect(listTool?.description).toContain('remote API'); @@ -43,8 +51,8 @@ describe('knowhere MCP wrapper', () => { ); expect(readTool?.description).toContain('configured parsed storage first'); expect(readTool?.description).toContain('returns asset URLs'); - expect(readTool?.description).toContain('metadata.pageAssets'); - expect(searchTool?.description).toContain('metadata.pageAssets'); + expect(readTool?.description).toContain(''); + expect(searchTool?.description).toContain('hasPageAssets="true"'); await client.close(); await server.close(); }); @@ -66,7 +74,7 @@ describe('knowhere MCP wrapper', () => { await server.close(); }); - it('should list published documents from the remote document API', async () => { + it('should list published documents from the remote document API as tagged text', async () => { const knowhereClient = createClient(); const { client, server } = await connectTestClient(knowhereClient); @@ -81,18 +89,17 @@ describe('knowhere MCP wrapper', () => { namespace: 'support-center', }); expect(knowhereClient.knowledge.listDocuments).not.toHaveBeenCalled(); - expect(response.structuredContent).toEqual({ - result: { - namespace: 'support-center', - documents: [ - { - documentId: 'doc-remote-1', - namespace: 'support-center', - status: 'active', - }, - ], - }, - }); + expectToolText( + response, + ` + + + + + + +`, + ); await client.close(); await server.close(); }); @@ -140,21 +147,30 @@ describe('knowhere MCP wrapper', () => { localDocumentId: 'local-report', pattern: 'revenue', }); - expect(parseResponse.structuredContent).toEqual({ - result: { - document: { - localDocumentId: 'local-report', - }, - result: { - jobId: 'job-1', - }, - }, - }); - expect(grepResponse.structuredContent).toEqual({ - result: { - matches: [], - }, - }); + expectToolText( + parseResponse, + ` + + + + + + + + + +`, + ); + expectToolText( + grepResponse, + ` + + + + + +`, + ); await client.close(); await server.close(); }); @@ -298,29 +314,23 @@ describe('knowhere MCP wrapper', () => { kbDir: undefined, }); expect(knowhereClient.knowledge.getJobStatus).toHaveBeenCalledWith('job-async'); - expect(parseResponse.structuredContent).toEqual({ - result: { - job: { - jobId: 'job-async', - }, - localDocumentId: 'local-report', - }, - }); - expect(statusResponse.structuredContent).toEqual({ - result: { - cache: { - document: { - localDocumentId: 'local-report', - }, - localDocumentId: 'local-report', - status: 'cached', - }, - job: { - jobId: 'job-async', - status: 'done', - }, - }, - }); + expectToolText( + parseResponse, + ` + +`, + ); + expectToolText( + statusResponse, + ` + + + + + + +`, + ); await client.close(); await server.close(); }); @@ -361,15 +371,16 @@ describe('knowhere MCP wrapper', () => { }); expect(knowhereClient.archiveDocument).toHaveBeenCalledWith('doc-1'); - expect(response.structuredContent).toEqual({ - result: { - document: { - documentId: 'doc-1', - status: 'archived', - }, - localDocumentId: 'local-report', - }, - }); + expectToolText( + response, + ` + + + + + +`, + ); await client.close(); await server.close(); }); @@ -387,15 +398,375 @@ describe('knowhere MCP wrapper', () => { expect(knowhereClient.knowledge.listDocuments).toHaveBeenCalledOnce(); expect(knowhereClient.archiveDocument).toHaveBeenCalledWith('doc-1'); - expect(response.structuredContent).toEqual({ - result: { - document: { - documentId: 'doc-1', - status: 'archived', + expectToolText( + response, + ` + + + + + +`, + ); + await client.close(); + await server.close(); + }); + + it('should format outlines as tagged text', async () => { + const knowhereClient = createClient(); + knowhereClient.knowledge.getDocumentOutline.mockResolvedValueOnce({ + document: createLocalDocument(), + totalChunks: 3, + typeCounts: createTypeCounts(), + sections: [], + sectionTree: [ + { + sectionPath: 'Overview', + sectionTitle: 'Overview', + sectionLevel: 1, + summary: 'Intro & context', + startChunk: 1, + endChunk: 2, + chunkCount: 2, + typeCounts: { + text: 1, + image: 0, + table: 0, + page: 1, + }, + children: [], }, + ], + }); + const { client, server } = await connectTestClient(knowhereClient); + + const response = await client.callTool({ + name: 'knowhere_get_document_outline', + arguments: { + localDocumentId: 'local-report', + }, + }); + + expectToolText( + response, + ` + + + + + +
+ Intro & context + +
+
+
`, + ); + await client.close(); + await server.close(); + }); + + it('should format local read chunks with chunk storage locations', async () => { + const knowhereClient = createClient(); + knowhereClient.knowledge.readChunks.mockResolvedValueOnce({ + document: createLocalDocument(), + chunks: [ + { + position: 1, + chunkId: 'chunk-text-1', + chunkType: 'text', + content: 'raw text', + readableContent: 'Hello & team', + sectionPath: 'Overview', + sourceChunkPath: 'chunks/text-1.md', + pageNumbers: [1], + metadata: {}, + }, + { + position: 2, + chunkId: 'chunk-table-1', + chunkType: 'table', + content: '
', + readableContent: 'Revenue table', + sectionPath: 'Tables', + sourceChunkPath: 'chunks/table-1.md', + filePath: 'tables/revenue.html', + assetUrl: 'https://assets.example/table.html', + metadata: {}, + }, + ], + nextChunk: 3, + }); + const { client, server } = await connectTestClient(knowhereClient); + + const response = await client.callTool({ + name: 'knowhere_read_chunks', + arguments: { localDocumentId: 'local-report', + limit: 2, + }, + }); + + expectToolText( + response, + ` + + + + + + + Hello <world> & team + + + Revenue table + + +`, + ); + await client.close(); + await server.close(); + }); + + it('should format page assets and marker storage roots in read chunks', async () => { + const knowhereClient = createClient(); + knowhereClient.knowledge.readChunks + .mockResolvedValueOnce({ + document: createMarkerDocument('parsed-storage:doc_remote'), + chunks: [ + { + position: 1, + chunkId: 'chunk-page-1', + chunkType: 'page', + content: '', + readableContent: 'Page 1 summary', + sectionPath: 'Page 1', + sourceChunkPath: 'chunks/page-1.md', + metadata: { + pageAssets: [ + { + pageNum: 1, + artifactRef: 'page_citation_assets/page-1.png', + assetUrl: 'https://blob.example/page-1.png', + contentType: 'image/png', + width: 1200, + height: 1600, + }, + ], + }, + }, + ], + page: 1, + pageSize: 1, + totalChunks: 2, + totalPages: 2, + }) + .mockResolvedValueOnce({ + document: createMarkerDocument('remote:doc_remote'), + chunks: [ + { + position: 2, + chunkId: 'chunk-page-2', + chunkType: 'page', + content: '', + readableContent: 'Page 2 summary', + sectionPath: 'Page 2', + sourceChunkPath: 'chunks/page-2.md', + metadata: { + page_assets: [ + { + page_num: 2, + artifact_ref: 'page_citation_assets/page-2.png', + content_type: 'image/png', + width: 1200, + height: 1600, + }, + ], + }, + }, + ], + }); + const { client, server } = await connectTestClient(knowhereClient); + + const readablePageResponse = await client.callTool({ + name: 'knowhere_read_chunks', + arguments: { + documentId: 'doc_remote', + page: 1, + pageSize: 1, + }, + }); + const unreadablePageResponse = await client.callTool({ + name: 'knowhere_read_chunks', + arguments: { + documentId: 'doc_remote', + chunkId: 'chunk-page-2', }, }); + + expectToolText( + readablePageResponse, + ` + + + + + + + + + + Open or fetch the listed assetUrl before relying on preview text. + Page 1 summary + + +`, + ); + expectToolText( + unreadablePageResponse, + ` + + + + + + + + + + A page asset exists, but it is not directly readable because no assetUrl was returned. + Page 2 summary + + +`, + ); + await client.close(); + await server.close(); + }); + + it('should format grep truncation and continuation metadata', async () => { + const knowhereClient = createClient(); + knowhereClient.knowledge.grepChunks.mockResolvedValueOnce({ + document: createMarkerDocument('parsed-storage:doc_remote'), + matches: [ + { + position: 2, + chunkId: 'chunk-table-1', + chunkType: 'table', + sectionPath: 'Tables', + sourceChunkPath: 'chunks/table-1.md', + filePath: 'tables/revenue.html', + startOffset: 5, + endOffset: 12, + snippet: '2026 revenue grew', + }, + ], + scannedChunks: 20, + truncated: true, + continuationCursor: 'cursor-next', + }); + const { client, server } = await connectTestClient(knowhereClient); + + const response = await client.callTool({ + name: 'knowhere_grep_chunks', + arguments: { + documentId: 'doc_remote', + pattern: 'revenue', + maxResults: 1, + }, + }); + + expectToolText( + response, + ` + + + + + + 2026 revenue grew + + +`, + ); + await client.close(); + await server.close(); + }); + + it('should format search evidence and page-result guidance', async () => { + const knowhereClient = createClient(); + knowhereClient.knowledge.search.mockResolvedValueOnce({ + namespace: 'support-center', + query: 'revenue', + evidenceText: 'Evidence ', + references: [ + { + localDocumentId: 'local-report', + documentId: 'doc-1', + chunkId: 'chunk-page-1', + chunkType: 'page', + sectionPath: 'Page 1', + score: 0.9, + }, + { + documentId: 'doc-1', + chunkId: 'chunk-text-1', + chunkType: 'text', + sectionPath: 'Overview', + }, + ], + results: [ + { + localDocumentId: 'local-report', + documentId: 'doc-1', + chunkId: 'chunk-page-1', + chunkType: 'page', + content: 'Page preview', + score: 0.91, + sectionPath: 'Page 1', + sourceFileName: 'report.md', + }, + ], + rawResponse: { + ignored: true, + }, + }); + const { client, server } = await connectTestClient(knowhereClient); + + const response = await client.callTool({ + name: 'knowhere_search', + arguments: { + namespace: 'support-center', + query: 'revenue', + topK: 3, + }, + }); + + expect(knowhereClient.knowledge.search).toHaveBeenCalledWith({ + namespace: 'support-center', + query: 'revenue', + topK: 3, + localDocumentIds: undefined, + useAgentic: undefined, + }); + expectToolText( + response, + ` + + Page results and references marked hasPageAssets="true" only include preview text here. Call knowhere_read_chunks with the documentId and chunkId to get readable page asset URLs and chunk storage locations. + Evidence <tree> + + + + + + + Page preview + + + +`, + ); await client.close(); await server.close(); }); @@ -437,38 +808,90 @@ function createClient(): Knowhere & { } { const knowledge: KnowledgeWithMocks = { parseToLocalCache: vi.fn().mockResolvedValue({ - document: { localDocumentId: 'local-report' }, - result: { jobId: 'job-1' }, + document: createLocalDocument(), + result: { + jobId: 'job-1', + documentId: 'doc-1', + namespace: 'support-center', + manifest: { + jobId: 'job-1', + sourceFileName: 'report.md', + statistics: { + totalChunks: 3, + textChunks: 1, + imageChunks: 0, + tableChunks: 0, + pageChunks: 2, + }, + }, + rawZip: Buffer.from('not rendered'), + chunks: [ + { + chunkId: 'not-rendered', + }, + ], + }, + assetUrlsByFilePath: { + 'page_citation_assets/page-1.png': 'https://assets.example/page-1.png', + }, }), startParse: vi.fn().mockResolvedValue({ - job: { jobId: 'job-async' }, + job: { + jobId: 'job-async', + status: 'running', + sourceType: 'url', + documentId: 'doc-async', + namespace: 'support-center', + }, localDocumentId: 'local-report', }), getJobStatus: vi.fn().mockResolvedValue({ - job: { jobId: 'job-async', status: 'done' }, + job: { + jobId: 'job-async', + status: 'done', + sourceType: 'url', + documentId: 'doc-async', + namespace: 'support-center', + isDone: true, + isFailed: false, + }, cache: { status: 'cached', localDocumentId: 'local-report', - document: { localDocumentId: 'local-report' }, + document: createLocalDocument(), }, }), importJobResult: vi.fn().mockResolvedValue({ - document: { localDocumentId: 'local-report' }, + document: createLocalDocument(), }), recoverPendingAsyncParseJobs: vi.fn().mockResolvedValue({ checkedJobs: 0, results: [], }), - listDocuments: vi.fn().mockResolvedValue([ - { - localDocumentId: 'local-report', - documentId: 'doc-1', - }, - ]), - getDocumentOutline: vi.fn().mockResolvedValue({ sections: [] }), - readChunks: vi.fn().mockResolvedValue({ chunks: [] }), - grepChunks: vi.fn().mockResolvedValue({ matches: [] }), - search: vi.fn().mockResolvedValue({ results: [] }), + listDocuments: vi.fn().mockResolvedValue([createLocalDocument()]), + getDocumentOutline: vi.fn().mockResolvedValue({ + document: createLocalDocument(), + totalChunks: 3, + typeCounts: createTypeCounts(), + sections: [], + sectionTree: [], + }), + readChunks: vi.fn().mockResolvedValue({ + document: createLocalDocument(), + chunks: [], + }), + grepChunks: vi.fn().mockResolvedValue({ + document: createLocalDocument(), + matches: [], + scannedChunks: 3, + truncated: false, + }), + search: vi.fn().mockResolvedValue({ + query: 'empty', + references: [], + results: [], + rawResponse: {}, + }), withCacheDirectory: vi.fn(), withParsedStorage: vi.fn(), }; @@ -484,9 +907,17 @@ function createClient(): Knowhere & { { documentId: 'doc-remote-1', namespace: 'support-center', + sourceFileName: 'remote-report.pdf', status: 'active', + currentJobResultId: 'jres-1', }, ], + pagination: { + page: 1, + pageSize: 50, + total: 1, + totalPages: 1, + }, }); return { @@ -504,6 +935,65 @@ function createClient(): Knowhere & { }; } +function createLocalDocument(): LocalKnowledgeDocument { + return { + localDocumentId: 'local-report', + jobId: 'job-1', + documentId: 'doc-1', + namespace: 'support-center', + sourceFileName: 'report.md', + chunkCount: 3, + typeCounts: createTypeCounts(), + resultDirectoryPath: '/tmp/knowhere/local-report', + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +function createMarkerDocument(storageRoot: string): LocalKnowledgeDocument { + return { + localDocumentId: 'doc_remote', + jobId: 'job-remote', + documentId: 'doc_remote', + namespace: 'support-center', + sourceFileName: 'remote-report.pdf', + chunkCount: 2, + typeCounts: { + text: 0, + image: 0, + table: 0, + page: 2, + }, + resultDirectoryPath: storageRoot, + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +function createTypeCounts(): Record { + return { + text: 1, + image: 0, + table: 0, + page: 2, + }; +} + +function expectToolText(response: ToolCallResponse, expectedText: string): void { + expect(response).not.toHaveProperty('structuredContent'); + if (!('content' in response)) { + throw new Error('Expected MCP tool response to include content'); + } + expect(response.content).toEqual([ + { + type: 'text', + text: expectedText, + }, + ]); +} + +type ToolCallResponse = Awaited>; + type KnowledgeWithMocks = Pick< Knowledge, | 'parseToLocalCache' diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 4546f34..76c4326 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,6 +2,7 @@ import path from 'path'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { DiskParsedDocumentStorage, Knowhere, @@ -15,8 +16,7 @@ import { import * as z from 'zod/v4'; import type { Permission } from './auth.js'; - -type ToolResult = object; +import { createKnowhereToolResult } from './tool-result-formatter.js'; const parsingParamsSchema = z .object({ @@ -32,10 +32,6 @@ const parsingParamsSchema = z }) .optional(); -const objectOutputSchema = { - result: z.record(z.string(), z.unknown()), -}; - export interface KnowhereMcpServerOptions { client?: Knowhere; authTokenProvider?: AuthTokenProvider; @@ -78,10 +74,10 @@ export async function createKnowhereMcpServer( dataId: z.string().optional(), parsingParams: parsingParamsSchema, }, - outputSchema: objectOutputSchema, }, async (input) => createToolResult( + 'parseUrl', await knowledge.parseToLocalCache({ url: input.url, namespace: input.namespace, @@ -105,10 +101,10 @@ export async function createKnowhereMcpServer( dataId: z.string().optional(), parsingParams: parsingParamsSchema, }, - outputSchema: objectOutputSchema, }, async (input) => createToolResult( + 'parseFile', await knowledge.parseToLocalCache({ file: input.file, fileName: input.fileName, @@ -132,10 +128,10 @@ export async function createKnowhereMcpServer( dataId: z.string().optional(), parsingParams: parsingParamsSchema, }, - outputSchema: objectOutputSchema, }, async (input) => createToolResult( + 'asyncParseUrl', await knowledge.startParse({ url: input.url, namespace: input.namespace, @@ -159,10 +155,10 @@ export async function createKnowhereMcpServer( dataId: z.string().optional(), parsingParams: parsingParamsSchema, }, - outputSchema: objectOutputSchema, }, async (input) => createToolResult( + 'asyncParseFile', await knowledge.startParse({ file: input.file, fileName: input.fileName, @@ -183,9 +179,8 @@ export async function createKnowhereMcpServer( inputSchema: { jobId: z.string(), }, - outputSchema: objectOutputSchema, }, - async (input) => createToolResult(await knowledge.getJobStatus(input.jobId)), + async (input) => createToolResult('jobStatus', await knowledge.getJobStatus(input.jobId)), ); server.registerTool( @@ -196,10 +191,10 @@ export async function createKnowhereMcpServer( inputSchema: { namespace: z.string().optional(), }, - outputSchema: objectOutputSchema, }, async (input) => createToolResult( + 'listDocuments', await client.documents.list({ namespace: input.namespace, }), @@ -216,10 +211,12 @@ export async function createKnowhereMcpServer( documentId: z.string().optional(), localDocumentId: z.string().optional(), }, - outputSchema: objectOutputSchema, }, async (input) => - createToolResult(await archiveDocument({ client, knowledge, params: input })), + createToolResult( + 'deleteDocument', + await archiveDocument({ client, knowledge, params: input }), + ), ); } @@ -234,16 +231,15 @@ export async function createKnowhereMcpServer( jobId: z.string().optional(), revisionKey: z.string().optional(), }, - outputSchema: objectOutputSchema, }, - async (input) => createToolResult(await knowledge.getDocumentOutline(input)), + async (input) => createToolResult('outline', await knowledge.getDocumentOutline(input)), ); server.registerTool( 'knowhere_read_chunks', { description: - 'Read exact chunks from one parsed document. Pass localDocumentId, published documentId, or completed jobId. page/pageSize are for display reads and cannot be combined with sectionPath/startChunk/endChunk/chunkId. The SDK reads configured parsed storage first, falls back to remote document chunks for documentId reads, and returns asset URLs when the source or storage provides them. Page screenshots are returned under chunk metadata.pageAssets, not as image chunks.', + 'Read exact chunks from one parsed document. Pass localDocumentId, published documentId, or completed jobId. page/pageSize are for display reads and cannot be combined with sectionPath/startChunk/endChunk/chunkId. The SDK reads configured parsed storage first, falls back to remote document chunks for documentId reads, and returns asset URLs when the source or storage provides them. Page screenshots are returned as tagged entries before preview text, not as image chunks.', inputSchema: { localDocumentId: z.string().optional(), documentId: z.string().optional(), @@ -258,9 +254,8 @@ export async function createKnowhereMcpServer( chunkType: z.enum(['text', 'image', 'table', 'page']).optional(), limit: z.number().int().positive().optional(), }, - outputSchema: objectOutputSchema, }, - async (input) => createToolResult(await knowledge.readChunks(input)), + async (input) => createToolResult('readChunks', await knowledge.readChunks(input)), ); server.registerTool( @@ -282,16 +277,15 @@ export async function createKnowhereMcpServer( sectionPathPrefix: z.string().optional(), contextChars: z.number().int().nonnegative().optional(), }, - outputSchema: objectOutputSchema, }, - async (input) => createToolResult(await knowledge.grepChunks(input)), + async (input) => createToolResult('grepChunks', await knowledge.grepChunks(input)), ); server.registerTool( 'knowhere_search', { description: - 'Search published Knowhere documents with the Knowhere API retrieval query. localDocumentIds only map returned server document IDs back to local cache IDs when available. Page screenshots are exposed by follow-up read calls under chunk metadata.pageAssets, not as image chunks.', + 'Search published Knowhere documents with the Knowhere API retrieval query. localDocumentIds only map returned server document IDs back to local cache IDs when available. Page results are marked with hasPageAssets="true"; use follow-up read calls to get tagged entries.', inputSchema: { query: z.string(), namespace: z.string().optional(), @@ -299,9 +293,8 @@ export async function createKnowhereMcpServer( localDocumentIds: z.array(z.string()).optional(), useAgentic: z.boolean().optional(), }, - outputSchema: objectOutputSchema, }, - async (input) => createToolResult(await knowledge.search(input)), + async (input) => createToolResult('search', await knowledge.search(input)), ); return server; @@ -323,15 +316,8 @@ function createKnowledgeClient(client: Knowhere, cacheDirectory: string | undefi }); } -function createToolResult(result: ToolResult): { - content: { type: 'text'; text: string }[]; - structuredContent: { result: ToolResult }; -} { - const structuredContent = { result }; - return { - content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }], - structuredContent, - }; +function createToolResult(operation: string, result: unknown): CallToolResult { + return createKnowhereToolResult({ operation, result }); } function toFlatParsingParams( diff --git a/packages/mcp/src/tool-result-formatter.ts b/packages/mcp/src/tool-result-formatter.ts new file mode 100644 index 0000000..365f3e0 --- /dev/null +++ b/packages/mcp/src/tool-result-formatter.ts @@ -0,0 +1,752 @@ +import path from 'path'; + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +type UnknownRecord = Readonly>; + +interface TextTag { + readonly name: string; + readonly attributes?: Readonly>; + readonly text?: string; +} + +interface PageAssetReference { + readonly pageNum?: number; + readonly artifactRef?: string; + readonly assetUrl?: string; + readonly contentType?: string; + readonly width?: number; + readonly height?: number; +} + +interface ChunkFields { + readonly position?: number; + readonly chunkId?: string; + readonly chunkType?: string; + readonly sectionPath?: string; + readonly chunkPath?: string; + readonly filePath?: string; + readonly assetUrl?: string; + readonly pageNumbers?: readonly number[]; + readonly storageLocation?: string; + readonly previewText: string; + readonly metadata?: UnknownRecord; +} + +export function createKnowhereToolResult(params: { + readonly operation: string; + readonly result: unknown; +}): CallToolResult { + return { + content: [ + { + type: 'text', + text: formatOperationResult(params.operation, params.result), + }, + ], + }; +} + +function formatOperationResult(operation: string, result: unknown): string { + const lines: string[] = [``]; + + switch (operation) { + case 'parseUrl': + case 'parseFile': + appendBlockingParseResult(lines, result); + break; + case 'asyncParseUrl': + case 'asyncParseFile': + appendAsyncParseResult(lines, result); + break; + case 'jobStatus': + appendJobStatusResult(lines, result); + break; + case 'listDocuments': + appendListDocumentsResult(lines, result); + break; + case 'deleteDocument': + appendDeleteDocumentResult(lines, result); + break; + case 'outline': + appendOutlineResult(lines, result); + break; + case 'readChunks': + appendReadChunksResult(lines, result); + break; + case 'grepChunks': + appendGrepChunksResult(lines, result); + break; + case 'search': + appendSearchResult(lines, result); + break; + default: + appendTextTag(lines, 1, { + name: 'summary', + text: 'Operation completed.', + }); + break; + } + + lines.push(''); + return lines.join('\n'); +} + +function appendBlockingParseResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + appendDocument(lines, readRecord(response, 'document'), 1); + appendParseSummary(lines, readRecord(response, 'result'), 1); + appendAssetUrls(lines, readRecord(response, 'assetUrlsByFilePath'), 1); +} + +function appendAsyncParseResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + const job: UnknownRecord | undefined = readRecord(response, 'job'); + appendSelfClosingTag(lines, 1, { + name: 'job', + attributes: { + localDocumentId: readString(response, 'localDocumentId'), + jobId: readString(job, 'jobId'), + status: readString(job, 'status'), + sourceType: readString(job, 'sourceType'), + documentId: readString(job, 'documentId'), + namespace: readString(job, 'namespace'), + dataId: readString(job, 'dataId'), + }, + }); +} + +function appendJobStatusResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + const job: UnknownRecord | undefined = readRecord(response, 'job'); + appendSelfClosingTag(lines, 1, { + name: 'job', + attributes: { + jobId: readString(job, 'jobId'), + status: readString(job, 'status'), + sourceType: readString(job, 'sourceType'), + documentId: readString(job, 'documentId'), + namespace: readString(job, 'namespace'), + isDone: readBoolean(job, 'isDone'), + isFailed: readBoolean(job, 'isFailed'), + }, + }); + + const cache: UnknownRecord | undefined = readRecord(response, 'cache'); + lines.push( + `${indent(1)}`, + ); + appendDocument(lines, readRecord(cache, 'document'), 2); + lines.push(`${indent(1)}`); +} + +function appendListDocumentsResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + const documents: readonly UnknownRecord[] = readRecordArray(response, 'documents'); + lines.push( + `${indent(1)}`, + ); + for (const document of documents) { + appendDocument(lines, document, 2); + } + appendPagination(lines, readRecord(response, 'pagination'), 2); + lines.push(`${indent(1)}`); +} + +function appendDeleteDocumentResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + lines.push( + `${indent(1)}`, + ); + appendDocument(lines, readRecord(response, 'document'), 2); + lines.push(`${indent(1)}`); +} + +function appendOutlineResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + appendDocument(lines, readRecord(response, 'document'), 1); + lines.push( + `${indent(1)}`, + ); + appendChunkCounts( + lines, + readRecord(response, 'typeCounts'), + 2, + readNumber(response, 'totalChunks'), + ); + const sectionTree: readonly UnknownRecord[] = readRecordArray(response, 'sectionTree'); + const sections: readonly UnknownRecord[] = + sectionTree.length > 0 ? sectionTree : readRecordArray(response, 'sections'); + for (const section of sections) { + appendSection(lines, section, 2); + } + lines.push(`${indent(1)}`); +} + +function appendReadChunksResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + const document: UnknownRecord | undefined = readRecord(response, 'document'); + const storageRoot: string | undefined = readString(document, 'resultDirectoryPath'); + const chunks: readonly UnknownRecord[] = readRecordArray(response, 'chunks'); + appendDocument(lines, document, 1); + appendPaginationSummary(lines, response, 1); + lines.push(`${indent(1)}`); + for (const chunk of chunks) { + appendChunk(lines, chunk, storageRoot, 2); + } + lines.push(`${indent(1)}`); +} + +function appendGrepChunksResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + const document: UnknownRecord | undefined = readRecord(response, 'document'); + const storageRoot: string | undefined = readString(document, 'resultDirectoryPath'); + const matches: readonly UnknownRecord[] = readRecordArray(response, 'matches'); + appendDocument(lines, document, 1); + lines.push( + `${indent(1)}`, + ); + for (const match of matches) { + appendGrepMatch(lines, match, storageRoot, 2); + } + lines.push(`${indent(1)}`); +} + +function appendSearchResult(lines: string[], result: unknown): void { + const response: UnknownRecord | undefined = toRecord(result); + const references: readonly UnknownRecord[] = readRecordArray(response, 'references'); + const results: readonly UnknownRecord[] = readRecordArray(response, 'results'); + const hasPageAssets: boolean = references.some(isPageRecord) || results.some(isPageRecord); + + lines.push( + `${indent(1)}`, + ); + if (hasPageAssets) { + appendTextTag(lines, 2, { + name: 'instruction', + text: 'Page results and references marked hasPageAssets="true" only include preview text here. Call knowhere_read_chunks with the documentId and chunkId to get readable page asset URLs and chunk storage locations.', + }); + } + appendOptionalTextTag(lines, 2, 'evidenceText', readString(response, 'evidenceText')); + appendSearchReferences(lines, references, 2); + appendSearchResults(lines, results, 2); + lines.push(`${indent(1)}`); +} + +function appendParseSummary( + lines: string[], + result: UnknownRecord | undefined, + depth: number, +): void { + const manifest: UnknownRecord | undefined = readRecord(result, 'manifest'); + const statistics: UnknownRecord | undefined = readRecord(manifest, 'statistics'); + lines.push( + `${indent(depth)}`, + ); + appendChunkCounts(lines, statistics, depth + 1, readNumber(statistics, 'totalChunks')); + lines.push(`${indent(depth)}`); +} + +function appendAssetUrls( + lines: string[], + assetUrlsByFilePath: UnknownRecord | undefined, + depth: number, +): void { + if (!assetUrlsByFilePath) { + return; + } + + const entries: readonly [string, string][] = Object.entries(assetUrlsByFilePath).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ); + if (entries.length === 0) { + return; + } + + lines.push(`${indent(depth)}`); + for (const [filePath, assetUrl] of entries) { + appendSelfClosingTag(lines, depth + 1, { + name: 'assetUrl', + attributes: { + filePath, + assetUrl, + }, + }); + } + lines.push(`${indent(depth)}`); +} + +function appendDocument(lines: string[], document: UnknownRecord | undefined, depth: number): void { + if (!document) { + appendSelfClosingTag(lines, depth, { name: 'document' }); + return; + } + + lines.push( + `${indent(depth)}`, + ); + appendChunkCounts( + lines, + readRecord(document, 'typeCounts'), + depth + 1, + readNumber(document, 'chunkCount'), + ); + lines.push(`${indent(depth)}`); +} + +function appendChunkCounts( + lines: string[], + counts: UnknownRecord | undefined, + depth: number, + totalChunks: number | undefined, +): void { + appendSelfClosingTag(lines, depth, { + name: 'chunkCounts', + attributes: { + total: totalChunks ?? readNumber(counts, 'totalChunks'), + text: readNumber(counts, 'text') ?? readNumber(counts, 'textChunks'), + image: readNumber(counts, 'image') ?? readNumber(counts, 'imageChunks'), + table: readNumber(counts, 'table') ?? readNumber(counts, 'tableChunks'), + page: readNumber(counts, 'page') ?? readNumber(counts, 'pageChunks'), + }, + }); +} + +function appendPagination( + lines: string[], + pagination: UnknownRecord | undefined, + depth: number, +): void { + if (!pagination) { + return; + } + appendSelfClosingTag(lines, depth, { + name: 'pagination', + attributes: { + page: readNumber(pagination, 'page'), + pageSize: readNumber(pagination, 'pageSize'), + total: readNumber(pagination, 'total'), + totalPages: readNumber(pagination, 'totalPages'), + }, + }); +} + +function appendPaginationSummary( + lines: string[], + response: UnknownRecord | undefined, + depth: number, +): void { + appendSelfClosingTag(lines, depth, { + name: 'pagination', + attributes: { + page: readNumber(response, 'page'), + pageSize: readNumber(response, 'pageSize'), + totalChunks: readNumber(response, 'totalChunks'), + totalPages: readNumber(response, 'totalPages'), + nextChunk: readNumber(response, 'nextChunk'), + }, + }); +} + +function appendSection(lines: string[], section: UnknownRecord, depth: number): void { + lines.push( + `${indent(depth)}`, + ); + appendOptionalTextTag(lines, depth + 1, 'summary', readString(section, 'summary')); + appendChunkCounts( + lines, + readRecord(section, 'typeCounts'), + depth + 1, + readNumber(section, 'chunkCount'), + ); + for (const child of readRecordArray(section, 'children')) { + appendSection(lines, child, depth + 1); + } + lines.push(`${indent(depth)}`); +} + +function appendChunk( + lines: string[], + chunk: UnknownRecord, + storageRoot: string | undefined, + depth: number, +): void { + const fields: ChunkFields = readChunkFields(chunk, storageRoot); + lines.push( + `${indent(depth)}`, + ); + if (fields.chunkType === 'page') { + appendPageAssets(lines, fields.metadata, depth + 1); + } + appendTextTag(lines, depth + 1, { + name: 'previewText', + text: fields.previewText, + }); + lines.push(`${indent(depth)}`); +} + +function appendPageAssets( + lines: string[], + metadata: UnknownRecord | undefined, + depth: number, +): void { + const pageAssets: readonly PageAssetReference[] = readPageAssets(metadata); + lines.push(`${indent(depth)}`); + for (const pageAsset of pageAssets) { + appendSelfClosingTag(lines, depth + 1, { + name: 'pageAsset', + attributes: { + pageNum: pageAsset.pageNum, + artifactRef: pageAsset.artifactRef, + assetUrl: pageAsset.assetUrl, + contentType: pageAsset.contentType, + width: pageAsset.width, + height: pageAsset.height, + }, + }); + } + lines.push(`${indent(depth)}`); + appendTextTag(lines, depth, { + name: 'instruction', + text: createPageAssetInstruction(pageAssets), + }); +} + +function appendGrepMatch( + lines: string[], + match: UnknownRecord, + storageRoot: string | undefined, + depth: number, +): void { + const fields: ChunkFields = readChunkFields(match, storageRoot); + lines.push( + `${indent(depth)}`, + ); + appendTextTag(lines, depth + 1, { + name: 'snippet', + text: readString(match, 'snippet') ?? '', + }); + lines.push(`${indent(depth)}`); +} + +function appendSearchReferences( + lines: string[], + references: readonly UnknownRecord[], + depth: number, +): void { + lines.push(`${indent(depth)}`); + for (const reference of references) { + appendSelfClosingTag(lines, depth + 1, { + name: 'reference', + attributes: { + localDocumentId: readString(reference, 'localDocumentId'), + documentId: readString(reference, 'documentId'), + chunkId: readString(reference, 'chunkId'), + chunkType: readString(reference, 'chunkType'), + sectionPath: readString(reference, 'sectionPath'), + score: readNumber(reference, 'score'), + hasPageAssets: isPageRecord(reference) ? true : undefined, + }, + }); + } + lines.push(`${indent(depth)}`); +} + +function appendSearchResults( + lines: string[], + results: readonly UnknownRecord[], + depth: number, +): void { + lines.push(`${indent(depth)}`); + for (const result of results) { + const chunkType: string | undefined = readString(result, 'chunkType'); + lines.push( + `${indent(depth + 1)}`, + ); + appendTextTag(lines, depth + 2, { + name: 'previewText', + text: readString(result, 'content') ?? '', + }); + lines.push(`${indent(depth + 1)}`); + } + lines.push(`${indent(depth)}`); +} + +function readChunkFields(chunk: UnknownRecord, storageRoot: string | undefined): ChunkFields { + const chunkType: string | undefined = readString(chunk, 'chunkType'); + const chunkPath: string | undefined = + readString(chunk, 'sourceChunkPath') ?? readString(chunk, 'chunkPath'); + const filePath: string | undefined = readString(chunk, 'filePath'); + return { + position: readNumber(chunk, 'position'), + chunkId: readString(chunk, 'chunkId'), + chunkType, + sectionPath: readString(chunk, 'sectionPath'), + chunkPath, + filePath, + assetUrl: readString(chunk, 'assetUrl'), + pageNumbers: readNumberArray(chunk, 'pageNumbers'), + storageLocation: createStorageLocation({ + storageRoot, + chunkType, + chunkPath, + filePath, + }), + previewText: readString(chunk, 'readableContent') ?? readString(chunk, 'content') ?? '', + metadata: readRecord(chunk, 'metadata'), + }; +} + +function createStorageLocation(params: { + readonly storageRoot: string | undefined; + readonly chunkType: string | undefined; + readonly chunkPath: string | undefined; + readonly filePath: string | undefined; +}): string | undefined { + if (!params.storageRoot) { + return undefined; + } + + const preferredPath: string | undefined = + isMediaChunk(params.chunkType) && params.filePath ? params.filePath : params.chunkPath; + if (!preferredPath) { + return undefined; + } + + const relativePath: string = stripLeadingPathSeparators(preferredPath); + if (isMarkerStorageRoot(params.storageRoot)) { + return `${stripTrailingPathSeparators(params.storageRoot)}/${relativePath}`; + } + + return path.join(params.storageRoot, relativePath); +} + +function createPageAssetInstruction(pageAssets: readonly PageAssetReference[]): string { + if (pageAssets.length === 0) { + return 'No page asset metadata was returned; rely on preview text only.'; + } + + if (pageAssets.some((pageAsset) => pageAsset.assetUrl !== undefined)) { + return 'Open or fetch the listed assetUrl before relying on preview text.'; + } + + return 'A page asset exists, but it is not directly readable because no assetUrl was returned.'; +} + +function readPageAssets(metadata: UnknownRecord | undefined): readonly PageAssetReference[] { + const rawPageAssets: unknown = metadata?.pageAssets ?? metadata?.page_assets ?? undefined; + if (!Array.isArray(rawPageAssets)) { + return []; + } + + return rawPageAssets.filter(isRecord).map( + (pageAsset): PageAssetReference => ({ + pageNum: readNumber(pageAsset, 'pageNum') ?? readNumber(pageAsset, 'page_num'), + artifactRef: readString(pageAsset, 'artifactRef') ?? readString(pageAsset, 'artifact_ref'), + assetUrl: readString(pageAsset, 'assetUrl') ?? readString(pageAsset, 'asset_url'), + contentType: readString(pageAsset, 'contentType') ?? readString(pageAsset, 'content_type'), + width: readNumber(pageAsset, 'width'), + height: readNumber(pageAsset, 'height'), + }), + ); +} + +function appendOptionalTextTag( + lines: string[], + depth: number, + name: string, + text: string | undefined, +): void { + if (text === undefined || text.length === 0) { + return; + } + appendTextTag(lines, depth, { name, text }); +} + +function appendTextTag(lines: string[], depth: number, tag: TextTag): void { + lines.push( + `${indent(depth)}<${tag.name}${formatAttributes(tag.attributes)}>${escapeText( + tag.text ?? '', + )}`, + ); +} + +function appendSelfClosingTag(lines: string[], depth: number, tag: TextTag): void { + lines.push(`${indent(depth)}<${tag.name}${formatAttributes(tag.attributes)} />`); +} + +function formatAttributes( + attributes: Readonly> | undefined, +): string { + if (!attributes) { + return ''; + } + + const renderedAttributes: string[] = []; + for (const [name, value] of Object.entries(attributes)) { + if (value === undefined) { + continue; + } + renderedAttributes.push(`${name}="${escapeAttribute(String(value))}"`); + } + + return renderedAttributes.length > 0 ? ` ${renderedAttributes.join(' ')}` : ''; +} + +function isPageRecord(record: UnknownRecord): boolean { + return readString(record, 'chunkType') === 'page'; +} + +function isMediaChunk(chunkType: string | undefined): boolean { + return chunkType === 'image' || chunkType === 'table'; +} + +function isMarkerStorageRoot(storageRoot: string): boolean { + return storageRoot.startsWith('parsed-storage:') || storageRoot.startsWith('remote:'); +} + +function stripLeadingPathSeparators(value: string): string { + return value.replace(/^[\\/]+/, ''); +} + +function stripTrailingPathSeparators(value: string): string { + return value.replace(/[\\/]+$/, ''); +} + +function readRecord(record: UnknownRecord | undefined, key: string): UnknownRecord | undefined { + return toRecord(record?.[key]); +} + +function readRecordArray(record: UnknownRecord | undefined, key: string): readonly UnknownRecord[] { + const value: unknown = record?.[key]; + if (!Array.isArray(value)) { + return []; + } + return value.filter(isRecord); +} + +function readString(record: UnknownRecord | undefined, key: string): string | undefined { + const value: unknown = record?.[key]; + return typeof value === 'string' ? value : undefined; +} + +function readNumber(record: UnknownRecord | undefined, key: string): number | undefined { + const value: unknown = record?.[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function readBoolean(record: UnknownRecord | undefined, key: string): boolean | undefined { + const value: unknown = record?.[key]; + return typeof value === 'boolean' ? value : undefined; +} + +function readNumberArray( + record: UnknownRecord | undefined, + key: string, +): readonly number[] | undefined { + const value: unknown = record?.[key]; + if (!Array.isArray(value)) { + return undefined; + } + const numbers: number[] = value.filter( + (item): item is number => typeof item === 'number' && Number.isFinite(item), + ); + return numbers.length > 0 ? numbers : undefined; +} + +function toRecord(value: unknown): UnknownRecord | undefined { + return isRecord(value) ? value : undefined; +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function indent(depth: number): string { + return ' '.repeat(depth); +} + +function escapeAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +}