Skip to content

Commit 91a222a

Browse files
committed
feat(tool/cloud-storage-read-object): return UTF-8 text, reject binary
MCP tool results only carry text today, so the previous base64-encoded content was unusable by the LLM. Validate object bytes with utf8.Valid and return plain-text content; non-UTF-8 objects surface as an agent-fixable ErrBinaryContent error. TODO notes mark the spots to revisit once MCP supports embedded resources.
1 parent b0f6dff commit 91a222a

File tree

5 files changed

+89
-45
lines changed

5 files changed

+89
-45
lines changed

docs/en/integrations/cloud-storage/tools/cloud-storage-read-object.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,27 @@ title: "cloud-storage-read-object"
33
type: docs
44
weight: 2
55
description: >
6-
A "cloud-storage-read-object" tool reads the content of a Cloud Storage object and returns it as a base64-encoded string, optionally constrained to a byte range.
6+
A "cloud-storage-read-object" tool reads the UTF-8 text content of a Cloud Storage object, optionally constrained to a byte range.
77
---
88

99
## About
1010

1111
A `cloud-storage-read-object` tool fetches the bytes of a single
12-
[Cloud Storage object][gcs-objects] and returns them base64-encoded so that
13-
arbitrary binary content can be round-tripped through JSON safely.
12+
[Cloud Storage object][gcs-objects] and returns them as plain UTF-8 text.
13+
14+
Only text objects are supported today: if the object bytes (or the requested
15+
range) are not valid UTF-8 the tool returns an agent-fixable error. This is
16+
because the MCP tool-result channel currently only carries text; binary
17+
payloads will be supported once MCP can carry embedded resources.
1418

1519
Reads are capped at **8 MiB** per call to protect the server's memory and keep
1620
LLM contexts manageable; objects or ranges larger than that are rejected with
1721
an agent-fixable error. Use the optional `range` parameter to read a slice of
1822
a larger object.
1923

20-
This tool is intended for small-to-medium textual or binary content an LLM can
21-
process directly. For bulk downloads of large files to the local filesystem,
22-
use `cloud-storage-download-object` (coming in a follow-up release).
24+
This tool is intended for small-to-medium textual content an LLM can process
25+
directly. For bulk downloads of large files to the local filesystem, use
26+
`cloud-storage-download-object` (coming in a follow-up release).
2327

2428
[gcs-objects]: https://cloud.google.com/storage/docs/objects
2529

internal/sources/cloudstorage/cloudstorage.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ package cloudstorage
1616

1717
import (
1818
"context"
19-
"encoding/base64"
2019
"fmt"
2120
"io"
21+
"unicode/utf8"
2222

2323
"cloud.google.com/go/storage"
2424
"github.com/goccy/go-yaml"
@@ -136,14 +136,19 @@ func (s *Source) ListObjects(ctx context.Context, bucket, prefix, delimiter stri
136136
}, nil
137137
}
138138

139-
// ReadObject fetches an object's bytes and returns a map with the
140-
// base64-encoded content, its content type, and the number of bytes read.
141-
// offset and length follow storage.ObjectHandle.NewRangeReader semantics:
142-
// length == -1 means "read to end of object"; a negative offset means "suffix
143-
// from end" (in which case length must be -1). Reads larger than
144-
// defaultMaxReadBytes are rejected with
145-
// cloudstoragecommon.ErrReadSizeLimitExceeded so the caller can narrow the
146-
// range.
139+
// ReadObject fetches an object's bytes and returns a map with the UTF-8
140+
// content, its content type, and the number of bytes read. offset and length
141+
// follow storage.ObjectHandle.NewRangeReader semantics: length == -1 means
142+
// "read to end of object"; a negative offset means "suffix from end" (in
143+
// which case length must be -1). Reads larger than defaultMaxReadBytes are
144+
// rejected with cloudstoragecommon.ErrReadSizeLimitExceeded so the caller can
145+
// narrow the range. Objects whose bytes are not valid UTF-8 are rejected
146+
// with cloudstoragecommon.ErrBinaryContent.
147+
//
148+
// TODO: MCP tool results only carry text today, so we gate this tool on
149+
// utf8.Valid. When the toolbox supports non-text MCP content (embedded
150+
// resources, images, blobs), expand this to detect content type and return
151+
// binary payloads natively.
147152
func (s *Source) ReadObject(ctx context.Context, bucket, object string, offset, length int64) (map[string]any, error) {
148153
reader, err := s.Client.Bucket(bucket).Object(object).NewRangeReader(ctx, offset, length)
149154
if err != nil {
@@ -162,8 +167,13 @@ func (s *Source) ReadObject(ctx context.Context, bucket, object string, offset,
162167
return nil, fmt.Errorf("failed to read object %q in bucket %q: %w", object, bucket, err)
163168
}
164169

170+
if !utf8.Valid(data) {
171+
return nil, fmt.Errorf("object %q in bucket %q: %w", object, bucket,
172+
cloudstoragecommon.ErrBinaryContent)
173+
}
174+
165175
return map[string]any{
166-
"content": base64.StdEncoding.EncodeToString(data),
176+
"content": string(data),
167177
"contentType": reader.Attrs.ContentType,
168178
"size": len(data),
169179
}, nil

internal/tools/cloudstorage/cloudstoragecommon/errors.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ import (
3232
// parameter.
3333
var ErrReadSizeLimitExceeded = errors.New("cloud storage read size limit exceeded")
3434

35+
// ErrBinaryContent is returned by the source when an object's bytes are not
36+
// valid UTF-8. The MCP tool result channel only carries text today, so binary
37+
// payloads cannot be faithfully round-tripped; ProcessGCSError maps this to an
38+
// Agent error so the LLM knows to stop asking for this object.
39+
//
40+
// TODO: when the toolbox supports non-text MCP content (embedded resources,
41+
// images, blobs), remove this guard and return binary payloads directly.
42+
var ErrBinaryContent = errors.New("cloud storage object is not valid UTF-8 text")
43+
3544
// ProcessGCSError classifies an error from the Cloud Storage Go client into
3645
// either an Agent Error (the LLM can self-correct by changing its input — bad
3746
// request, missing bucket/object, unsatisfiable range) or a Server Error
@@ -59,6 +68,14 @@ func ProcessGCSError(err error) util.ToolboxError {
5968
err)
6069
}
6170

71+
// Non-UTF-8 object — MCP only carries text today, so the agent should
72+
// stop trying to read this object.
73+
if errors.Is(err, ErrBinaryContent) {
74+
return util.NewAgentError(
75+
"object contains non-text (binary) bytes and cannot be returned; only UTF-8 text objects are supported",
76+
err)
77+
}
78+
6279
// GCS sentinel errors — "not found" flavours are agent-fixable.
6380
if errors.Is(err, storage.ErrBucketNotExist) {
6481
return util.NewAgentError("cloud storage bucket does not exist", err)

internal/tools/cloudstorage/cloudstoragecommon/errors_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func TestProcessGCSError_Categorization(t *testing.T) {
4949
{desc: "object not exist sentinel", in: storage.ErrObjectNotExist, category: util.CategoryAgent},
5050
{desc: "wrapped object not exist", in: fmt.Errorf("wrapped: %w", storage.ErrObjectNotExist), category: util.CategoryAgent},
5151
{desc: "read size limit exceeded", in: fmt.Errorf("big: %w", cloudstoragecommon.ErrReadSizeLimitExceeded), category: util.CategoryAgent},
52+
{desc: "binary (non-UTF-8) content", in: fmt.Errorf("obj: %w", cloudstoragecommon.ErrBinaryContent), category: util.CategoryAgent},
5253
{desc: "400 bad request", in: gcpErr(http.StatusBadRequest), category: util.CategoryAgent},
5354
{desc: "401 unauthorized", in: gcpErr(http.StatusUnauthorized), category: util.CategoryServer, code: http.StatusUnauthorized},
5455
{desc: "403 forbidden", in: gcpErr(http.StatusForbidden), category: util.CategoryServer, code: http.StatusForbidden},

tests/cloudstorage/cloud_storage_integration_test.go

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package cloudstorage
1717
import (
1818
"bytes"
1919
"context"
20-
"encoding/base64"
2120
"encoding/json"
2221
"fmt"
2322
"io"
@@ -42,11 +41,12 @@ var (
4241
)
4342

4443
const (
45-
helloObject = "seed/hello.txt"
46-
jsonObject = "seed/nested/data.json"
47-
largeObject = "seed/large.bin"
48-
helloBody = "hello world"
49-
jsonBody = `{"foo":"bar"}`
44+
helloObject = "seed/hello.txt"
45+
jsonObject = "seed/nested/data.json"
46+
largeObject = "seed/large.bin"
47+
binaryObject = "seed/binary.bin"
48+
helloBody = "hello world"
49+
jsonBody = `{"foo":"bar"}`
5050
// largeObjectSize is > the 8 MiB read cap so we can assert the size-limit
5151
// agent-error path on the read_object tool.
5252
largeObjectSize = (8 << 20) + 1024
@@ -158,6 +158,19 @@ func setupCloudStorageTestData(t *testing.T, ctx context.Context, client *storag
158158
t.Fatalf("failed to close writer for seed object %q: %v", largeObject, err)
159159
}
160160

161+
// Seed a small binary (non-UTF-8) object to exercise the
162+
// ErrBinaryContent path on read_object.
163+
binary := []byte{0xff, 0xfe, 0xfd, 0xfc}
164+
bw := bkt.Object(binaryObject).NewWriter(ctx)
165+
bw.ContentType = "application/octet-stream"
166+
if _, err := bw.Write(binary); err != nil {
167+
_ = bw.Close()
168+
t.Fatalf("failed to write seed object %q: %v", binaryObject, err)
169+
}
170+
if err := bw.Close(); err != nil {
171+
t.Fatalf("failed to close writer for seed object %q: %v", binaryObject, err)
172+
}
173+
161174
return func(t *testing.T) {
162175
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
163176
defer cancel()
@@ -330,9 +343,8 @@ func runCloudStorageReadObjectTest(t *testing.T, bucket string) {
330343
if status != http.StatusOK {
331344
t.Fatalf("unexpected status %d: %s", status, result)
332345
}
333-
decoded := decodeBase64Field(t, result, "content")
334-
if decoded != helloBody {
335-
t.Errorf("expected %q, got %q (raw %s)", helloBody, decoded, result)
346+
if content := extractStringField(t, result, "content"); content != helloBody {
347+
t.Errorf("expected %q, got %q (raw %s)", helloBody, content, result)
336348
}
337349
if ct := extractStringField(t, result, "contentType"); ct != "text/plain" {
338350
t.Errorf("expected contentType text/plain, got %q", ct)
@@ -345,8 +357,8 @@ func runCloudStorageReadObjectTest(t *testing.T, bucket string) {
345357
if status != http.StatusOK {
346358
t.Fatalf("unexpected status %d: %s", status, result)
347359
}
348-
if decoded := decodeBase64Field(t, result, "content"); decoded != "hello" {
349-
t.Errorf("expected %q, got %q (raw %s)", "hello", decoded, result)
360+
if content := extractStringField(t, result, "content"); content != "hello" {
361+
t.Errorf("expected %q, got %q (raw %s)", "hello", content, result)
350362
}
351363
})
352364

@@ -356,8 +368,8 @@ func runCloudStorageReadObjectTest(t *testing.T, bucket string) {
356368
if status != http.StatusOK {
357369
t.Fatalf("unexpected status %d: %s", status, result)
358370
}
359-
if decoded := decodeBase64Field(t, result, "content"); decoded != "world" {
360-
t.Errorf("expected %q, got %q (raw %s)", "world", decoded, result)
371+
if content := extractStringField(t, result, "content"); content != "world" {
372+
t.Errorf("expected %q, got %q (raw %s)", "world", content, result)
361373
}
362374
})
363375

@@ -367,8 +379,8 @@ func runCloudStorageReadObjectTest(t *testing.T, bucket string) {
367379
if status != http.StatusOK {
368380
t.Fatalf("unexpected status %d: %s", status, result)
369381
}
370-
if decoded := decodeBase64Field(t, result, "content"); decoded != "world" {
371-
t.Errorf("expected %q, got %q (raw %s)", "world", decoded, result)
382+
if content := extractStringField(t, result, "content"); content != "world" {
383+
t.Errorf("expected %q, got %q (raw %s)", "world", content, result)
372384
}
373385
})
374386

@@ -419,8 +431,20 @@ func runCloudStorageReadObjectTest(t *testing.T, bucket string) {
419431
if status != http.StatusOK {
420432
t.Fatalf("unexpected status %d: %s", status, result)
421433
}
422-
if decoded := decodeBase64Field(t, result, "content"); decoded != "AAAAAAAAAA" {
423-
t.Errorf("expected 10 'A' bytes, got %q (raw %s)", decoded, result)
434+
if content := extractStringField(t, result, "content"); content != "AAAAAAAAAA" {
435+
t.Errorf("expected 10 'A' bytes, got %q (raw %s)", content, result)
436+
}
437+
})
438+
439+
t.Run("binary object returns agent error", func(t *testing.T) {
440+
result, status := invokeTool(t, "my-read-object",
441+
fmt.Sprintf(`{"bucket": %q, "object": %q}`, bucket, binaryObject))
442+
if status != http.StatusOK {
443+
t.Fatalf("expected 200 agent error, got status %d: %s", status, result)
444+
}
445+
lower := strings.ToLower(result)
446+
if !strings.Contains(lower, "binary") && !strings.Contains(lower, "non-text") && !strings.Contains(lower, "utf-8") {
447+
t.Errorf("expected binary-content error message, got %s", result)
424448
}
425449
})
426450
}
@@ -436,15 +460,3 @@ func extractStringField(t *testing.T, result, field string) string {
436460
v, _ := parsed[field].(string)
437461
return v
438462
}
439-
440-
// decodeBase64Field extracts a base64-encoded string field and returns its
441-
// decoded UTF-8 value, failing the test if decoding fails.
442-
func decodeBase64Field(t *testing.T, result, field string) string {
443-
t.Helper()
444-
encoded := extractStringField(t, result, field)
445-
decoded, err := base64.StdEncoding.DecodeString(encoded)
446-
if err != nil {
447-
t.Fatalf("failed to base64-decode field %q: %s (encoded=%s)", field, err, encoded)
448-
}
449-
return string(decoded)
450-
}

0 commit comments

Comments
 (0)