131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
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(),
|
|
}
|
|
}
|