Files
wehub-resource-sync e04ed9c211
CF: Deploy Dev Docs / deploy (push) Has been cancelled
Sync Labels / build (push) Has been cancelled
tests / unit tests (macos-latest) (push) Has been cancelled
tests / unit tests (windows-latest) (push) Has been cancelled
tests / unit tests (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:32:45 +08:00

602 lines
19 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cloudstorage
import (
"context"
"errors"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
"cloud.google.com/go/storage"
"github.com/goccy/go-yaml"
"github.com/googleapis/mcp-toolbox/internal/sources"
"github.com/googleapis/mcp-toolbox/internal/tools/cloudstorage/cloudstoragecommon"
"github.com/googleapis/mcp-toolbox/internal/util"
"go.opentelemetry.io/otel/trace"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
)
const SourceType string = "cloud-storage"
// defaultMaxReadBytes caps the payload ReadObject will return per call,
// protecting the server from OOM and keeping LLM contexts manageable. Objects
// or ranges exceeding this are rejected with ErrReadSizeLimitExceeded.
const defaultMaxReadBytes int64 = 8 << 20 // 8 MiB
// validate interface
var _ sources.SourceConfig = Config{}
func init() {
if !sources.Register(SourceType, newConfig) {
panic(fmt.Sprintf("source type %q already registered", SourceType))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (sources.SourceConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type Config struct {
Name string `yaml:"name" validate:"required"`
Type string `yaml:"type" validate:"required"`
Project string `yaml:"project" validate:"required"`
AllowedBuckets []string `yaml:"allowedBuckets,omitempty"`
AllowedLocalRoots []string `yaml:"allowedLocalRoots,omitempty"`
}
func (r Config) SourceConfigType() string {
return SourceType
}
func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) {
client, err := initGCSClient(ctx, tracer, r.Name, r.Project)
if err != nil {
return nil, fmt.Errorf("unable to create client: %w", err)
}
s := &Source{
Config: r,
client: client,
}
return s, nil
}
var _ sources.Source = &Source{}
type Source struct {
Config
client *storage.Client
}
func (s *Source) validateBucket(bucket string) error {
if len(s.AllowedBuckets) == 0 {
return nil
}
for _, b := range s.AllowedBuckets {
if b == bucket {
return nil
}
}
return fmt.Errorf("bucket %q is not allowed by source %q configuration", bucket, s.Name)
}
func (s *Source) validateLocalPath(p string) error {
clean, err := cloudstoragecommon.ValidateLocalPath(p)
if err != nil {
return err
}
if len(s.AllowedLocalRoots) == 0 {
return nil
}
for _, root := range s.AllowedLocalRoots {
if isUnderRoot(clean, root) {
return nil
}
}
return fmt.Errorf("local path %q is not under any allowed local roots for source %q", p, s.Name)
}
func isUnderRoot(target, root string) bool {
target = filepath.Clean(target)
root = filepath.Clean(root)
if target == root {
return true
}
if root == string(filepath.Separator) {
return true
}
sep := string(filepath.Separator)
if !strings.HasSuffix(root, sep) {
root += sep
}
return strings.HasPrefix(target, root)
}
func (s *Source) SourceType() string {
return SourceType
}
func (s *Source) ToConfig() sources.SourceConfig {
return s.Config
}
func (s *Source) StorageClient() *storage.Client {
return s.client
}
func (s *Source) GetProjectID() string {
return s.Project
}
// ListObjects lists objects in a bucket with optional prefix and delimiter filtering.
// maxResults == 0 means return up to one page as returned by the GCS API. A non-empty
// pageToken resumes listing from a prior call. The returned map contains "objects"
// (raw *storage.ObjectAttrs entries as returned by the GCS client), "prefixes"
// (common prefixes when a delimiter is set), and "nextPageToken" (empty when
// there are no more results).
func (s *Source) ListObjects(ctx context.Context, bucket, prefix, delimiter string, maxResults int, pageToken string) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
it := s.client.Bucket(bucket).Objects(ctx, &storage.Query{
Prefix: prefix,
Delimiter: delimiter,
})
// iterator.NewPager errors on pageSize <= 0; the tool layer already rejects
// values above the GCS per-page cap of 1000, so any positive value is safe.
ps := maxResults
if ps <= 0 {
ps = 1000
}
pager := iterator.NewPager(it, ps, pageToken)
var attrsPage []*storage.ObjectAttrs
nextPageToken, err := pager.NextPage(&attrsPage)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %q: %w", bucket, err)
}
objects := make([]*storage.ObjectAttrs, 0, len(attrsPage))
prefixes := make([]string, 0)
for _, attrs := range attrsPage {
if attrs.Prefix != "" {
prefixes = append(prefixes, attrs.Prefix)
continue
}
objects = append(objects, attrs)
}
return map[string]any{
"objects": objects,
"prefixes": prefixes,
"nextPageToken": nextPageToken,
}, nil
}
// ReadObject fetches an object's bytes and returns a map with the UTF-8
// content, its content type, and the number of bytes read. offset and length
// follow storage.ObjectHandle.NewRangeReader semantics: length == -1 means
// "read to end of object"; a negative offset means "suffix from end" (in
// which case length must be -1). Reads larger than defaultMaxReadBytes are
// rejected with cloudstoragecommon.ErrReadSizeLimitExceeded so the caller can
// narrow the range. Objects whose bytes are not valid UTF-8 are rejected
// with cloudstoragecommon.ErrBinaryContent.
//
// TODO: MCP tool results only carry text today, so we gate this tool on
// utf8.Valid. When the toolbox supports non-text MCP content (embedded
// resources, images, blobs), expand this to detect content type and return
// binary payloads natively.
func (s *Source) ReadObject(ctx context.Context, bucket, object string, offset, length int64) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
reader, err := s.client.Bucket(bucket).Object(object).NewRangeReader(ctx, offset, length)
if err != nil {
return nil, fmt.Errorf("failed to open object %q in bucket %q: %w", object, bucket, err)
}
defer reader.Close()
if remain := reader.Remain(); remain > defaultMaxReadBytes {
return nil, fmt.Errorf("object %q: %d bytes exceeds %d byte limit: %w",
object, remain, defaultMaxReadBytes,
cloudstoragecommon.ErrReadSizeLimitExceeded)
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read object %q in bucket %q: %w", object, bucket, err)
}
if !utf8.Valid(data) {
return nil, fmt.Errorf("object %q in bucket %q: %w", object, bucket,
cloudstoragecommon.ErrBinaryContent)
}
return map[string]any{
"content": string(data),
"contentType": reader.Attrs.ContentType,
"size": len(data),
}, nil
}
// ListBuckets lists buckets in a project. When project is empty, the source's
// configured project is used. maxResults == 0 returns up to the GCS per-page
// default (1000). A non-empty pageToken resumes listing. The returned map
// contains "buckets" ([]*storage.BucketAttrs) and "nextPageToken" (empty when
// there are no more results).
func (s *Source) ListBuckets(ctx context.Context, project, prefix string, maxResults int, pageToken string) (map[string]any, error) {
if project == "" {
project = s.Project
}
it := s.client.Buckets(ctx, project)
if prefix != "" {
it.Prefix = prefix
}
ps := maxResults
if ps <= 0 {
ps = 1000
}
pager := iterator.NewPager(it, ps, pageToken)
var buckets []*storage.BucketAttrs
nextPageToken, err := pager.NextPage(&buckets)
if err != nil {
return nil, fmt.Errorf("failed to list buckets in project %q: %w", project, err)
}
return map[string]any{
"buckets": buckets,
"nextPageToken": nextPageToken,
}, nil
}
// CreateBucket creates a Cloud Storage bucket and returns its freshly-read
// metadata. When project is empty, the source's configured project is used.
// When location is empty, Cloud Storage applies its service default.
func (s *Source) CreateBucket(ctx context.Context, bucket, project, location string, uniformBucketLevelAccess bool) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
if project == "" {
project = s.Project
}
attrs := &storage.BucketAttrs{Location: location}
if uniformBucketLevelAccess {
attrs.UniformBucketLevelAccess = storage.UniformBucketLevelAccess{Enabled: true}
}
bkt := s.client.Bucket(bucket)
if err := bkt.Create(ctx, project, attrs); err != nil {
return nil, fmt.Errorf("failed to create bucket %q in project %q: %w", bucket, project, err)
}
createdAttrs, err := bkt.Attrs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get metadata for created bucket %q: %w", bucket, err)
}
return map[string]any{
"bucket": bucket,
"created": true,
"metadata": createdAttrs,
}, nil
}
// GetBucketMetadata returns raw bucket metadata from the Cloud Storage client.
func (s *Source) GetBucketMetadata(ctx context.Context, bucket string) (*storage.BucketAttrs, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
attrs, err := s.client.Bucket(bucket).Attrs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get metadata for bucket %q: %w", bucket, err)
}
return attrs, nil
}
// GetBucketIAMPolicy returns bucket IAM bindings in a stable, agent-friendly
// shape while preserving conditional bindings when present.
func (s *Source) GetBucketIAMPolicy(ctx context.Context, bucket string) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
policy, err := s.client.Bucket(bucket).IAM().Policy(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get IAM policy for bucket %q: %w", bucket, err)
}
bindings := make([]map[string]any, 0)
if policy != nil && policy.InternalProto != nil {
for _, binding := range policy.InternalProto.Bindings {
members := append([]string(nil), binding.Members...)
sort.Strings(members)
out := map[string]any{
"role": binding.Role,
"members": members,
}
if binding.Condition != nil {
out["condition"] = map[string]any{
"title": binding.Condition.Title,
"description": binding.Condition.Description,
"expression": binding.Condition.Expression,
}
}
bindings = append(bindings, out)
}
}
sort.Slice(bindings, func(i, j int) bool {
return fmt.Sprint(bindings[i]["role"]) < fmt.Sprint(bindings[j]["role"])
})
return map[string]any{
"bucket": bucket,
"bindings": bindings,
}, nil
}
// GetObjectMetadata returns the raw *storage.ObjectAttrs for an object, giving
// callers the full field set the GCS client exposes (name, size, contentType,
// hashes, timestamps, user metadata, etc.) without a curated subset.
func (s *Source) GetObjectMetadata(ctx context.Context, bucket, object string) (*storage.ObjectAttrs, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
attrs, err := s.client.Bucket(bucket).Object(object).Attrs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %q in bucket %q: %w", object, bucket, err)
}
return attrs, nil
}
// DownloadObject streams a GCS object to destination on the local filesystem.
// Unlike ReadObject there is no size cap and no UTF-8 check — the bytes go to
// disk, not into the LLM context, so binary payloads are fine. When overwrite
// is false, a pre-existing destination returns cloudstoragecommon.ErrDestinationExists
// (mapped to AgentError so the caller can retry with overwrite=true).
func (s *Source) DownloadObject(ctx context.Context, bucket, object, destination string, overwrite bool) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
if err := s.validateLocalPath(destination); err != nil {
return nil, err
}
reader, err := s.client.Bucket(bucket).Object(object).NewReader(ctx)
if err != nil {
return nil, fmt.Errorf("failed to open object %q in bucket %q: %w", object, bucket, err)
}
defer reader.Close()
flags := os.O_WRONLY | os.O_CREATE
if overwrite {
flags |= os.O_TRUNC
} else {
flags |= os.O_EXCL
}
f, err := os.OpenFile(destination, flags, 0o644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("destination %q: %w", destination, cloudstoragecommon.ErrDestinationExists)
}
return nil, fmt.Errorf("failed to open destination %q: %w", destination, err)
}
n, err := io.Copy(f, reader)
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to write object %q to %q: %w", object, destination, err)
}
if err := f.Close(); err != nil {
return nil, fmt.Errorf("failed to close destination %q: %w", destination, err)
}
return map[string]any{
"destination": destination,
"bytes": n,
"contentType": reader.Attrs.ContentType,
}, nil
}
// UploadObject streams a local file into a GCS object. When contentType is
// empty, mime.TypeByExtension is consulted; if inference still fails the
// writer's ContentType is left unset so GCS content-sniffs the first 512 bytes.
// The returned contentType is the post-Close value from w.Attrs(), i.e. what
// GCS actually recorded.
func (s *Source) UploadObject(ctx context.Context, bucket, object, source, contentType string) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
if err := s.validateLocalPath(source); err != nil {
return nil, err
}
f, err := os.Open(source)
if err != nil {
return nil, fmt.Errorf("failed to open source %q: %w", source, err)
}
defer f.Close()
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(source))
}
w := s.client.Bucket(bucket).Object(object).NewWriter(ctx)
if contentType != "" {
w.ContentType = contentType
}
n, err := io.Copy(w, f)
if err != nil {
_ = w.Close()
return nil, fmt.Errorf("failed to copy %q to object %q in bucket %q: %w", source, object, bucket, err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("failed to finalize upload of %q to %q/%q: %w", source, bucket, object, err)
}
attrs := w.Attrs()
finalContentType := ""
if attrs != nil {
finalContentType = attrs.ContentType
}
return map[string]any{
"bucket": bucket,
"object": object,
"bytes": n,
"contentType": finalContentType,
}, nil
}
// WriteObject writes text content directly into a GCS object. When contentType
// is empty, the writer's ContentType is left unset so Cloud Storage detects it
// from the first 512 bytes. The returned contentType is the post-Close value
// from w.Attrs(), i.e. what GCS actually recorded.
func (s *Source) WriteObject(ctx context.Context, bucket, object, content, contentType string) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
w := s.client.Bucket(bucket).Object(object).NewWriter(ctx)
if contentType != "" {
w.ContentType = contentType
}
n, err := io.WriteString(w, content)
if err != nil {
_ = w.Close()
return nil, fmt.Errorf("failed to write content to object %q in bucket %q: %w", object, bucket, err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("failed to finalize write to %q/%q: %w", bucket, object, err)
}
attrs := w.Attrs()
finalContentType := ""
if attrs != nil {
finalContentType = attrs.ContentType
}
return map[string]any{
"bucket": bucket,
"object": object,
"bytes": n,
"contentType": finalContentType,
}, nil
}
// CopyObject copies an object to a destination object. The destination may be
// in the same bucket or a different bucket. Existing destination objects are
// replaced, matching Cloud Storage's copy semantics without preconditions.
func (s *Source) CopyObject(ctx context.Context, sourceBucket, sourceObject, destinationBucket, destinationObject string) (map[string]any, error) {
if err := s.validateBucket(sourceBucket); err != nil {
return nil, err
}
if err := s.validateBucket(destinationBucket); err != nil {
return nil, err
}
src := s.client.Bucket(sourceBucket).Object(sourceObject)
dst := s.client.Bucket(destinationBucket).Object(destinationObject)
attrs, err := dst.CopierFrom(src).Run(ctx)
if err != nil {
return nil, fmt.Errorf("failed to copy %q/%q to %q/%q: %w", sourceBucket, sourceObject, destinationBucket, destinationObject, err)
}
return map[string]any{
"sourceBucket": sourceBucket,
"sourceObject": sourceObject,
"destinationBucket": destinationBucket,
"destinationObject": destinationObject,
"bytes": attrs.Size,
"contentType": attrs.ContentType,
}, nil
}
// MoveObject atomically renames or moves an object within the same bucket using
// Cloud Storage's native move API. Cross-bucket moves should be modeled as
// CopyObject followed by DeleteObject.
func (s *Source) MoveObject(ctx context.Context, bucket, sourceObject, destinationObject string) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
attrs, err := s.client.Bucket(bucket).Object(sourceObject).Move(ctx, storage.MoveObjectDestination{Object: destinationObject})
if err != nil {
return nil, fmt.Errorf("failed to move %q to %q in bucket %q: %w", sourceObject, destinationObject, bucket, err)
}
return map[string]any{
"bucket": bucket,
"sourceObject": sourceObject,
"destinationObject": destinationObject,
"bytes": attrs.Size,
"contentType": attrs.ContentType,
}, nil
}
// DeleteObject deletes a GCS object.
func (s *Source) DeleteObject(ctx context.Context, bucket, object string) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
if err := s.client.Bucket(bucket).Object(object).Delete(ctx); err != nil {
return nil, fmt.Errorf("failed to delete object %q in bucket %q: %w", object, bucket, err)
}
return map[string]any{
"bucket": bucket,
"object": object,
"deleted": true,
}, nil
}
// DeleteBucket deletes an empty Cloud Storage bucket.
func (s *Source) DeleteBucket(ctx context.Context, bucket string) (map[string]any, error) {
if err := s.validateBucket(bucket); err != nil {
return nil, err
}
if err := s.client.Bucket(bucket).Delete(ctx); err != nil {
return nil, fmt.Errorf("failed to delete bucket %q: %w", bucket, err)
}
return map[string]any{
"bucket": bucket,
"deleted": true,
}, nil
}
func initGCSClient(ctx context.Context, tracer trace.Tracer, name, project string) (*storage.Client, error) {
//nolint:all // Reassigned ctx
ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceType, name)
defer span.End()
userAgent, err := util.UserAgentFromContext(ctx)
if err != nil {
return nil, err
}
client, err := storage.NewClient(ctx, option.WithUserAgent(userAgent))
if err != nil {
return nil, fmt.Errorf("unable to create storage.NewClient for project %q: %w", project, err)
}
return client, nil
}