/ src / utils / mcpInstructionsDelta.ts
mcpInstructionsDelta.ts
  1  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
  2  import { logEvent } from '../services/analytics/index.js'
  3  import type {
  4    ConnectedMCPServer,
  5    MCPServerConnection,
  6  } from '../services/mcp/types.js'
  7  import type { Message } from '../types/message.js'
  8  import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js'
  9  
 10  export type McpInstructionsDelta = {
 11    /** Server names — for stateless-scan reconstruction. */
 12    addedNames: string[]
 13    /** Rendered "## {name}\n{instructions}" blocks for addedNames. */
 14    addedBlocks: string[]
 15    removedNames: string[]
 16  }
 17  
 18  /**
 19   * Client-authored instruction block to announce when a server connects,
 20   * in addition to (or instead of) the server's own `InitializeResult.instructions`.
 21   * Lets first-party servers (e.g., claude-in-chrome) carry client-side
 22   * context the server itself doesn't know about.
 23   */
 24  export type ClientSideInstruction = {
 25    serverName: string
 26    block: string
 27  }
 28  
 29  /**
 30   * True → announce MCP server instructions via persisted delta attachments.
 31   * False → prompts.ts keeps its DANGEROUS_uncachedSystemPromptSection
 32   * (rebuilt every turn; cache-busts on late connect).
 33   *
 34   * Env override for local testing: CLAUDE_CODE_MCP_INSTR_DELTA=true/false
 35   * wins over both ant bypass and the GrowthBook gate.
 36   */
 37  export function isMcpInstructionsDeltaEnabled(): boolean {
 38    if (isEnvTruthy(process.env.CLAUDE_CODE_MCP_INSTR_DELTA)) return true
 39    if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_MCP_INSTR_DELTA)) return false
 40    return (
 41      process.env.USER_TYPE === 'ant' ||
 42      getFeatureValue_CACHED_MAY_BE_STALE('tengu_basalt_3kr', false)
 43    )
 44  }
 45  
 46  /**
 47   * Diff the current set of connected MCP servers that have instructions
 48   * (server-authored via InitializeResult, or client-side synthesized)
 49   * against what's already been announced in this conversation. Null if
 50   * nothing changed.
 51   *
 52   * Instructions are immutable for the life of a connection (set once at
 53   * handshake), so the scan diffs on server NAME, not on content.
 54   */
 55  export function getMcpInstructionsDelta(
 56    mcpClients: MCPServerConnection[],
 57    messages: Message[],
 58    clientSideInstructions: ClientSideInstruction[],
 59  ): McpInstructionsDelta | null {
 60    const announced = new Set<string>()
 61    let attachmentCount = 0
 62    let midCount = 0
 63    for (const msg of messages) {
 64      if (msg.type !== 'attachment') continue
 65      attachmentCount++
 66      if (msg.attachment.type !== 'mcp_instructions_delta') continue
 67      midCount++
 68      for (const n of msg.attachment.addedNames) announced.add(n)
 69      for (const n of msg.attachment.removedNames) announced.delete(n)
 70    }
 71  
 72    const connected = mcpClients.filter(
 73      (c): c is ConnectedMCPServer => c.type === 'connected',
 74    )
 75    const connectedNames = new Set(connected.map(c => c.name))
 76  
 77    // Servers with instructions to announce (either channel). A server can
 78    // have both: server-authored instructions + a client-side block appended.
 79    const blocks = new Map<string, string>()
 80    for (const c of connected) {
 81      if (c.instructions) blocks.set(c.name, `## ${c.name}\n${c.instructions}`)
 82    }
 83    for (const ci of clientSideInstructions) {
 84      if (!connectedNames.has(ci.serverName)) continue
 85      const existing = blocks.get(ci.serverName)
 86      blocks.set(
 87        ci.serverName,
 88        existing
 89          ? `${existing}\n\n${ci.block}`
 90          : `## ${ci.serverName}\n${ci.block}`,
 91      )
 92    }
 93  
 94    const added: Array<{ name: string; block: string }> = []
 95    for (const [name, block] of blocks) {
 96      if (!announced.has(name)) added.push({ name, block })
 97    }
 98  
 99    // A previously-announced server that is no longer connected → removed.
100    // There is no "announced but now has no instructions" case for a still-
101    // connected server: InitializeResult is immutable, and client-side
102    // instruction gates are session-stable in practice. (/model can flip
103    // the model gate, but deferred_tools_delta has the same property and
104    // we treat history as historical — no retroactive retractions.)
105    const removed: string[] = []
106    for (const n of announced) {
107      if (!connectedNames.has(n)) removed.push(n)
108    }
109  
110    if (added.length === 0 && removed.length === 0) return null
111  
112    // Same diagnostic fields as tengu_deferred_tools_pool_change — same
113    // scan-fails-in-prod bug, same attachment persistence path.
114    logEvent('tengu_mcp_instructions_pool_change', {
115      addedCount: added.length,
116      removedCount: removed.length,
117      priorAnnouncedCount: announced.size,
118      clientSideCount: clientSideInstructions.length,
119      messagesLength: messages.length,
120      attachmentCount,
121      midCount,
122    })
123  
124    added.sort((a, b) => a.name.localeCompare(b.name))
125    return {
126      addedNames: added.map(a => a.name),
127      addedBlocks: added.map(a => a.block),
128      removedNames: removed.sort(),
129    }
130  }