@@ -5,7 +5,12 @@ import { Request } from 'express';
55import { IAuthTokenPayload } from '#/user/auth/domain' ;
66import { IAgentGateway } from '#/agent/agent/domain' ;
77import { ITemplateGateway } from '#/agent/template/domain' ;
8+ import { IDynamicallyDescribedTool } from '#/mcp/interfaces/dynamic-description.interface' ;
89import { KnowledgeService } from './domain/knowledge.service' ;
10+ import { IKnowledgeGateway } from './domain/knowledge.gateway' ;
11+
12+ const BASE_DESCRIPTION =
13+ 'Search your bound knowledge bases for factual information. ALWAYS try this BEFORE web_search when the user asks about specific facts, company details, product specs, internal documentation, or anything the user might have uploaded. Returns matched content with citations. The knowledge_id parameter is optional - omit it to search across all your bound bases at once.' ;
914
1015interface ToolResult {
1116 content : { type : 'text' ; text : string } [ ] ;
@@ -28,28 +33,65 @@ const err = (message: string): ToolResult => ({
2833} ) ;
2934
3035@Injectable ( )
31- export class KnowledgeTool {
36+ export class KnowledgeTool implements IDynamicallyDescribedTool {
3237 private readonly logger = new Logger ( KnowledgeTool . name ) ;
3338
3439 constructor (
3540 private readonly knowledgeService : KnowledgeService ,
3641 private readonly agentGateway : IAgentGateway ,
3742 private readonly templateGateway : ITemplateGateway ,
43+ private readonly knowledgeGateway : IKnowledgeGateway ,
3844 ) { }
3945
46+ /**
47+ * MCP tools/list is invoked per-session at agent startup. Returning a
48+ * description that lists the bases bound to the calling agent (name +
49+ * description) gives the LLM enough context to decide when to query
50+ * without first having to enumerate via a separate tool. Returns null
51+ * (= use static description) when the caller isn't an agent or has no
52+ * bound bases.
53+ */
54+ async describeForRequest (
55+ httpRequest : Request & { user ?: IAuthTokenPayload } ,
56+ ) : Promise < string | null > {
57+ const agentId = this . extractAgentId ( httpRequest ) ;
58+ if ( ! agentId ) return null ;
59+
60+ const allowedIds = await this . resolveAllowedIds ( agentId ) ;
61+ if ( allowedIds . length === 0 ) return null ;
62+
63+ const bases = await this . knowledgeGateway . findExistingByIds ( allowedIds ) ;
64+ if ( bases . length === 0 ) return null ;
65+
66+ const lines = bases . map ( ( b ) => {
67+ const description = b . description ?. trim ( ) ;
68+ return description
69+ ? `- "${ b . name } " (id: ${ b . id } ) - ${ description } `
70+ : `- "${ b . name } " (id: ${ b . id } )` ;
71+ } ) ;
72+ return [
73+ BASE_DESCRIPTION ,
74+ '' ,
75+ 'Knowledge bases bound to this agent (you can omit knowledge_id to search all of them):' ,
76+ ...lines ,
77+ ] . join ( '\n' ) ;
78+ }
79+
4080 @Tool ( {
4181 name : 'query_knowledge' ,
42- description :
43- 'Query a knowledge base bound to this agent. Bases are configured per-template (defaultKnowledgeIds) or per-agent (knowledgeIds override). The caller must pass a knowledge_id from its allowed list.' ,
82+ description : BASE_DESCRIPTION ,
4483 parameters : z . object ( {
4584 knowledge_id : z
4685 . string ( )
47- . describe ( 'Knowledge base id, e.g. knowledge-abc123' ) ,
86+ . optional ( )
87+ . describe (
88+ 'Optional. Omit to search all knowledge bases bound to this agent. Provide only when you already know the specific knowledge base id.' ,
89+ ) ,
4890 query : z . string ( ) . describe ( 'Natural-language search query.' ) ,
4991 } ) ,
5092 } )
5193 async query (
52- { knowledge_id, query } : { knowledge_id : string ; query : string } ,
94+ { knowledge_id, query } : { knowledge_id ? : string ; query : string } ,
5395 _context : unknown ,
5496 httpRequest : Request & { user ?: IAuthTokenPayload } ,
5597 ) : Promise < ToolResult > {
@@ -59,17 +101,44 @@ export class KnowledgeTool {
59101 }
60102
61103 const allowedIds = await this . resolveAllowedIds ( callerAgentId ) ;
62- if ( ! allowedIds . includes ( knowledge_id ) ) {
63- return err ( `Knowledge ${ knowledge_id } not bound to this agent.` ) ;
104+ if ( allowedIds . length === 0 ) {
105+ return err (
106+ 'No knowledge bases are bound to this agent. Skip this tool and try web_search or other sources.' ,
107+ ) ;
108+ }
109+
110+ if ( knowledge_id && ! allowedIds . includes ( knowledge_id ) ) {
111+ return err (
112+ `Knowledge ${ knowledge_id } not bound to this agent. Available: ${ allowedIds . join ( ', ' ) } . Omit knowledge_id to search all of them.` ,
113+ ) ;
64114 }
65115
116+ const targetIds = knowledge_id ? [ knowledge_id ] : allowedIds ;
117+
66118 try {
67- const result = await this . knowledgeService . query ( knowledge_id , query ) ;
68- return ok ( result ) ;
119+ if ( targetIds . length === 1 ) {
120+ const result = await this . knowledgeService . query ( targetIds [ 0 ] , query ) ;
121+ return ok ( result ) ;
122+ }
123+ // Multi-base search: per-base errors are surfaced inline so one broken
124+ // base doesn't sink the others. LLM sees a `results` array and picks
125+ // the relevant entry.
126+ const results = await Promise . all (
127+ targetIds . map ( async ( id ) => {
128+ try {
129+ const r = await this . knowledgeService . query ( id , query ) ;
130+ return { ...r , knowledge_id : id } ;
131+ } catch ( e ) {
132+ const message = e instanceof Error ? e . message : 'query failed' ;
133+ return { knowledge_id : id , error : message } ;
134+ }
135+ } ) ,
136+ ) ;
137+ return ok ( { results } ) ;
69138 } catch ( e ) {
70139 const message = e instanceof Error ? e . message : 'Knowledge query failed' ;
71140 this . logger . warn (
72- `query_knowledge failed for agent=${ callerAgentId } knowledge= ${ knowledge_id } : ${ message } ` ,
141+ `query_knowledge failed for agent=${ callerAgentId } : ${ message } ` ,
73142 ) ;
74143 return err ( message ) ;
75144 }
0 commit comments