|
| 1 | +"use client"; |
| 2 | + |
| 3 | +/* eslint-disable react/forbid-elements -- blog widgets use bespoke styled |
| 4 | + controls; the product design-system <Button> does not model them. */ |
| 5 | + |
| 6 | +import { useState } from "react"; |
| 7 | +import { fmt, useAnimatedNumber } from "./shared"; |
| 8 | + |
| 9 | +/** |
| 10 | + * The centerpiece: context cost is a 2×2, not a protocol property. One axis |
| 11 | + * picks the interface (CLI or MCP), the other picks the loading strategy |
| 12 | + * (on demand or everything up front). Flipping the interface barely moves the |
| 13 | + * number; flipping the loading strategy moves it ~20x. |
| 14 | + * |
| 15 | + * Figures are illustrative but anchored: the arXiv paper everyone cited |
| 16 | + * measured the official GitHub MCP server at 44 tools, eager clients resending |
| 17 | + * the whole catalog every request, and ~22k tokens for 27 built-in tool |
| 18 | + * schemas (~800 tokens per schema). |
| 19 | + */ |
| 20 | + |
| 21 | +type Interface_ = "cli" | "mcp"; |
| 22 | +type Loading = "lazy" | "eager"; |
| 23 | + |
| 24 | +const SYSTEM_TOK = 1200; |
| 25 | + |
| 26 | +type Segment = { readonly label: string; readonly tok: number; readonly flood?: boolean }; |
| 27 | + |
| 28 | +const CELLS: Record<Interface_, Record<Loading, ReadonlyArray<Segment>>> = { |
| 29 | + cli: { |
| 30 | + lazy: [{ label: "one bash tool definition", tok: 320 }], |
| 31 | + eager: [ |
| 32 | + { label: "one bash tool definition", tok: 320 }, |
| 33 | + { label: "--help for every gh subcommand, inlined", tok: 29800, flood: true }, |
| 34 | + ], |
| 35 | + }, |
| 36 | + mcp: { |
| 37 | + lazy: [{ label: "gateway pair: search tools + invoke tool", tok: 640 }], |
| 38 | + eager: [{ label: "44 tool schemas × ~800 tokens", tok: 35200, flood: true }], |
| 39 | + }, |
| 40 | +}; |
| 41 | + |
| 42 | +const cellTotal = (i: Interface_, l: Loading) => |
| 43 | + SYSTEM_TOK + CELLS[i][l].reduce((s, seg) => s + seg.tok, 0); |
| 44 | + |
| 45 | +const MAX_TOTAL = Math.max( |
| 46 | + cellTotal("cli", "eager"), |
| 47 | + cellTotal("mcp", "eager"), |
| 48 | + cellTotal("cli", "lazy"), |
| 49 | + cellTotal("mcp", "lazy"), |
| 50 | +); |
| 51 | + |
| 52 | +function Seg({ |
| 53 | + value, |
| 54 | + onPick, |
| 55 | + options, |
| 56 | + label, |
| 57 | +}: { |
| 58 | + readonly value: string; |
| 59 | + readonly onPick: (v: string) => void; |
| 60 | + readonly options: ReadonlyArray<{ readonly id: string; readonly label: string }>; |
| 61 | + readonly label: string; |
| 62 | +}) { |
| 63 | + return ( |
| 64 | + <div className="bw-axis"> |
| 65 | + <span className="bw-axis__label">{label}</span> |
| 66 | + <div className="bw-seg" role="group" aria-label={label}> |
| 67 | + {options.map((o) => ( |
| 68 | + <button |
| 69 | + key={o.id} |
| 70 | + type="button" |
| 71 | + className="bw-seg__btn" |
| 72 | + data-on={value === o.id ? "true" : undefined} |
| 73 | + aria-pressed={value === o.id} |
| 74 | + onClick={() => onPick(o.id)} |
| 75 | + > |
| 76 | + {o.label} |
| 77 | + </button> |
| 78 | + ))} |
| 79 | + </div> |
| 80 | + </div> |
| 81 | + ); |
| 82 | +} |
| 83 | + |
| 84 | +export function ContextFloodDemo() { |
| 85 | + const [iface, setIface] = useState<Interface_>("mcp"); |
| 86 | + const [loading, setLoading] = useState<Loading>("eager"); |
| 87 | + |
| 88 | + const segments: ReadonlyArray<Segment> = [ |
| 89 | + { label: "system prompt", tok: SYSTEM_TOK }, |
| 90 | + ...CELLS[iface][loading], |
| 91 | + ]; |
| 92 | + const total = cellTotal(iface, loading); |
| 93 | + const totalDisplay = useAnimatedNumber(total); |
| 94 | + const flooding = segments.some((s) => s.flood); |
| 95 | + |
| 96 | + return ( |
| 97 | + <div className="bw"> |
| 98 | + <p className="sr-only" aria-live="polite"> |
| 99 | + {iface === "cli" ? "CLI" : "MCP"} with tools loaded{" "} |
| 100 | + {loading === "lazy" ? "on demand" : "up front"}: about {fmt(total)} tokens spent before your |
| 101 | + first message. |
| 102 | + </p> |
| 103 | + |
| 104 | + <div className="bw-head bw-head--stack"> |
| 105 | + <Seg |
| 106 | + label="Interface" |
| 107 | + value={iface} |
| 108 | + onPick={(v) => setIface(v as Interface_)} |
| 109 | + options={[ |
| 110 | + { id: "cli", label: "CLI" }, |
| 111 | + { id: "mcp", label: "MCP server" }, |
| 112 | + ]} |
| 113 | + /> |
| 114 | + <Seg |
| 115 | + label="Tool loading" |
| 116 | + value={loading} |
| 117 | + onPick={(v) => setLoading(v as Loading)} |
| 118 | + options={[ |
| 119 | + { id: "lazy", label: "On demand" }, |
| 120 | + { id: "eager", label: "Everything up front" }, |
| 121 | + ]} |
| 122 | + /> |
| 123 | + </div> |
| 124 | + |
| 125 | + <div className="bw-flood"> |
| 126 | + <div className="bw-flood__rows"> |
| 127 | + {segments.map((s) => ( |
| 128 | + <div key={s.label} className="bw-flood__row"> |
| 129 | + <div className="bw-flood__meta"> |
| 130 | + <span className="bw-flood__name">{s.label}</span> |
| 131 | + <span className="bw-flood__tok">~{fmt(s.tok)} tok</span> |
| 132 | + </div> |
| 133 | + <div className="bw-flood__track"> |
| 134 | + <div |
| 135 | + className="bw-flood__fill" |
| 136 | + data-flood={s.flood ? "true" : undefined} |
| 137 | + style={{ width: `${Math.max(1.5, (s.tok / MAX_TOTAL) * 100)}%` }} |
| 138 | + /> |
| 139 | + </div> |
| 140 | + </div> |
| 141 | + ))} |
| 142 | + </div> |
| 143 | + <div className="bw-flood__total"> |
| 144 | + <div className="bw-flood__num">~{fmt(totalDisplay)}</div> |
| 145 | + <div className="bw-flood__cap">tokens before your first message</div> |
| 146 | + <div className="bw-badge" data-show={flooding ? "true" : undefined}> |
| 147 | + resent with every request |
| 148 | + </div> |
| 149 | + </div> |
| 150 | + </div> |
| 151 | + |
| 152 | + <div className="bw-map" role="group" aria-label="All four combinations"> |
| 153 | + {(["cli", "mcp"] as const).flatMap((i) => |
| 154 | + (["lazy", "eager"] as const).map((l) => ( |
| 155 | + <button |
| 156 | + key={`${i}-${l}`} |
| 157 | + type="button" |
| 158 | + className="bw-map__cell" |
| 159 | + data-on={i === iface && l === loading ? "true" : undefined} |
| 160 | + onClick={() => { |
| 161 | + setIface(i); |
| 162 | + setLoading(l); |
| 163 | + }} |
| 164 | + > |
| 165 | + <span className="bw-map__label"> |
| 166 | + {i === "cli" ? "CLI" : "MCP"} · {l === "lazy" ? "on demand" : "up front"} |
| 167 | + </span> |
| 168 | + <span className="bw-map__val">~{fmt(cellTotal(i, l))}</span> |
| 169 | + </button> |
| 170 | + )), |
| 171 | + )} |
| 172 | + </div> |
| 173 | + |
| 174 | + <div className="bw-foot"> |
| 175 | + Flip the interface: the number barely moves. Flip the loading strategy: ~20x. The bloat |
| 176 | + lives on one axis, and it is not the protocol axis. |
| 177 | + </div> |
| 178 | + </div> |
| 179 | + ); |
| 180 | +} |
0 commit comments