Reduce MCP token usage

This commit is contained in:
2026-05-29 13:16:05 +01:00
parent da46340a82
commit 51aac9f447
9 changed files with 453 additions and 137 deletions

View File

@@ -65,6 +65,15 @@ type toolHost struct {
timers *timerManager
}
const (
defaultMCPContentBytes = 12_000
maxMCPContentBytes = 65_536
defaultMCPTailBytes = 8_000
defaultScratchpadReadBytes = 12_000
defaultSearchLineBytes = 2_000
maxSearchMatches = 50
)
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
h := &toolHost{
sess: sess,
@@ -353,8 +362,8 @@ func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStat
return st, nil
}
func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
caller := h.WhoAmI(callerID)
func (h *toolHost) GetProjectStatus(callerID string, includeTools bool) (mcp.ProjectStatus, error) {
caller := h.WhoAmI(callerID, includeTools)
processes := h.ListProcesses(callerID, "")
pads, _ := h.pads.List()
return mcp.ProjectStatus{
@@ -365,7 +374,8 @@ func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error)
}, nil
}
func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
func (h *toolHost) GetProcessOutput(callerID string, args mcp.ProcessOutputArgs) (mcp.ProcessOutput, error) {
processID, mode, sinceOffset := args.ProcessID, args.Mode, args.SinceOffset
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
@@ -399,11 +409,12 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
}
out.Content = normalizeGridText(txt)
content := normalizeGridText(txt)
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextMiddle(content, capLimit(args.MaxBytes, defaultMCPContentBytes))
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
out.Content = string(stripANSIBytes(nil, b))
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capBytesTail(stripANSIBytes(nil, b), capLimit(args.MaxBytes, defaultMCPContentBytes))
out.NewOffset = end
return out, nil
default:
@@ -411,34 +422,46 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
}
}
func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
c := h.sess.FindChild(processID)
func (h *toolHost) GetProcessRawOutput(callerID string, args mcp.RawOutputArgs) (mcp.RawOutput, error) {
c := h.sess.FindChild(args.ProcessID)
if c == nil {
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
b, end := c.StreamRead(sinceOffset)
b, end := c.StreamRead(args.SinceOffset)
content, contentBytes, truncated, truncatedBytes := capBytesTail(b, capLimit(args.MaxBytes, defaultMCPContentBytes))
return mcp.RawOutput{
Content: string(b),
NewOffset: end,
Status: string(c.Status()),
Content: content,
NewOffset: end,
Status: string(c.Status()),
ContentBytes: contentBytes,
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil
}
func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
c := h.sess.FindChild(processID)
func (h *toolHost) SearchOutput(callerID string, args mcp.SearchOutputArgs) (mcp.SearchResult, error) {
c := h.sess.FindChild(args.ProcessID)
if c == nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
re, err := regexp.Compile(pattern)
re, err := regexp.Compile(args.Pattern)
if err != nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
b, _ := c.StreamRead(0)
if kind == "rendered" {
if args.Kind == "rendered" {
b = stripANSIBytes(nil, b)
}
text := string(b)
lines := strings.Split(text, "\n")
limit := args.Limit
if limit <= 0 {
limit = 10
}
if limit > maxSearchMatches {
limit = maxSearchMatches
}
lineLimit := capLimit(args.MaxBytes, defaultSearchLineBytes)
matches := make([]mcp.SearchMatch, 0, limit)
truncated := false
for i, line := range lines {
@@ -447,6 +470,8 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
truncated = true
break
}
line, _, lineTruncated, _ := capTextTail(line, lineLimit)
truncated = truncated || lineTruncated
matches = append(matches, mcp.SearchMatch{LineNo: i + 1, Text: line})
}
}
@@ -588,6 +613,7 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
if err != nil {
return mcp.SendInputResult{}, err
}
tailSince := c.StreamOffset()
if err := c.InjectAsOrchestrator(payload); err != nil {
return mcp.SendInputResult{}, err
}
@@ -599,7 +625,12 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
}
if mode != "none" {
time.Sleep(time.Duration(args.WaitMS) * time.Millisecond)
tail, err := h.GetProcessOutput(callerID, args.ProcessID, mode, 0)
tail, err := h.GetProcessOutput(callerID, mcp.ProcessOutputArgs{
ProcessID: args.ProcessID,
Mode: mode,
SinceOffset: tailSince,
MaxBytes: capLimit(args.TailMaxBytes, defaultMCPTailBytes),
})
if err == nil {
res.Tail = &tail
}
@@ -813,8 +844,30 @@ func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadRead(name string) (string, string, error) {
return h.pads.Read(name)
func (h *toolHost) ScratchpadRead(args mcp.ScratchpadReadArgs) (mcp.ScratchpadReadResult, error) {
content, rev, err := h.pads.Read(args.Name)
if err != nil {
return mcp.ScratchpadReadResult{}, err
}
offset := args.Offset
if offset < 0 {
offset = 0
}
if offset > len(content) {
offset = len(content)
}
limited, contentBytes, truncated, truncatedBytes := capTextHead(content[offset:], capLimit(args.MaxBytes, defaultScratchpadReadBytes))
next := offset + contentBytes
return mcp.ScratchpadReadResult{
Content: limited,
Revision: rev,
Offset: offset,
NextOffset: next,
ContentBytes: contentBytes,
TotalBytes: len(content),
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil
}
func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
@@ -841,7 +894,7 @@ func (h *toolHost) ScratchpadDelete(name string) error {
return err
}
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
func (h *toolHost) WhoAmI(callerID string, includeTools bool) mcp.WhoAmI {
w := mcp.WhoAmI{
ProcessID: callerID,
Role: h.CallerRole(callerID),
@@ -849,7 +902,9 @@ func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
Path: h.sess.projectDir,
Key: h.sess.projectKey,
},
AvailableTools: availableToolsForRole(h.CallerRole(callerID)),
}
if includeTools {
w.AvailableTools = availableToolsForRole(h.CallerRole(callerID))
}
if c := h.sess.FindChild(callerID); c != nil {
w.Name = c.DisplayName()
@@ -1043,6 +1098,51 @@ func normalizeGridText(s string) string {
return strings.Join(out, "\n")
}
func capLimit(requested, def int) int {
if requested <= 0 {
requested = def
}
if requested > maxMCPContentBytes {
requested = maxMCPContentBytes
}
if requested < 0 {
return 0
}
return requested
}
func capBytesTail(b []byte, limit int) (string, int, bool, int) {
if limit <= 0 || len(b) <= limit {
return string(b), len(b), false, 0
}
dropped := len(b) - limit
return string(b[dropped:]), limit, true, dropped
}
func capTextTail(s string, limit int) (string, int, bool, int) {
return capBytesTail([]byte(s), limit)
}
func capTextHead(s string, limit int) (string, int, bool, int) {
if limit <= 0 || len(s) <= limit {
return s, len(s), false, 0
}
return s[:limit], limit, true, len(s) - limit
}
func capTextMiddle(s string, limit int) (string, int, bool, int) {
if limit <= 0 || len(s) <= limit {
return s, len(s), false, 0
}
const marker = "\n...[truncated]...\n"
if limit <= len(marker)+2 {
return s[len(s)-limit:], limit, true, len(s) - limit
}
head := (limit - len(marker)) / 2
tail := limit - len(marker) - head
return s[:head] + marker + s[len(s)-tail:], limit, true, len(s) - limit
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a