mcpInstructionsDelta.ts 4.6 KB

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