Files
coder/coderd/x/chatd/chattool
Michael Suchacz 7724ee281a feat: defer MCP tool schemas behind a find_tools search (#28225)
## Summary

When the `mcp-tool-search` experiment is enabled, chatd stops inlining
connected MCP tool schemas into every generation. It instead exposes a
built-in `find_tools` tool whose description carries a compact catalog
of the deferred tools, and only ships full JSON schemas for tools the
model has activated by searching or by calling them directly.

Closes [CODAGT-760](https://linear.app/coder/issue/CODAGT-760).

## Problem

Tool-heavy agent configurations (GitHub, Linear, Notion, and dev-tooling
MCP servers) inline over 100k tokens of tool schema definitions into
every generation. Initial uncached requests reached ~216k tokens with
time-to-first-token close to nine minutes, while the model typically
invokes only a handful of tools per turn.

## How it works

- `decideMCPToolSearch` defers external and workspace `.mcp.json` MCP
tools whenever the experiment is enabled. Native, dynamic, provider,
skill, and transport tools are never deferred.
- `find_tools` embeds a server-grouped catalog in its tool description
(degrading to names-only, then counts-only, then a constant-size summary
past a context-scaled size cap) and scores keyword matches across tool
names, descriptions, parameter schemas, and server metadata. Queries can
scope to one server with a `server:` prefix, and exact `names` arguments
always activate.
- Activation state is ephemeral: it is re-derived each generation from
surviving chat history (`find_tools` results and direct calls to
deferred tools), so activations naturally lapse when compaction
summarizes them away. Aggregate activated schema weight is capped at 10%
of the context window, shedding the least recently activated schemas
first; `find_tools` shares that budget across parallel calls in one
step. No new persistence.
- Deferred tools stay registered for execution, so the model can call a
cataloged tool directly without searching first; the schema is activated
for subsequent steps.
- Fail-open: the experiment being disabled, an empty candidate set, or
an MCP tool named `find_tools` all disable deferral, leaving today's
behavior byte-identical on the wire.
- Prometheus counters/histograms track `find_tools` calls, matches,
activations, and deferred token weight.
- The conversation timeline renders `find_tools` calls with a collapsed
search summary and expandable match list, falling back to the generic
renderer on malformed payloads.

## Validation

- Unit tests for the catalog, matcher, experiment-gated decision, and
activation derivation; end-to-end chatd generation tests covering
search-then-call, direct-call activation, experiment-off wire parity,
compaction lapse, and subagent tool gating.
- Storybook interaction tests for the timeline rendering and
malformed-payload fallback.
- Remote dogfood UAT on dev.coder.com passed: deferral with a real MCP
server and Anthropic model, direct calls without prior search,
activation persistence across turns, experiment-off parity, and clean
UI/console.

> Disclosure: Mux (AI agent) authored this PR on Mike's behalf.
2026-08-18 19:12:47 +02:00
..

package chattool

import (
	"bufio"
	"strings"
	"unicode"

	"github.com/coder/coder/v2/coderd/render"
	coderstrings "github.com/coder/coder/v2/coderd/util/strings"
)

// readmeInputMaxBytes caps how many README bytes are parsed so a giant README
// can't OOM coderd. It sits well above the output rune cap, so it never trims a
// real excerpt.
const readmeInputMaxBytes = 64 * 1024

// readmeText returns the README as bounded, frontmatter-stripped plain text
// truncated to maxRunes, or "" when the README is blank or conversion fails.
func readmeText(readme string, maxRunes int) string {
	// Cap the parse input first (see readmeInputMaxBytes); goldmark and the
	// tokenizer tolerate a mid-line or mid-rune cut.
	bounded := readme[:min(len(readme), readmeInputMaxBytes)]
	text, err := render.InnerTextFromMarkdown(stripReadmeFrontmatter(bounded))
	if err != nil {
		return ""
	}
	return coderstrings.Truncate(text, maxRunes, coderstrings.TruncateWithEllipsis)
}

// stripReadmeFrontmatter strips a leading frontmatter block from the README if it
// exists. An unterminated frontmatter block is treated as a regular body section.
// UTF-8 BOMs are stripped if present, and CRLF is normalized to LF. Leading
// whitespace is also stripped.
func stripReadmeFrontmatter(readme string) string {
	trimmed := strings.TrimLeftFunc(readme, func(r rune) bool {
		return unicode.IsSpace(r) || r == '\ufeff'
	})

	var out strings.Builder
	scn := bufio.NewScanner(strings.NewReader(trimmed))
	scn.Buffer(nil, readmeInputMaxBytes+1) // headroom
	var lineNumber int
	var fences int
	for scn.Scan() {
		line := scn.Text()
		lineNumber++

		// Only handle fences if we haven't already found an
		// opening and closing fence.
		if fences < 2 {
			isFence := strings.TrimRight(line, " \t\r") == "---"
			if isFence {
				fences++
				continue
			} else if lineNumber == 1 {
				// No leading fence -> no frontmatter. Return the entire document.
				return trimmed
			}
			// We are still in the frontmatter block. Skip writing this line.
			continue
		}
		_, _ = out.WriteString(line)
		_, _ = out.WriteString("\n")
	}

	// Can err if input is too big. Shouldn't happen normally.
	if scn.Err() != nil {
		return trimmed
	}

	// Scanner did not scan any lines. No frontmatter to strip.
	if lineNumber == 0 {
		return trimmed
	}

	// If we are still fenced but we reached the end of the document
	// we have an unterminated fence.
	if fences == 1 {
		return trimmed
	}
	return out.String()
}