Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/retrieval-evidence-compose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ontos-ai/knowhere-sdk": minor
"@ontos-ai/knowhere-mcp": minor
---

Expose composed retrieval `evidence` for agents and keep `results` as placeholder debug chunks. Local `knowhere_search` now returns that evidence as MCP text/image content.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,12 +311,13 @@ const response = await client.retrieval.query({

console.log(response.answerText); // LLM-generated answer
console.log(response.referencedChunks); // cited evidence chunks
console.log(response.evidenceText); // rendered evidence context, when returned
console.log(response.evidence); // composed parts to consume (text/HTML and inline images)
console.log(response.evidenceText); // text projection of those parts
console.log(response.stopReason); // agentic termination reason, when returned
console.log(response.failureReason); // no-answer reason, when returned

for (const result of response.results) {
console.log(result.content);
console.log(result.content); // raw path chunk for debug
console.log(result.contentSource);
console.log(result.score);
console.log(result.metadata?.pageNums);
Expand Down
25 changes: 19 additions & 6 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,23 +204,36 @@ When logged in with Read only permission, the MCP server exposes only
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. 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.
retrieval query. Compose already happened in Knowhere. The tool returns
assembled evidence as MCP text/image content, then a debug XML item with the
raw results list.

## Response Contract

All tools return a single MCP text content item:
Most tools return a single MCP text content item:

```json
{
"content": [{ "type": "text", "text": "<knowhere operation=\"...\">..." }]
}
```

`knowhere_search` returns assembled evidence first, then the debug XML:

```json
{
"content": [
{ "type": "text", "text": "composed table or text" },
{ "type": "image", "data": "...", "mimeType": "image/png" },
{ "type": "text", "text": "<knowhere operation=\"search\">..." }
]
}
```

The MCP package does not expose `structuredContent` or tool `outputSchema`
fields. Each response is tagged text rooted at
`<knowhere operation="...">`, using SDK-native camelCase field names such as
fields. Non-search tools return tagged text rooted at
`<knowhere operation="...">`. Search prepends assembled evidence content, then
the same tagged debug XML. Field names stay SDK-native camelCase such as
`documentId`, `jobId`, `localDocumentId`, `chunkId`, `assetUrl`,
`chunkPath`, `filePath`, and `storageRoot`.

Expand Down
31 changes: 21 additions & 10 deletions packages/mcp/src/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ describe('knowhere MCP wrapper', () => {
expect(readTool?.description).toContain('configured parsed storage first');
expect(readTool?.description).toContain('returns asset URLs');
expect(readTool?.description).toContain('<pageAssets>');
expect(searchTool?.description).toContain('hasPageAssets="true"');
expect(searchTool?.description).toContain('assembled evidence');
expect(searchTool?.description).toContain('debug XML');
await client.close();
await server.close();
});
Expand Down Expand Up @@ -683,11 +684,15 @@ describe('knowhere MCP wrapper', () => {
await server.close();
});

it('should format search evidence and page-result guidance', async () => {
it('should emit assembled evidence then debug search results', async () => {
const knowhereClient = createClient();
knowhereClient.knowledge.search.mockResolvedValueOnce({
namespace: 'support-center',
query: 'revenue',
evidence: [
{ type: 'text', text: 'Revenue increased' },
{ type: 'image', mediaType: 'image/png', data: 'abc' },
],
evidenceText: 'Evidence <tree>',
references: [
{
Expand Down Expand Up @@ -739,24 +744,30 @@ describe('knowhere MCP wrapper', () => {
localDocumentIds: undefined,
useAgentic: undefined,
});
expectToolText(
response,
`<knowhere operation="search">
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: 'Revenue increased' },
{ type: 'image', data: 'abc', mimeType: 'image/png' },
{
type: 'text',
text: `<knowhere operation="search">
<search namespace="support-center" query="revenue" referenceCount="2" resultCount="1">
<instruction>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.</instruction>
<evidenceText>Evidence &lt;tree&gt;</evidenceText>
<references count="2">
<reference localDocumentId="local-report" documentId="doc-1" chunkId="chunk-page-1" chunkType="page" sectionPath="Page 1" score="0.9" hasPageAssets="true" />
<reference localDocumentId="local-report" documentId="doc-1" chunkId="chunk-page-1" chunkType="page" sectionPath="Page 1" score="0.9" />
<reference documentId="doc-1" chunkId="chunk-text-1" chunkType="text" sectionPath="Overview" />
</references>
<results count="1">
<result localDocumentId="local-report" documentId="doc-1" chunkId="chunk-page-1" chunkType="page" sectionPath="Page 1" sourceFileName="report.md" score="0.91" hasPageAssets="true">
<result localDocumentId="local-report" documentId="doc-1" chunkId="chunk-page-1" chunkType="page" sectionPath="Page 1" sourceFileName="report.md" score="0.91">
<previewText>Page preview</previewText>
</result>
</results>
</search>
</knowhere>`,
);
},
]);
await client.close();
await server.close();
});
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export async function createKnowhereMcpServer(
'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 results are marked with hasPageAssets="true"; use follow-up read calls to get tagged <pageAssets> entries.',
'Search published Knowhere documents. Compose already happened in Knowhere. The tool returns assembled evidence as MCP text/image content, then a debug XML item with the raw results list. localDocumentIds only map returned server document IDs back to local cache IDs when available.',
inputSchema: {
query: z.string(),
namespace: z.string().optional(),
Expand Down
47 changes: 33 additions & 14 deletions packages/mcp/src/tool-result-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export function createKnowhereToolResult(params: {
readonly operation: string;
readonly result: unknown;
}): CallToolResult {
if (params.operation === 'search') {
return { content: toSearchToolContent(params.result) };
}

return {
content: [
{
Expand All @@ -47,6 +51,35 @@ export function createKnowhereToolResult(params: {
};
}

function toSearchToolContent(result: unknown): CallToolResult['content'] {
const response: UnknownRecord | undefined = toRecord(result);
const content: CallToolResult['content'] = [];

for (const part of readRecordArray(response, 'evidence')) {
const type: string | undefined = readString(part, 'type');
if (type === 'text') {
const text: string | undefined = readString(part, 'text');
if (text !== undefined) {
content.push({ type: 'text', text });
}
continue;
}
if (type === 'image') {
const data: string | undefined = readString(part, 'data');
const mediaType: string | undefined = readString(part, 'mediaType');
if (data !== undefined && mediaType !== undefined) {
content.push({ type: 'image', data, mimeType: mediaType });
}
}
}

content.push({
type: 'text',
text: formatOperationResult('search', result),
});
return content;
}

function formatOperationResult(operation: string, result: unknown): string {
const lines: string[] = [`<knowhere operation="${escapeAttribute(operation)}">`];

Expand Down Expand Up @@ -223,7 +256,6 @@ 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)}<search${formatAttributes({
Expand All @@ -233,13 +265,6 @@ function appendSearchResult(lines: string[], result: unknown): void {
resultCount: results.length,
})}>`,
);
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)}</search>`);
Expand Down Expand Up @@ -451,7 +476,6 @@ function appendSearchReferences(
chunkType: readString(reference, 'chunkType'),
sectionPath: readString(reference, 'sectionPath'),
score: readNumber(reference, 'score'),
hasPageAssets: isPageRecord(reference) ? true : undefined,
},
});
}
Expand All @@ -475,7 +499,6 @@ function appendSearchResults(
sectionPath: readString(result, 'sectionPath'),
sourceFileName: readString(result, 'sourceFileName'),
score: readNumber(result, 'score'),
hasPageAssets: isPageRecord(result) ? true : undefined,
})}>`,
);
appendTextTag(lines, depth + 2, {
Expand Down Expand Up @@ -608,10 +631,6 @@ function formatAttributes(
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';
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type {
RetrievalSectionExclusion,
RetrievalQueryParams,
RetrievalSource,
RetrievalEvidencePart,
RetrievalResult,
RetrievalReferencedChunk,
RetrievalQueryResponse,
Expand Down
2 changes: 2 additions & 0 deletions src/knowledge/__tests__/knowledge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,7 @@ describe('Knowledge', () => {
topK: 2,
useAgentic: true,
});
expect(response.evidence).toEqual([{ type: 'text', text: 'Margin guidance improved.' }]);
expect(response.results).toHaveLength(1);
expect(response.results[0]).toMatchObject({
localDocumentId: 'local-report',
Expand Down Expand Up @@ -1199,6 +1200,7 @@ function createClient(parseResult: ParseResult): {
query: 'margin',
routerUsed: 'legacy',
answerText: null,
evidence: [{ type: 'text', text: 'Margin guidance improved.' }],
evidenceText: '[report.md / Revenue]\nMargin guidance improved.',
referencedChunks: [
{
Expand Down
1 change: 1 addition & 0 deletions src/knowledge/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ export class Knowledge {
return {
namespace: rawResponse.namespace,
query: rawResponse.query,
evidence: rawResponse.evidence ?? [],
evidenceText: rawResponse.evidenceText,
references: [
...rawResponse.referencedChunks.map(
Expand Down
2 changes: 2 additions & 0 deletions src/knowledge/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RetrievalEvidencePart } from '../types/retrieval.js';
import type { ParseParams } from '../types/params.js';
import type { Job, JobResult } from '../types/job.js';
import type { Chunk, DocumentChunkType, ParseResult } from '../types/index.js';
Expand Down Expand Up @@ -238,6 +239,7 @@ export interface KnowledgeSearchReference {
export interface KnowledgeSearchResponse {
namespace?: string;
query: string;
evidence?: RetrievalEvidencePart[];
evidenceText?: string | null;
references: KnowledgeSearchReference[];
results: KnowledgeSearchResult[];
Expand Down
54 changes: 54 additions & 0 deletions src/resources/__tests__/retrieval-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,57 @@ describe.each(['apiKey', 'authTokenProvider'] as const)('Retrieval wire payload
}
});
});

describe('Retrieval wire response', () => {
it('keeps composed evidence and camelCases image media type', async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({
namespace: 'default',
query: 'refund policy',
router_used: 'small_corpus_all',
evidence: [
{ type: 'text', text: 'before' },
{ type: 'image', media_type: 'image/png', data: 'abc' },
],
evidence_text: 'beforedata:image/png;base64,abc',
results: [
{
content: '[images/a.png]',
chunk_type: 'text',
score: 1,
source: { document_id: 'doc-1' },
},
],
}),
);
});

try {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected TCP server address');
const client = new Knowhere({
baseURL: `http://127.0.0.1:${address.port}`,
apiKey: 'test-key',
maxRetries: 0,
});

const result = await client.retrieval.query({ query: 'refund policy' });

expect(result.evidence).toEqual([
{ type: 'text', text: 'before' },
{ type: 'image', mediaType: 'image/png', data: 'abc' },
]);
expect(result.results[0]?.content).toBe('[images/a.png]');
expect(result.results[0]).not.toHaveProperty('composed');
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
}
});
});
8 changes: 8 additions & 0 deletions src/resources/__tests__/retrieval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ describe('Retrieval Resource', () => {
query: 'refund policy',
routerUsed: 'discovery+agent',
answerText: null,
evidence: [
{ type: 'text', text: 'Annual plans may be refunded within 30 days.' },
{ type: 'image', mediaType: 'image/png', data: 'abc' },
],
referencedChunks: [],
results: [
{
Expand Down Expand Up @@ -83,6 +87,10 @@ describe('Retrieval Resource', () => {
],
});
expect(response.routerUsed).toBe('discovery+agent');
expect(response.evidence).toEqual([
{ type: 'text', text: 'Annual plans may be refunded within 30 days.' },
{ type: 'image', mediaType: 'image/png', data: 'abc' },
]);
expect(response.results[0]).toEqual({
content: 'Annual plans may be refunded within 30 days.',
chunkType: 'text',
Expand Down
Loading
Loading