项目文件夹

文件
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

862 行
29 KiB
Go

package mcp
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
)
// defaultMaxBytes is the upper bound on a single tool response.
// Empirically the agent harness (claude-code at the time of writing)
// starts spilling responses to a side file around ~50 KB of wire
// text. The MCP `tools/call` envelope wraps our payload as
// `{"content":[{"type":"text","text":"<payload>"}]}`, then JSON-RPC
// itself adds one more layer of escaping when serialised across the
// stdio bridge — round-trip overhead averages 25–30 % on top of the
// raw payload bytes for our shapes. Capping the inner payload at
// 40 KB keeps the wire form comfortably under the 50 KB threshold,
// leaving headroom for the rare row that has unusually heavy meta.
//
// Lower this number cautiously: every drop here means more rows get
// trimmed across every list-shaped tool. Raise it only after
// re-measuring the harness threshold; "no spill" beats "more rows"
// because spilled output forces a cold re-read for the agent.
const defaultMaxBytes = 40_000
// avgBytesPerToken is the calibration constant used to translate the
// `max_tokens` parameter into an effective byte cap. Empirically:
//
// - dense JSON / TOON rows (`{"id":"...","kind":"function",...}`)
// tokenise at ~3.0–3.4 chars per BPE token on cl100k_base. The
// punctuation density (quotes, colons, braces) drags the ratio
// down vs. natural English.
// - GCX1 rows (`id\tkind\tname\t...`) tokenise at ~4.0–4.5 chars
// per token because tabs collapse into single tokens and the
// identifier-heavy payload tokenises more efficiently than JSON.
// - smart_context / get_editing_context source-bearing payloads
// average ~3.6 chars per token because the source lines push
// the ratio toward English text.
//
// 3.5 is the safe midpoint: it slightly UNDER-counts tokens for JSON
// (so the resulting byte cap is tighter than strictly necessary —
// erring on the side of "fits the budget") and approximately matches
// the GCX row case. Token estimation is necessarily imperfect across
// model tokenisers; the budget guard's job is to ride a safe margin,
// not to count exactly. A caller who needs precise token-counting
// should run their own tokenizer post-hoc.
const avgBytesPerToken = 3.5
// tokenBudgetParamDescription is the canonical description string
// for the `max_tokens` parameter wired onto every list-shaped tool.
// Centralised so the wording stays consistent across the registry.
const tokenBudgetParamDescription = "Cap the marshaled response at approximately this many tokens (3.5 bytes/token heuristic; composable with max_bytes — tighter wins). Use when you have a token budget instead of a byte budget. Omit for no cap; pass 0 to opt out explicitly."
// tokensToBytes converts a token budget into a byte cap using the
// avgBytesPerToken ratio. Returns 0 for non-positive inputs so
// `max_tokens: 0` is honoured as "opt out" with the same semantics
// as `max_bytes: 0`.
func tokensToBytes(maxTokens int) int {
if maxTokens <= 0 {
return 0
}
return int(float64(maxTokens) * avgBytesPerToken)
}
// bytesToTokens is the inverse of tokensToBytes. Used to render a
// human-readable token-equivalent on the truncation meta so callers
// see "kept ~N tokens" alongside the raw byte cap. Returns 0 for
// non-positive inputs.
func bytesToTokens(byteCount int) int {
if byteCount <= 0 {
return 0
}
return int(float64(byteCount) / avgBytesPerToken)
}
// numArgInt extracts an integer arg from the request arguments map.
// JSON numbers arrive as float64; some test harnesses pass int.
// Returns (value, present). When the arg is the wrong type, returns
// (0, true) so callers can distinguish "absent" from "malformed
// zero" — important because a zero value is meaningful here (it is
// the opt-out signal for both max_bytes and max_tokens).
func numArgInt(args map[string]any, key string) (int, bool) {
raw, ok := args[key]
if !ok {
return 0, false
}
switch n := raw.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
default:
return 0, true
}
}
// budgetTruncatedKey is the meta flag appended to a payload trimmed
// by applyBudget so callers can branch on truncation without scanning
// for shape-specific signals. Mirrored on the GCX path through the
// `truncated_by_budget=true` header meta.
const budgetTruncatedKey = "_truncated_by_budget"
// applyBudget enforces a marshaled-size cap on payload by trimming
// top-level lists in longest-first order until the result fits.
// Returns the (possibly trimmed) payload and a flag indicating
// whether trimming happened. The trimmed payload carries inline
// metadata so callers can surface "narrow your filter" hints:
//
// - _truncated_by_budget: true
// - _max_returned_<field>: N
// - _original_count_<field>: M (one pair per trimmed list)
//
// Multi-list payloads (`nodes` + `edges` for get_file_summary, etc.)
// are trimmed iteratively: the longest list is binary-searched first;
// if the result still exceeds the cap, the next-longest list is
// trimmed too, and so on. We stop when the cap is met or every list
// has been emptied (the second is a degraded fallback — extremely
// large per-row payloads can still exceed the budget with zero
// rows; that case is rare and the MCP transport's spill fallback
// handles it).
//
// Best-effort: if no top-level list is found in the marshaled JSON,
// the payload is returned unchanged.
func applyBudget(payload any, maxBytes int) (any, bool) {
if maxBytes <= 0 || payload == nil {
return payload, false
}
bytes, err := json.Marshal(payload)
if err != nil || len(bytes) <= maxBytes {
return payload, false
}
// Re-shape into a generic map so we can manipulate any payload
// type uniformly (struct, *query.SubGraph, map[string]any). The
// JSON round-trip costs one extra alloc — cheap given we already
// know we are over budget.
var generic map[string]any
if err := json.Unmarshal(bytes, &generic); err != nil {
return payload, false
}
trimmed := false
// Cap iteration count by the number of distinct top-level slices
// so we cannot loop forever on a payload whose non-list scalars
// alone exceed the cap.
for pass := 0; pass < 8; pass++ {
longestKey := findLongestSliceKey(generic)
if longestKey == "" {
break
}
longest := genericSlice(generic, longestKey)
if len(longest) == 0 {
// Already-empty list cannot shrink further; pick the
// next-longest in the next iteration. Mark this list
// completed by removing it from candidate set via length 0.
break
}
originalLen := len(longest)
// Binary search for the largest prefix that fits.
lo, hi := 0, originalLen
for lo < hi {
mid := (lo + hi + 1) / 2
generic[longestKey] = longest[:mid]
generic[budgetTruncatedKey] = true
generic["_max_returned_"+longestKey] = mid
generic["_original_count_"+longestKey] = originalLen
candidate, err := json.Marshal(generic)
if err != nil {
break
}
if len(candidate) <= maxBytes {
lo = mid
} else {
hi = mid - 1
}
}
generic[longestKey] = longest[:lo]
generic[budgetTruncatedKey] = true
generic["_max_returned_"+longestKey] = lo
generic["_original_count_"+longestKey] = originalLen
trimmed = true
final, _ := json.Marshal(generic)
if len(final) <= maxBytes {
return generic, true
}
}
if !trimmed {
// No slice candidate was actually trimmed — return the
// original payload type intact so callers comparing against
// concrete Go types (int vs json's float64, etc.) keep
// working unchanged.
return payload, false
}
return generic, trimmed
}
// findLongestSliceKey returns the top-level field name whose value is
// the longest []any. Empty string when no slices are present. Used by
// applyBudget to pick the trimming target without per-tool config.
func findLongestSliceKey(m map[string]any) string {
var key string
maxLen := 0
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// Stable iteration so ties resolve deterministically.
sort.Strings(keys)
for _, k := range keys {
arr, ok := m[k].([]any)
if !ok {
continue
}
if len(arr) > maxLen {
maxLen = len(arr)
key = k
}
}
return key
}
func genericSlice(m map[string]any, key string) []any {
if arr, ok := m[key].([]any); ok {
return arr
}
return nil
}
// applyFieldsFilter returns a copy of payload with only the fields
// listed in `fields` retained on each list element (and on top-level
// scalar fields). Empty `fields` returns payload unchanged. Designed
// for sparse fieldsets — the caller asks for `id,line` and gets only
// those keys, dropping verbose `meta`, `doc`, etc.
//
// Filtering happens after the budget guard so a sparse request fits
// even more rows under the same cap.
func applyFieldsFilter(payload any, fields []string) any {
if len(fields) == 0 || payload == nil {
return payload
}
keep := make(map[string]bool, len(fields))
for _, f := range fields {
f = strings.TrimSpace(f)
if f != "" {
keep[f] = true
}
}
if len(keep) == 0 {
return payload
}
// Round-trip through JSON for the same uniform-shape reason as
// applyBudget. This also mirrors the GCX/TOON pipeline so the
// fields filter behaves identically across formats.
bytes, err := json.Marshal(payload)
if err != nil {
return payload
}
var generic map[string]any
if err := json.Unmarshal(bytes, &generic); err != nil {
return payload
}
for k, v := range generic {
arr, ok := v.([]any)
if !ok {
continue
}
filtered := make([]any, 0, len(arr))
for _, row := range arr {
rowMap, ok := row.(map[string]any)
if !ok {
filtered = append(filtered, row)
continue
}
out := make(map[string]any, len(keep))
for f := range keep {
if val, ok := rowMap[f]; ok {
out[f] = val
}
}
filtered = append(filtered, out)
}
generic[k] = filtered
}
return generic
}
// parseFields splits the comma-separated `fields` arg into a clean
// slice. Whitespace is stripped; empty tokens are dropped.
func parseFields(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// trimGCXBytes shrinks a GCX1 payload that exceeds maxBytes by
// dropping rows from the tail of the LAST section, leaving the
// header and any earlier sections intact. Each GCX row ends at `\n`
// (embedded newlines in field values are escaped to a literal `\n`
// sequence in the wire format, so a raw byte 0x0A unambiguously
// marks a row boundary), which makes byte-level trimming safe and
// cheap. Multi-section payloads — flow_between, taint_paths,
// contracts.check, get_editing_context, smart_context — start each
// section with a fresh `GCX1 tool=` header line; we trim only the
// last section because earlier sections usually carry the summary
// metadata the caller needs to interpret what was dropped.
//
// A trailing comment line records the truncation:
//
// # truncated_by_budget=true original_rows=N kept_rows=K
//
// Agents that decode GCX with `@gortex/wire` see this as a comment
// and skip it; agents reading the raw text get a clear hint.
//
// Returns the trimmed payload (always ≤ maxBytes when there's at
// least one droppable row) and a flag indicating whether trimming
// occurred. Unchanged when payload was already under the cap.
func trimGCXBytes(payload []byte, maxBytes int) ([]byte, bool) {
if maxBytes <= 0 || len(payload) <= maxBytes {
return payload, false
}
// Locate the last section header (`GCX1 tool=`). The first
// section header is always at offset 0; subsequent headers start
// after a `\n` and begin with `GCX1`. We scan to find the last
// one so single-section payloads (the common case) work without
// branching.
const tag = "GCX1 tool="
lastSectionStart := 0
for i := 0; i+len(tag) <= len(payload); i++ {
if i > 0 && payload[i-1] != '\n' {
continue
}
if string(payload[i:i+len(tag)]) == tag {
lastSectionStart = i
}
}
// Find the end of the last section's header line (first `\n`
// after lastSectionStart). Header is preserved verbatim — we
// never trim it.
headerEnd := lastSectionStart
for headerEnd < len(payload) && payload[headerEnd] != '\n' {
headerEnd++
}
if headerEnd >= len(payload) {
return payload, false
}
headerEnd++ // skip past `\n`
// Walk row boundaries from the tail. Each row ends at `\n` that
// is NOT followed by a header line. We collect the cumulative
// byte offset of every row terminator within the last section
// so we can binary-truncate at the largest prefix that fits.
rowEnds := make([]int, 0, 64)
originalRowCount := 0
for i := headerEnd; i < len(payload); i++ {
if payload[i] != '\n' {
continue
}
// Skip comment-line endings (preserve all comments — they
// carry tool-author hints) and section-boundary newlines.
// Comments start with `#`. Section boundary detection: the
// next byte after the trailing `\n` is the start of `GCX1`.
// We never split across that boundary; lastSectionStart is
// the actual upper bound for our slice anyway.
rowEnds = append(rowEnds, i+1)
// Increment row count only for non-comment rows so the
// trailing meta is faithful. Find the start of this row.
rowStart := headerEnd
if len(rowEnds) > 1 {
rowStart = rowEnds[len(rowEnds)-2]
}
if rowStart < len(payload) && payload[rowStart] != '#' {
originalRowCount++
}
}
if len(rowEnds) == 0 {
return payload, false
}
// Build the truncation comment first so we can subtract its
// length from the budget when deciding how many rows to keep.
commentTemplate := "# truncated_by_budget=true original_rows=%d kept_rows=%d\n"
// We don't yet know kept_rows. Use a pessimistic placeholder:
// the comment with the max digit count we'll need.
maxComment := []byte(fmt.Sprintf(commentTemplate, originalRowCount, originalRowCount))
roomForRows := maxBytes - len(maxComment)
if roomForRows < headerEnd {
// Header alone (plus the truncation comment) exceeds the
// cap. Return header + comment; pathological case for
// extremely tight caps but keeps the response valid GCX.
out := make([]byte, 0, headerEnd+len(maxComment))
out = append(out, payload[:headerEnd]...)
out = append(out, []byte(fmt.Sprintf(commentTemplate, originalRowCount, 0))...)
return out, true
}
// Find the largest k such that payload[:rowEnds[k-1]] fits in
// roomForRows. Linear scan from the tail is O(rows) and rows are
// already in ascending offset order; we walk forward picking the
// last row whose end is ≤ roomForRows.
keep := 0
for i, end := range rowEnds {
if end > roomForRows {
break
}
keep = i + 1
}
// Floor: when the input had any rows but every row crosses the
// cap, keep one anyway. A response with zero rows + a
// truncated_by_budget marker is uninformative — the caller can't
// see the row shape, can't decide whether to re-issue with a
// tighter scope, and can't even tell which fields are populated.
// One overflowing row tells them more than zero. The overshoot is
// bounded (one row's worth of bytes) and the truncation comment
// rides on the response so downstream parsers know we overran.
if keep == 0 && len(rowEnds) > 0 {
keep = 1
}
// Compute kept_rows = non-comment rows in the prefix [0, keep).
keptRows := 0
rowStart := headerEnd
for i := 0; i < keep; i++ {
if rowStart < len(payload) && payload[rowStart] != '#' {
keptRows++
}
rowStart = rowEnds[i]
}
out := make([]byte, 0, maxBytes)
if keep == 0 {
out = append(out, payload[:headerEnd]...)
} else {
out = append(out, payload[:rowEnds[keep-1]]...)
}
out = append(out, []byte(fmt.Sprintf(commentTemplate, originalRowCount, keptRows))...)
return out, true
}
// effectiveBudget resolves the per-call byte budget. Budget-by-default
// — every list-shaped tool runs through graceful degradation so the
// agent gets a usable in-band response instead of a transport spill
// that the model learns to route around. Resolution order:
//
// - `max_bytes` and / or `max_tokens` set explicitly: the tighter
// cap wins. `max_tokens` is converted via tokensToBytes and then
// min-merged with `max_bytes`. Passing 0 (or negative) on either
// axis opts OUT of that axis; opting out of one axis still
// respects the other. Opting out of both yields no cap.
// - `paginate: true`: shorthand for "I'll follow next_cursor; cap
// each page at the project default". Same effective budget as
// the default, but advertises the caller's iteration intent.
// - Nothing set: the project default. Spill becomes a true edge
// case rather than the routine outcome on real-world payloads.
//
// The opt-out is intentional friction: agents that need exhaustive
// data can pass `max_bytes: 0` (or `max_tokens: 0`), but the default
// prefers a partial, inline answer over a spilled file the agent has
// to re-read.
func effectiveBudget(req mcp.CallToolRequest) int {
args := req.GetArguments()
rawBytes, bytesPresent := numArgInt(args, "max_bytes")
rawTokens, tokensPresent := numArgInt(args, "max_tokens")
if !bytesPresent && !tokensPresent {
return defaultMaxBytes
}
// Per-axis opt-out: a non-positive value means "skip THIS axis".
bytesOptOut := bytesPresent && rawBytes <= 0
tokensOptOut := tokensPresent && rawTokens <= 0
// Both axes opted out → no cap.
if bytesPresent && tokensPresent && bytesOptOut && tokensOptOut {
return 0
}
// Only one axis present and it opted out → no cap.
if bytesPresent && !tokensPresent && bytesOptOut {
return 0
}
if tokensPresent && !bytesPresent && tokensOptOut {
return 0
}
tokensBytes := tokensToBytes(rawTokens) // 0 when token axis absent or opt-out
switch {
case bytesPresent && tokensPresent:
switch {
case bytesOptOut:
return tokensBytes
case tokensOptOut:
return rawBytes
case rawBytes < tokensBytes:
return rawBytes
default:
return tokensBytes
}
case bytesPresent:
return rawBytes
default: // tokensPresent
return tokensBytes
}
}
// budgetSource describes which arg drove the resolved byte cap. Used
// purely to decorate truncation metadata — the cap value itself is
// already returned by effectiveBudget. "none" means no cap was
// applied (default budget or explicit opt-out); the other values
// match the parameter name that won the min-merge.
type budgetSource string
const (
budgetSourceNone budgetSource = "none"
budgetSourceBytes budgetSource = "max_bytes"
budgetSourceTokens budgetSource = "max_tokens"
budgetSourceDefault budgetSource = "default"
)
// resolveBudgetSource reports which axis was the tighter constraint
// for this request. Mirrors effectiveBudget's resolution order but
// returns the *origin* rather than the value. When max_tokens drove
// the cap, callers can surface that in truncation meta so the agent
// sees "trimmed because of your max_tokens=N" instead of a generic
// "too big" hint. Returns budgetSourceDefault when no caller arg was
// present and the project default applied.
func resolveBudgetSource(req mcp.CallToolRequest) (budgetSource, int) {
args := req.GetArguments()
rawBytes, bytesPresent := numArgInt(args, "max_bytes")
rawTokens, tokensPresent := numArgInt(args, "max_tokens")
if !bytesPresent && !tokensPresent {
return budgetSourceDefault, 0
}
bytesOptOut := bytesPresent && rawBytes <= 0
tokensOptOut := tokensPresent && rawTokens <= 0
if bytesPresent && tokensPresent && bytesOptOut && tokensOptOut {
return budgetSourceNone, 0
}
if bytesPresent && !tokensPresent && bytesOptOut {
return budgetSourceNone, 0
}
if tokensPresent && !bytesPresent && tokensOptOut {
return budgetSourceNone, 0
}
tokensBytes := tokensToBytes(rawTokens)
switch {
case bytesPresent && tokensPresent:
switch {
case bytesOptOut:
return budgetSourceTokens, rawTokens
case tokensOptOut:
return budgetSourceBytes, rawBytes
case rawBytes < tokensBytes:
return budgetSourceBytes, rawBytes
default:
return budgetSourceTokens, rawTokens
}
case bytesPresent:
return budgetSourceBytes, rawBytes
default:
return budgetSourceTokens, rawTokens
}
}
// decorateTokenBudgetJSON folds caller-supplied max_tokens info into
// a payload's truncation metadata. Only mutates when (a) max_tokens
// was supplied and (b) the budget guard actually fired — under both
// conditions a `_max_tokens` and (when tokens drove the cap) a
// `_truncated_by_tokens` marker ride alongside the existing
// `_truncated_by_budget` flag. Callers without max_tokens see the
// unchanged byte-only meta.
//
// Best-effort: payload that isn't a map[string]any is returned
// untouched (no shape to decorate).
func decorateTokenBudgetJSON(payload any, req mcp.CallToolRequest) any {
if payload == nil {
return payload
}
m, ok := payload.(map[string]any)
if !ok {
return payload
}
// Only decorate when the budget actually fired.
flag, hasFlag := m[budgetTruncatedKey].(bool)
if !hasFlag || !flag {
return payload
}
source, value := resolveBudgetSource(req)
if source == budgetSourceTokens && value > 0 {
m["_max_tokens"] = value
m["_truncated_by_tokens"] = true
return m
}
// Even when bytes drove the cap, surface a tokens-equivalent if
// max_tokens was supplied so the agent can see what fraction of
// its token budget was actually used.
if rawTokens, present := numArgInt(req.GetArguments(), "max_tokens"); present && rawTokens > 0 {
m["_max_tokens"] = rawTokens
}
return m
}
// decorateTokenBudgetGCX appends a trailing comment to a GCX payload
// recording the max_tokens annotation. Only fires when max_tokens
// was supplied AND the payload already shows the byte-trim
// `# truncated_by_budget=true` marker — otherwise we'd spam every
// response with budget metadata. The decoder (@gortex/wire, gcx-go)
// treats trailing comments as no-ops, so the appended line is
// non-invasive.
func decorateTokenBudgetGCX(payload []byte, req mcp.CallToolRequest) []byte {
if len(payload) == 0 {
return payload
}
// Only decorate when the GCX trim path already fired.
if !bytes.Contains(payload, []byte("# truncated_by_budget=true")) {
return payload
}
rawTokens, present := numArgInt(req.GetArguments(), "max_tokens")
if !present || rawTokens <= 0 {
return payload
}
source, _ := resolveBudgetSource(req)
extra := fmt.Sprintf("# max_tokens=%d", rawTokens)
if source == budgetSourceTokens {
extra += " truncated_by_tokens=true"
}
extra += "\n"
// Avoid duplicate decoration on retries that re-trim an already-
// trimmed payload (cheap idempotency).
if bytes.Contains(payload, []byte(fmt.Sprintf("max_tokens=%d", rawTokens))) {
return payload
}
return append(payload, []byte(extra)...)
}
// DegradeShape registers a per-tool graceful-degradation policy.
// The cascade applied by applyDegradation when a payload exceeds the
// budget runs in priority order: meta-strip first (cheapest signal
// to drop), then tier-3 row drops, then tier-2 row drops, then
// finally a longest-list tail-trim as the last-resort fallback.
//
// The TierFunc is invoked per row. Lower numbers mean "keep" — 1 is
// the must-keep tier, 2 is dropped second, 3 is dropped first.
// Rows whose row-map shape doesn't fit the policy (e.g. a non-map
// value) default to tier 1 (kept) so the policy can never accidentally
// strip a payload to nothing.
type DegradeShape struct {
// MetaStrip lists the keys to remove from each list-row before
// any row drops. Use this for high-bytes / low-signal columns
// like `doc`, `signature` body, raw `meta` blobs.
MetaStrip []string
// TierFunc returns the priority tier for a row (1 = keep, 2 =
// drop second, 3 = drop first). Implementations typically read
// `row["kind"]` and switch on the value.
TierFunc func(row map[string]any) int
}
// degradeShapes is the registry consulted by respondJSONOrTOON when
// a payload needs trimming. Per-tool shapes live next to their
// handlers in init() blocks, so the policy and the data shape stay
// co-located.
var degradeShapes = map[string]DegradeShape{}
// registerDegradeShape installs a per-tool policy. Idempotent — a
// re-register replaces the previous entry, so adapter packages can
// override the default policy if their tool variant needs different
// priorities.
func registerDegradeShape(toolName string, shape DegradeShape) {
degradeShapes[toolName] = shape
}
// applyDegradation runs the priority-aware trim cascade for a tool
// whose handler registered a DegradeShape. Steps:
//
// 1. Marshal payload to JSON; under cap → return as-is.
// 2. Strip MetaStrip keys from every list row; under cap → return
// with `_meta_stripped` flag.
// 3. Drop rows where TierFunc == 3 across every list; under cap →
// return with `_dropped_tier_3_<key>` counters.
// 4. Drop rows where TierFunc == 2; under cap → return with
// `_dropped_tier_2_<key>` counters.
// 5. Fall through to applyBudget (longest-list tail-trim) on
// whatever survives, marking `_truncated_by_budget`.
//
// Each escape adds metadata so the agent sees what was dropped at
// which step. The trim is monotone: every step either fits the cap
// (return) or progresses to a more aggressive step.
func applyDegradation(payload any, shape DegradeShape, maxBytes int) (any, bool) {
if maxBytes <= 0 || payload == nil {
return payload, false
}
if shape.TierFunc == nil && len(shape.MetaStrip) == 0 {
return applyBudget(payload, maxBytes)
}
bytes, err := json.Marshal(payload)
if err != nil || len(bytes) <= maxBytes {
return payload, false
}
var generic map[string]any
if err := json.Unmarshal(bytes, &generic); err != nil {
return payload, false
}
// Step 1: strip verbose meta keys from every row.
if len(shape.MetaStrip) > 0 {
stripped := false
for k, v := range generic {
arr, ok := v.([]any)
if !ok {
continue
}
for i, row := range arr {
rowMap, ok := row.(map[string]any)
if !ok {
continue
}
for _, ms := range shape.MetaStrip {
if _, has := rowMap[ms]; has {
delete(rowMap, ms)
stripped = true
}
}
arr[i] = rowMap
}
generic[k] = arr
}
if stripped {
if size, _ := json.Marshal(generic); len(size) <= maxBytes {
generic[budgetTruncatedKey] = true
generic["_meta_stripped"] = shape.MetaStrip
return generic, true
}
}
}
// Step 2: drop tier-3 rows, then tier-2 rows.
if shape.TierFunc != nil {
for tier := 3; tier >= 2; tier-- {
anyDropped := false
for k, v := range generic {
arr, ok := v.([]any)
if !ok {
continue
}
kept := make([]any, 0, len(arr))
originalLen := len(arr)
droppedCount := 0
for _, row := range arr {
rowMap, ok := row.(map[string]any)
if !ok {
kept = append(kept, row)
continue
}
if shape.TierFunc(rowMap) >= tier {
droppedCount++
continue
}
kept = append(kept, row)
}
if droppedCount > 0 {
generic[k] = kept
generic[fmt.Sprintf("_dropped_tier_%d_%s", tier, k)] = droppedCount
generic[fmt.Sprintf("_original_count_%s", k)] = originalLen
anyDropped = true
}
}
_ = anyDropped
if size, _ := json.Marshal(generic); len(size) <= maxBytes {
generic[budgetTruncatedKey] = true
return generic, true
}
}
}
// Step 3: last-resort tail-trim of the longest remaining list.
return applyBudget(generic, maxBytes)
}
// encodeCursor packs an opaque cursor value (currently {offset:N}) as
// base64-encoded JSON so it is safe to round-trip across MCP tool
// args. Opaque on purpose — callers must not parse it. We can change
// the encoding later without breaking callers as long as we keep the
// "round-trip what the server gave you" contract.
func encodeCursor(offset int) string {
if offset <= 0 {
return ""
}
raw, err := json.Marshal(map[string]any{"offset": offset})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(raw)
}
// decodeCursor returns the offset encoded in cursor or 0 if cursor is
// empty / malformed. Malformed cursors degrade to "start from row 0"
// rather than failing the call — defensive against agents that might
// reuse stale cursors after a server restart.
func decodeCursor(cursor string) int {
if cursor == "" {
return 0
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return 0
}
var p struct {
Offset int `json:"offset"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return 0
}
if p.Offset < 0 {
return 0
}
return p.Offset
}
// applyOffsetLimit slices a generic []any according to offset/limit
// and returns the windowed slice plus the next-cursor (empty when no
// more rows). Defensive: an offset past the end yields an empty slice
// rather than an error.
func applyOffsetLimit(rows []any, offset, limit int) ([]any, string) {
if offset >= len(rows) {
return []any{}, ""
}
end := offset + limit
if limit <= 0 || end > len(rows) {
end = len(rows)
}
next := ""
if end < len(rows) {
next = encodeCursor(end)
}
return rows[offset:end], next
}