Skip to content

Commit 76d287c

Browse files
committed
feat(mcp): per-caller dynamic tool description + auto-search all bound
KBs
1 parent d0b46be commit 76d287c

4 files changed

Lines changed: 145 additions & 18 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// @scope:api
2+
// @slice:mcp
3+
// @layer:application
4+
// @type:interface
5+
6+
import type { Request } from 'express';
7+
8+
/**
9+
* Tools may implement this to enrich their MCP description with per-request
10+
* context (e.g. listing data the caller is authorised to access). The MCP
11+
* tools/list handler calls describeForRequest on every resolved tool that
12+
* implements this; a non-null return overrides the static description from
13+
* the @Tool decorator, null falls back to the static one.
14+
*/
15+
export interface IDynamicallyDescribedTool {
16+
describeForRequest(httpRequest: Request): Promise<string | null>;
17+
}
18+
19+
export function isDynamicallyDescribed(
20+
value: unknown,
21+
): value is IDynamicallyDescribedTool {
22+
if (typeof value !== 'object' || value === null) return false;
23+
const obj = value as Record<string, unknown>;
24+
return typeof obj.describeForRequest === 'function';
25+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './mcp-options.interface';
22
export * from './mcp-tool.interface';
33
export * from './http-adapter.interface';
4+
export * from './dynamic-description.interface';

api/src/slices/mcp/services/handlers/mcp-tools.handler.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Request } from 'express';
1111
import { zodToJsonSchema } from 'zod-to-json-schema';
1212
import { McpRegistryService } from '../mcp-registry.service';
1313
import { McpHandlerBase } from './mcp-handler.base';
14+
import { isDynamicallyDescribed } from '../../interfaces/dynamic-description.interface';
1415

1516
@Injectable({ scope: Scope.REQUEST })
1617
export class McpToolsHandler extends McpHandlerBase {
@@ -27,14 +28,45 @@ export class McpToolsHandler extends McpHandlerBase {
2728
}
2829

2930
registerHandlers(mcpServer: McpServer, httpRequest: Request) {
30-
mcpServer.server.setRequestHandler(ListToolsRequestSchema, () => {
31-
const tools = this.registry.getTools().map((tool) => ({
32-
name: tool.metadata.name,
33-
description: tool.metadata.description,
34-
inputSchema: tool.metadata.parameters
35-
? this.convertZodToJsonSchema(tool.metadata.parameters)
36-
: undefined,
37-
}));
31+
mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
32+
const contextId = ContextIdFactory.getByRequest(httpRequest);
33+
this.moduleRef.registerRequestByContextId(httpRequest, contextId);
34+
35+
const tools = await Promise.all(
36+
this.registry.getTools().map(async (tool) => {
37+
let description = tool.metadata.description;
38+
// Tools may opt into per-caller descriptions by implementing
39+
// IDynamicallyDescribedTool. Failure to resolve or describe falls
40+
// back to the static decorator description so a broken tool can't
41+
// hide the rest of the list.
42+
try {
43+
const instance = await this.moduleRef.resolve(
44+
tool.providerClass,
45+
contextId,
46+
{ strict: false },
47+
);
48+
if (isDynamicallyDescribed(instance)) {
49+
const dyn = await instance.describeForRequest(httpRequest);
50+
if (typeof dyn === 'string' && dyn.length > 0) {
51+
description = dyn;
52+
}
53+
}
54+
} catch (e) {
55+
this.logger.debug(
56+
`describeForRequest failed for ${tool.metadata.name}: ${
57+
e instanceof Error ? e.message : String(e)
58+
}`,
59+
);
60+
}
61+
return {
62+
name: tool.metadata.name,
63+
description,
64+
inputSchema: tool.metadata.parameters
65+
? this.convertZodToJsonSchema(tool.metadata.parameters)
66+
: undefined,
67+
};
68+
}),
69+
);
3870

3971
return {
4072
tools,

api/src/slices/reins/knowledge/knowledge.tool.ts

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { Request } from 'express';
55
import { IAuthTokenPayload } from '#/user/auth/domain';
66
import { IAgentGateway } from '#/agent/agent/domain';
77
import { ITemplateGateway } from '#/agent/template/domain';
8+
import { IDynamicallyDescribedTool } from '#/mcp/interfaces/dynamic-description.interface';
89
import { 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

1015
interface 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

Comments
 (0)