feat(ai-chat): per-MCP-server instructions in the agent system prompt (#180)
Admins can now give each EXTERNAL MCP server a free-text instruction ("how/
when to use this server's tools") that the agent receives in its SYSTEM
PROMPT next to the tool descriptions — porting the built-in SERVER_INSTRUCTIONS
idea to admin-configured servers. Trusted, admin-authored text (like a system
prompt); NON-secret, so unlike headersEnc it IS returned in views/forms.
- Migration: nullable `instructions text` on ai_mcp_servers (old rows = null =
no guidance). Table type + repo insert/update (blank/whitespace -> null via
blankToNull). DTO `@MaxLength(4000)`. Service threads it through
McpServerView/toView.
- mcp-clients: `McpServerInstruction { serverName, toolPrefix, instructions }`
threaded through the toolset/cache/lease. Guidance is built ONLY for a server
that actually connected AND contributed >=1 callable tool (the allowlist may
filter all of them out) AND has non-blank text — so a guide never appears for
tools the agent cannot call. Cached with the toolset, so an edit is picked up
next turn via the existing CRUD cache invalidation.
- System prompt: `buildMcpToolingBlock` renders an <mcp_tooling> block INSIDE
the safety sandwich (after context, before the trailing SAFETY_FRAMEWORK) so
it informs tool choice but cannot override the rules; each section is headed
by the server's `prefix_*` namespace. Empty/blank -> block omitted. The
caller (ai-chat.service) now builds the external toolset BEFORE the prompt and
passes external.instructions; client-handle lifecycle (close-once) unchanged.
- Client: instructions field in types + a Textarea (autosize, maxLength 4000)
in the MCP-server form with a namespace-prefix hint; i18n (en/ru).
Tests across every layer (prompt block placement + both SAFETY copies; view
blank->null; buildEntry includes guidance only for connected+>=1-tool+non-blank;
DTO MaxLength; repo + integration round-trip; service wiring). Delegated impl
reviewed (APPROVE); applied the import-type follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -92,3 +92,84 @@ describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () =>
|
||||
expect(healed?.toolAllowlist).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* AiMcpServerRepo `instructions` text round-trip (#180). The column is plain
|
||||
* text (no jsonb); blank/whitespace is normalized to null on both insert and
|
||||
* update so an empty guide is never persisted.
|
||||
*/
|
||||
describe('AiMcpServerRepo instructions round-trip [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let repo: AiMcpServerRepo;
|
||||
let ws: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
repo = new AiMcpServerRepo(db as any);
|
||||
ws = (await createWorkspace(db)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
it('insert stores trimmed non-blank instructions and reads them back', async () => {
|
||||
const row = await repo.insert({
|
||||
workspaceId: ws,
|
||||
name: `srv-${randomUUID()}`,
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
instructions: ' Use search for fresh facts. ',
|
||||
});
|
||||
expect((await repo.findById(row.id, ws))?.instructions).toBe(
|
||||
'Use search for fresh facts.',
|
||||
);
|
||||
});
|
||||
|
||||
it('insert normalizes blank/whitespace instructions to null', async () => {
|
||||
const row = await repo.insert({
|
||||
workspaceId: ws,
|
||||
name: `srv-${randomUUID()}`,
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
instructions: ' ',
|
||||
});
|
||||
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
|
||||
});
|
||||
|
||||
it('insert with omitted instructions stores null', async () => {
|
||||
const row = await repo.insert({
|
||||
workspaceId: ws,
|
||||
name: `srv-${randomUUID()}`,
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
});
|
||||
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
|
||||
});
|
||||
|
||||
it('update sets, clears (blank => null), and leaves unchanged when absent', async () => {
|
||||
const row = await repo.insert({
|
||||
workspaceId: ws,
|
||||
name: `srv-${randomUUID()}`,
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
instructions: 'initial guide',
|
||||
});
|
||||
|
||||
// Set a new value.
|
||||
await repo.update(row.id, ws, { instructions: 'updated guide' });
|
||||
expect((await repo.findById(row.id, ws))?.instructions).toBe(
|
||||
'updated guide',
|
||||
);
|
||||
|
||||
// Absent in the patch => unchanged.
|
||||
await repo.update(row.id, ws, { name: 'renamed' });
|
||||
expect((await repo.findById(row.id, ws))?.instructions).toBe(
|
||||
'updated guide',
|
||||
);
|
||||
|
||||
// Blank => cleared to null.
|
||||
await repo.update(row.id, ws, { instructions: ' ' });
|
||||
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user