| 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 |