| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130 |
- 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<string>()
- 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<string, string>()
- 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(),
- }
- }
|