import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import { logEvent } from '../services/analytics/index.js' import type { ConnectedMCPServer, MCPServerConnection, } from '../services/mcp/types.js' import type { Message } from '../types/message.js' import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js' export type McpInstructionsDelta = { /** Server names — for stateless-scan reconstruction. */ addedNames: string[] /** Rendered "## {name}\n{instructions}" blocks for addedNames. */ addedBlocks: string[] removedNames: string[] } /** * Client-authored instruction block to announce when a server connects, * in addition to (or instead of) the server's own `InitializeResult.instructions`. * Lets first-party servers (e.g., claude-in-chrome) carry client-side * context the server itself doesn't know about. */ export type ClientSideInstruction = { serverName: string block: string } /** * True → announce MCP server instructions via persisted delta attachments. * False → prompts.ts keeps its DANGEROUS_uncachedSystemPromptSection * (rebuilt every turn; cache-busts on late connect). * * Env override for local testing: CLAUDE_CODE_MCP_INSTR_DELTA=true/false * wins over both ant bypass and the GrowthBook gate. */ export function isMcpInstructionsDeltaEnabled(): boolean { if (isEnvTruthy(process.env.CLAUDE_CODE_MCP_INSTR_DELTA)) return true if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_MCP_INSTR_DELTA)) return false return ( process.env.USER_TYPE === 'ant' || getFeatureValue_CACHED_MAY_BE_STALE('tengu_basalt_3kr', false) ) } /** * Diff the current set of connected MCP servers that have instructions * (server-authored via InitializeResult, or client-side synthesized) * against what's already been announced in this conversation. Null if * nothing changed. * * Instructions are immutable for the life of a connection (set once at * handshake), so the scan diffs on server NAME, not on content. */ export function getMcpInstructionsDelta( mcpClients: MCPServerConnection[], messages: Message[], clientSideInstructions: ClientSideInstruction[], ): McpInstructionsDelta | null { const announced = new Set() let attachmentCount = 0 let midCount = 0 for (const msg of messages) { if (msg.type !== 'attachment') continue attachmentCount++ if (msg.attachment.type !== 'mcp_instructions_delta') continue midCount++ for (const n of msg.attachment.addedNames) announced.add(n) for (const n of msg.attachment.removedNames) announced.delete(n) } const connected = mcpClients.filter( (c): c is ConnectedMCPServer => c.type === 'connected', ) const connectedNames = new Set(connected.map(c => c.name)) // Servers with instructions to announce (either channel). A server can // have both: server-authored instructions + a client-side block appended. const blocks = new Map() for (const c of connected) { if (c.instructions) blocks.set(c.name, `## ${c.name}\n${c.instructions}`) } for (const ci of clientSideInstructions) { if (!connectedNames.has(ci.serverName)) continue const existing = blocks.get(ci.serverName) blocks.set( ci.serverName, existing ? `${existing}\n\n${ci.block}` : `## ${ci.serverName}\n${ci.block}`, ) } const added: Array<{ name: string; block: string }> = [] for (const [name, block] of blocks) { if (!announced.has(name)) added.push({ name, block }) } // A previously-announced server that is no longer connected → removed. // There is no "announced but now has no instructions" case for a still- // connected server: InitializeResult is immutable, and client-side // instruction gates are session-stable in practice. (/model can flip // the model gate, but deferred_tools_delta has the same property and // we treat history as historical — no retroactive retractions.) const removed: string[] = [] for (const n of announced) { if (!connectedNames.has(n)) removed.push(n) } if (added.length === 0 && removed.length === 0) return null // Same diagnostic fields as tengu_deferred_tools_pool_change — same // scan-fails-in-prod bug, same attachment persistence path. logEvent('tengu_mcp_instructions_pool_change', { addedCount: added.length, removedCount: removed.length, priorAnnouncedCount: announced.size, clientSideCount: clientSideInstructions.length, messagesLength: messages.length, attachmentCount, midCount, }) added.sort((a, b) => a.name.localeCompare(b.name)) return { addedNames: added.map(a => a.name), addedBlocks: added.map(a => a.block), removedNames: removed.sort(), } }