a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
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
166 lines
4.5 KiB
Go
166 lines
4.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
// defaultResponseBufferCap is how many recent tool responses a session
|
|
// keeps available for post-filter re-cutting.
|
|
const defaultResponseBufferCap = 8
|
|
|
|
// minCapturedResponseBytes is the capture floor: responses smaller
|
|
// than this are cheap to re-fetch, so they never displace a ring slot.
|
|
const minCapturedResponseBytes = 1024
|
|
|
|
// postFilterTools never have their own responses captured — re-cutting
|
|
// a re-cut would only churn the ring.
|
|
var postFilterTools = map[string]bool{
|
|
"ctx_stats": true,
|
|
"ctx_peek": true,
|
|
"ctx_slice": true,
|
|
"ctx_grep": true,
|
|
"grep_results": true,
|
|
"head_results": true,
|
|
}
|
|
|
|
// bufferedResponse is one captured tool response held for re-cutting.
|
|
type bufferedResponse struct {
|
|
ID string
|
|
Tool string
|
|
Text string
|
|
CapturedAt time.Time
|
|
}
|
|
|
|
// responseBuffer is a per-session ring of recent large tool responses.
|
|
// It backs the post-filter tools (ctx_grep, ctx_slice, …) so an agent
|
|
// can re-cut a prior result without re-issuing the original query.
|
|
type responseBuffer struct {
|
|
mu sync.Mutex
|
|
entries []bufferedResponse
|
|
seq int
|
|
}
|
|
|
|
// capture stores a response and returns its handle ID. The oldest
|
|
// entry is evicted, and its backing memory released, once the ring is
|
|
// full.
|
|
func (b *responseBuffer) capture(tool, text string) string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.seq++
|
|
id := fmt.Sprintf("resp_%d", b.seq)
|
|
b.entries = append(b.entries, bufferedResponse{
|
|
ID: id,
|
|
Tool: tool,
|
|
Text: text,
|
|
CapturedAt: time.Now(),
|
|
})
|
|
if len(b.entries) > defaultResponseBufferCap {
|
|
trimmed := make([]bufferedResponse, defaultResponseBufferCap)
|
|
copy(trimmed, b.entries[len(b.entries)-defaultResponseBufferCap:])
|
|
b.entries = trimmed
|
|
}
|
|
return id
|
|
}
|
|
|
|
// get resolves a handle. An empty or "latest" id returns the most
|
|
// recent capture; the bool is false when the buffer is empty or the id
|
|
// is unknown.
|
|
func (b *responseBuffer) get(id string) (bufferedResponse, bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if len(b.entries) == 0 {
|
|
return bufferedResponse{}, false
|
|
}
|
|
if id == "" || id == "latest" {
|
|
return b.entries[len(b.entries)-1], true
|
|
}
|
|
for i := len(b.entries) - 1; i >= 0; i-- {
|
|
if b.entries[i].ID == id {
|
|
return b.entries[i], true
|
|
}
|
|
}
|
|
return bufferedResponse{}, false
|
|
}
|
|
|
|
// list returns every buffered response, most recent first.
|
|
func (b *responseBuffer) list() []bufferedResponse {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
out := make([]bufferedResponse, 0, len(b.entries))
|
|
for i := len(b.entries) - 1; i >= 0; i-- {
|
|
out = append(out, b.entries[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// responseBufferFor returns the calling session's response buffer,
|
|
// allocating it on first use.
|
|
func (s *Server) responseBufferFor(ctx context.Context) *responseBuffer {
|
|
sess := s.sessionFor(ctx)
|
|
sess.mu.Lock()
|
|
defer sess.mu.Unlock()
|
|
if sess.responses == nil {
|
|
sess.responses = &responseBuffer{}
|
|
}
|
|
return sess.responses
|
|
}
|
|
|
|
// captureResponse stores a large, successful, non-post-filter tool
|
|
// response in the session ring so it can be re-cut later. Called from
|
|
// wrapToolHandler for every tool call.
|
|
func (s *Server) captureResponse(ctx context.Context, tool string, res *mcp.CallToolResult) {
|
|
if res == nil || res.IsError || postFilterTools[tool] {
|
|
return
|
|
}
|
|
text := normalizeForBuffer(toolResultText(res))
|
|
if len(text) < minCapturedResponseBytes {
|
|
return
|
|
}
|
|
s.responseBufferFor(ctx).capture(tool, text)
|
|
}
|
|
|
|
// normalizeForBuffer renders a compact-JSON response as indented text
|
|
// so the post-filter tools' line-based slicing and grep land on
|
|
// meaningful lines. Non-JSON text (GCX, TOON) is already
|
|
// line-structured and passes through unchanged.
|
|
func normalizeForBuffer(text string) string {
|
|
t := strings.TrimSpace(text)
|
|
if t == "" || (t[0] != '{' && t[0] != '[') {
|
|
return text
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := json.Indent(&buf, []byte(text), "", " "); err != nil {
|
|
return text
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// toolResultText extracts the concatenated text content of a tool
|
|
// result. The common single-block case is returned without a copy.
|
|
func toolResultText(res *mcp.CallToolResult) string {
|
|
if res == nil || len(res.Content) == 0 {
|
|
return ""
|
|
}
|
|
if len(res.Content) == 1 {
|
|
if tc, ok := res.Content[0].(mcp.TextContent); ok {
|
|
return tc.Text
|
|
}
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
for _, c := range res.Content {
|
|
if tc, ok := c.(mcp.TextContent); ok {
|
|
b.WriteString(tc.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|