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
862 lines
29 KiB
Go
862 lines
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
|
||
}
|