Skip to content

Commit 9b46819

Browse files
committed
feat: cache-results key decouples blob caching from content-type (closes #319)
Caching was a side effect of declaring a mime: MCP allows mimeType only on resource blocks, resource blocks need a URI, so content-type (text path) and script-emitted mimeType (mcp-result path) forced a madder write on EVERY output — and since virtually all ~55 mcp-result tools stamp a mime on every block, each small status-y output paid a blob write (folio.ls 1.8k calls/mo, jq 740/mo, all of freud/sisyphus/piers; measured via freud over 30 days). Sampling showed small-output cache URIs are essentially never consumed, while large outputs were already independently protected by the token threshold. New schema-3 key `cache-results = "always" | "threshold" | "never"` (omitted = threshold), honored by both result paths: - threshold (default): blob-cache only above the token threshold; small outputs are plain text blocks — no blob write, mime dropped (there is nowhere to put a mime without a resource block). - always: every non-empty output cached; small outputs become resource blocks (URI + text + mime). For small-but-composable outputs. - never: the blob store is never touched; even oversized output stays plain inline text. The author owns the context cost. content-type is now a pure mime label stamped onto whatever resource block caching produces. no-truncate is orthogonal (inlining above threshold) and unchanged. Annotated cache-results = "always" where small-output URIs are genuinely re-piped: jq.jq, grit.diff, man.list/toc/section. Everything else moves to threshold — the intended churn reduction; large outputs keep URIs everywhere. Also: the moxy.native:// → madder://blobs/ code migration happened previously; this fixes the three folio read* TOML descriptions still advertising the old scheme (doc-only per review — no legacy resolver). moxin(7) gains a RESULT SHAPING section documenting result-type / content-type / cache-results / no-truncate as one system; CLAUDE.md gets the summary. Covered by unit tests (parse + all three modes on both paths) and updated/extended bats: folio 36/36, native 44/44, mcp_proto 46/46 — old-shape assertions updated to the new contract, fixtures that test the resource mechanic opt into "always", new threshold-drops-mime cases on both paths. 🤡 Generated with Clown (https://github.com/amarbel-llc/clown)
1 parent 5603ea9 commit 9b46819

18 files changed

Lines changed: 450 additions & 32 deletions

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@ The `_moxin.toml` manifest declares server identity (`schema`, `name`,
166166
`[tools.input]`). Tools may declare `perms-request` to control permission
167167
behavior: `always-allow`, `each-use`, or `delegate-to-client` (default).
168168

169+
Result shaping (see moxin(7) RESULT SHAPING for the full system):
170+
`result-type` picks envelope ownership (`mcp-result` default = script emits
171+
the full MCP result, needed for dynamic mimeTypes; `text` = moxy builds it).
172+
`cache-results` (`threshold` default | `always` | `never`) controls madder
173+
blob writes independently of mime declaration (#319): threshold caches only
174+
oversized outputs; `always` is for small-but-composable outputs (jq, man
175+
sections, diffs); `content-type` is a pure mime label stamped onto cached
176+
resource blocks — alone it never causes caching, and small uncached outputs
177+
drop the mime.
178+
169179
**Moxin dependency rule:** All moxins (`moxins/*/*.toml`) must have their
170180
external dependencies provided via nix wrapping. Never rely on tools being on
171181
the ambient PATH --- they won't be outside the moxy devshell. The pattern: put

cmd/moxy/moxin.7

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,89 @@ forbids backgrounding even for tools whose permission resolves to allow
103103
\(em use it for interactive, ordering-sensitive, or trivially-fast tools.
104104
The permission tier is a separate, additional gate: only allow-resolving
105105
tools may background regardless of this key.
106+
.It Sy result-type
107+
How the tool's stdout becomes an MCP result (schema 2/3 key).
108+
See
109+
.Sx RESULT SHAPING .
110+
.It Sy content-type
111+
MIME label stamped onto cached resource blocks (schema 3 key).
112+
See
113+
.Sx RESULT SHAPING .
114+
.It Sy cache-results
115+
When the tool's output is written to the madder blob store (schema 3
116+
key).
117+
See
118+
.Sx RESULT SHAPING .
119+
.It Sy no-truncate
120+
Disable the head/tail summary for oversized outputs (boolean; schema 3
121+
key).
122+
See
123+
.Sx RESULT SHAPING .
106124
.El
125+
.Sh RESULT SHAPING
126+
Four keys control how a tool's stdout becomes an MCP result.
127+
.Pp
128+
.Sy result-type
129+
selects who owns the result envelope:
130+
.Bl -tag -width "mcp-result"
131+
.It Li mcp-result
132+
The default when omitted on schema 2/3.
133+
Stdout must be a complete MCP
134+
.Li ToolCallResult
135+
JSON object.
136+
Use when the script needs dynamic control \(em per-call MIME types
137+
(e.g. a diff tool switching between
138+
.Li text/x-diff
139+
and
140+
.Li text/plain ) ,
141+
output-format parameters, or multiple content blocks.
142+
.It Li text
143+
Stdout is plain text; moxy builds the result.
144+
.El
145+
.Pp
146+
.Sy cache-results
147+
controls when output is written to the madder blob store and surfaced
148+
as a
149+
.Li madder://blobs/<digest>
150+
resource URI, on both result-type paths:
151+
.Bl -tag -width "threshold"
152+
.It Li threshold
153+
The default when omitted.
154+
Outputs above the token threshold are blob-cached and returned as a
155+
head/tail summary pointing at the URI; small outputs are plain text
156+
blocks with no blob write.
157+
.It Li always
158+
Every non-empty output is blob-cached; small outputs come back as
159+
resource blocks (URI, full text, MIME).
160+
Declare it when small outputs are composable artifacts that get
161+
re-piped into other tools (jq filters, man sections, diffs).
162+
.It Li never
163+
The blob store is never touched \(em even oversized output is returned
164+
as plain full text.
165+
The tool author owns the context cost.
166+
.El
167+
.Pp
168+
.Sy content-type
169+
is a pure MIME label: it is stamped onto whatever resource block
170+
caching produces and is otherwise inert.
171+
Small outputs under the
172+
.Li threshold
173+
default come back as plain text with the MIME dropped \(em the MCP spec
174+
has nowhere to put a MIME without a resource block, and a resource
175+
block requires a URI.
176+
Declaring
177+
.Sy content-type
178+
alone does not cause caching; pair it with
179+
.Sy cache-results No = Li \(dqalways\(dq
180+
when the label must always be present.
181+
The same policy governs MIME-bearing text blocks emitted by
182+
.Li mcp-result
183+
tools.
184+
.Pp
185+
.Sy no-truncate
186+
keeps the full text inline (prefixed with the blob URI) instead of the
187+
head/tail summary when an oversized output is cached.
188+
It does not affect whether caching happens.
107189
.Sh PERMISSIONS
108190
Each tool may declare a
109191
.Sy perms-request

internal/native/config.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ type ToolSpec struct {
104104
PermitAsync *bool // nil = eligible (see PermitsAsync); #317
105105
ContentType string
106106
ResultType ResultType
107+
CacheResults CacheResults // resolved; never empty (#319)
107108
NoTruncate bool
108109
SubstituteResultURIs *bool
109110
Annotations *ToolAnnotations
@@ -148,6 +149,7 @@ type rawToolFile struct {
148149
PermsRequest PermsRequest `toml:"perms-request"`
149150
DynamicPerms *DynamicPermsSpec `toml:"dynamic-perms"` // Added for moxy POC dynamic-perms
150151
PermitAsync *bool `toml:"permit-async"` // #317
152+
CacheResults string `toml:"cache-results"` // #319
151153
ContentType string `toml:"content-type"`
152154
ResultType string `toml:"result-type"`
153155
NoTruncate bool `toml:"no-truncate"`
@@ -284,6 +286,11 @@ func ParseMoxinDirFull(dirPath string) (*ParseResult, error) {
284286
return nil, fmt.Errorf("tool file %s: %w", filename, err)
285287
}
286288

289+
cacheResults, err := resolveCacheResults(raw.CacheResults)
290+
if err != nil {
291+
return nil, fmt.Errorf("tool file %s: %w", filename, err)
292+
}
293+
287294
ts := ToolSpec{
288295
Name: toolName,
289296
Description: raw.Description,
@@ -296,6 +303,7 @@ func ParseMoxinDirFull(dirPath string) (*ParseResult, error) {
296303
PermitAsync: raw.PermitAsync, // #317
297304
ContentType: raw.ContentType,
298305
ResultType: resultType,
306+
CacheResults: cacheResults, // #319
299307
NoTruncate: raw.NoTruncate,
300308
SubstituteResultURIs: raw.SubstituteResultURIs,
301309
Annotations: raw.Annotations,
@@ -343,6 +351,40 @@ func resolveResultType(schema int, raw string) (ResultType, error) {
343351
}
344352
}
345353

354+
// CacheResults controls when a tool's output is written to the madder blob
355+
// store and surfaced as a resource URI (#319). Decoupled from content-type:
356+
// the mime is a label stamped onto whatever resource block caching produces,
357+
// never itself a reason to cache.
358+
type CacheResults string
359+
360+
const (
361+
// CacheAlways writes every non-empty output to the blob store; small
362+
// outputs come back as resource blocks (URI + text + mime if declared).
363+
// For tools whose small outputs are composable artifacts (re-piped
364+
// into jq, folio.read, etc.).
365+
CacheAlways CacheResults = "always"
366+
// CacheThreshold (the default) writes only outputs above the token
367+
// threshold; small outputs are plain text blocks with no blob write
368+
// and no mime.
369+
CacheThreshold CacheResults = "threshold"
370+
// CacheNever never writes the blob store — even oversized outputs are
371+
// returned as plain full text. The tool author owns the context cost.
372+
CacheNever CacheResults = "never"
373+
)
374+
375+
func resolveCacheResults(raw string) (CacheResults, error) {
376+
if raw == "" {
377+
return CacheThreshold, nil
378+
}
379+
cr := CacheResults(raw)
380+
switch cr {
381+
case CacheAlways, CacheThreshold, CacheNever:
382+
return cr, nil
383+
default:
384+
return "", fmt.Errorf("invalid cache-results %q (want always, threshold, or never)", raw)
385+
}
386+
}
387+
346388
func validatePermsRequest(pr PermsRequest) error {
347389
switch pr {
348390
case "", PermsDelegateToClient, PermsAlwaysAllow, PermsEachUse, PermsDynamic:
@@ -409,6 +451,7 @@ func detectUndecodedTool(data []byte, filename string, schema int) []string {
409451
"arg-order", "stdin-param", "perms-request",
410452
"dynamic-perms", // Added for moxy POC dynamic-perms
411453
"permit-async", // #317
454+
"cache-results", // #319
412455
"content-type", "result-type", "no-truncate", "substitute-result-uris",
413456
}
414457
if schema <= 2 {

internal/native/config_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"os"
66
"path/filepath"
7+
"strings"
78
"testing"
89
)
910

@@ -498,6 +499,66 @@ timeout-ms = 5000
498499
}
499500
}
500501

502+
// cache-results (#319): omitted resolves to the threshold default; the three
503+
// explicit values parse; anything else is a config error.
504+
func TestParseMoxinDirCacheResults(t *testing.T) {
505+
dir := writeMoxinDir(t, t.TempDir(), "test", `
506+
schema = 1
507+
name = "test"
508+
`, map[string]string{
509+
"defaulted": `
510+
schema = 3
511+
command = "echo"
512+
`,
513+
"always": `
514+
schema = 3
515+
command = "echo"
516+
cache-results = "always"
517+
`,
518+
"never": `
519+
schema = 3
520+
command = "echo"
521+
cache-results = "never"
522+
`,
523+
})
524+
525+
cfg, err := ParseMoxinDir(dir)
526+
if err != nil {
527+
t.Fatalf("unexpected error: %v", err)
528+
}
529+
byName := map[string]ToolSpec{}
530+
for _, tool := range cfg.Tools {
531+
byName[tool.Name] = tool
532+
}
533+
if got := byName["defaulted"].CacheResults; got != CacheThreshold {
534+
t.Errorf("defaulted: CacheResults = %q, want threshold", got)
535+
}
536+
if got := byName["always"].CacheResults; got != CacheAlways {
537+
t.Errorf("always: CacheResults = %q, want always", got)
538+
}
539+
if got := byName["never"].CacheResults; got != CacheNever {
540+
t.Errorf("never: CacheResults = %q, want never", got)
541+
}
542+
}
543+
544+
func TestParseMoxinDirCacheResultsInvalid(t *testing.T) {
545+
dir := writeMoxinDir(t, t.TempDir(), "test", `
546+
schema = 1
547+
name = "test"
548+
`, map[string]string{
549+
"bad": `
550+
schema = 3
551+
command = "echo"
552+
cache-results = "sometimes"
553+
`,
554+
})
555+
556+
_, err := ParseMoxinDir(dir)
557+
if err == nil || !strings.Contains(err.Error(), "invalid cache-results") {
558+
t.Fatalf("err = %v, want invalid cache-results", err)
559+
}
560+
}
561+
501562
// permit-async (#317): omitted = eligible, explicit false forbids, explicit
502563
// true is recorded distinctly from omitted (*bool) for future tools/list
503564
// execution.taskSupport surfacing.

internal/native/server.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,11 +453,13 @@ func (s *Server) buildMCPResult(ctx context.Context, spec *ToolSpec, output stri
453453

454454
// Rewrite text blocks that carry mimeType into resource blocks with
455455
// cache URIs — the MCP spec only allows mimeType on resource blocks.
456+
// Whether a block is cached is the tool's cache-results policy (#319):
457+
// the mime is just the label stamped onto whatever caching produces.
456458
// Skip empty text — EmbeddedResourceContents requires non-empty text or blob.
457459
cleaned := result.Content[:0]
458460
for _, block := range result.Content {
459461
if block.Type == "text" && block.MimeType != "" {
460-
if block.Text != "" && s.madder != nil {
462+
if block.Text != "" && s.madder != nil && s.shouldCache(spec, block.Text) {
461463
uri, cacheErr := s.cacheAndGetURI(ctx, block.Text)
462464
if cacheErr == nil {
463465
text := block.Text
@@ -488,13 +490,31 @@ func (s *Server) buildMCPResult(ctx context.Context, spec *ToolSpec, output stri
488490
return marshalResult(&result)
489491
}
490492

493+
// shouldCache applies the tool's cache-results policy to one output (#319):
494+
// always caches everything, threshold (the default) only above the token
495+
// threshold, never caches nothing.
496+
func (s *Server) shouldCache(spec *ToolSpec, text string) bool {
497+
switch spec.CacheResults {
498+
case CacheAlways:
499+
return true
500+
case CacheNever:
501+
return false
502+
default: // CacheThreshold (and zero value for hand-built specs)
503+
return estimateTokens(text) > tokenThreshold
504+
}
505+
}
506+
491507
func (s *Server) buildTextResult(ctx context.Context, spec *ToolSpec, output string) (json.RawMessage, error) {
492508
if output == "" {
493509
return marshalResult(&protocol.ToolCallResultV1{})
494510
}
495511

512+
// cache-results policy (#319): "never" skips the blob store entirely
513+
// (even oversized output stays plain inline text — the author owns the
514+
// context cost); "threshold"/"always" blob-cache oversized output with
515+
// the summary (or no-truncate inline) shape.
496516
tokens := estimateTokens(output)
497-
if tokens > tokenThreshold && s.madder != nil {
517+
if spec.CacheResults != CacheNever && tokens > tokenThreshold && s.madder != nil {
498518
digest, storeErr := s.madder.Write(ctx, strings.NewReader(output))
499519
if storeErr == nil {
500520
if spec.NoTruncate {
@@ -513,7 +533,12 @@ func (s *Server) buildTextResult(ctx context.Context, spec *ToolSpec, output str
513533
}
514534
}
515535

516-
if spec.ContentType != "" && s.madder != nil {
536+
// "always" additionally caches small outputs as resource blocks, with
537+
// content-type as the mime label. content-type alone no longer forces a
538+
// cache write: small outputs under "threshold"/"never" are plain text
539+
// blocks with the mime dropped (the MCP spec has nowhere to put a mime
540+
// without a resource block, and a resource block needs a URI).
541+
if spec.CacheResults == CacheAlways && s.madder != nil {
517542
uri, cacheErr := s.cacheAndGetURI(ctx, output)
518543
if cacheErr == nil {
519544
block := protocol.ContentBlockV1{

0 commit comments

Comments
 (0)