Guide · MCP Cloud Storage Integration

MCP Server GCP Cloud Storage — list, download, upload, signed URLs, and /health/gcs

Google Cloud Storage is the object store powering the GCP ecosystem — from ML training datasets to agent tool outputs. This guide covers building TypeScript MCP tools against GCS using the @google-cloud/storage SDK: Application Default Credentials and service account setup, list_objects, get_object, put_object, and delete_object tool implementations, generating signed URLs for temporary download access, and wiring a /health/gcs endpoint that calls bucket().getMetadata() to verify bucket connectivity and IAM permissions before AliveMCP detects silent credential failures.

TL;DR

Install @google-cloud/storage and authenticate via GOOGLE_APPLICATION_CREDENTIALS pointing to a service account JSON key — or rely on ADC when deployed to Cloud Run or GCE. Never expose bucket names in tool parameters. Use file.download() for small files and file.createReadStream() for large ones. Generate signed URLs with file.getSignedUrl() — requires iam.serviceAccounts.signBlob permission, which means the service account must be able to sign its own key. Health probe: await bucket.getMetadata() catches IAM errors (403), missing buckets (404), and network failures before your MCP server silently stops accessing GCS.

SDK setup and authentication

The @google-cloud/storage package is Google's official Node.js client for GCS. It handles authentication, retries, and multipart uploads automatically. Authentication follows the Application Default Credentials chain: service account key file → environment variable → metadata server (on GCP compute).

import { Storage } from '@google-cloud/storage';

// Option 1: Explicit service account key file
// Set GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
// The SDK picks it up automatically — no code change needed

// Option 2: Inline key (from secrets manager at startup)
const storage = new Storage({
  projectId: process.env.GCP_PROJECT_ID,
  credentials: JSON.parse(process.env.GCS_SERVICE_ACCOUNT_JSON ?? '{}')
});

// Option 3: ADC — works automatically on Cloud Run, GCE, GKE
// const storage = new Storage({ projectId: process.env.GCP_PROJECT_ID });

// Lock the bucket at server startup — never accept bucket names from callers
const BUCKET_NAME = process.env.GCS_BUCKET_NAME;
if (!BUCKET_NAME) throw new Error('GCS_BUCKET_NAME environment variable required');

const bucket = storage.bucket(BUCKET_NAME);

export { storage, bucket, BUCKET_NAME };

The ADC chain resolves credentials in this priority order: (1) GOOGLE_APPLICATION_CREDENTIALS env var pointing to a JSON key file; (2) gcloud CLI credentials (~/.config/gcloud/application_default_credentials.json); (3) the compute metadata server when running on GCP. For MCP servers deployed to Cloud Run or GKE, option 3 is the preferred approach — attach a service account to the Cloud Run service and grant it only the GCS permissions it needs. No key file to rotate or accidentally expose.

IAM roles for the MCP server's service account:

Role What it grants Use when
roles/storage.objectViewer Read objects and list bucket contents Read-only MCP servers
roles/storage.objectCreator Write objects (no read, no delete) Write-only ingest servers
roles/storage.objectAdmin Read, write, delete, list objects Full read-write MCP servers
roles/storage.legacyBucketReader List objects via bucket.getFiles() Required alongside objectViewer for list operations
roles/iam.serviceAccountTokenCreator Sign URLs on behalf of the service account Required for file.getSignedUrl() to work

Grant roles at the bucket level, not project level: gsutil iam ch serviceAccount:mcp-server@PROJECT.iam.gserviceaccount.com:roles/storage.objectAdmin gs://YOUR_BUCKET. Project-level grants give access to every bucket in the project — too broad for MCP servers.

Core GCS tool patterns

import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { bucket } from './gcs-client.js';

// Validate object names from caller input
function validateObjectName(name: string): void {
  if (!name || name.includes('..') || name.startsWith('/') || /[\0\r\n]/.test(name)) {
    throw new McpError(ErrorCode.InvalidParams, `Invalid GCS object name: ${name}`);
  }
}

// ---- list_objects ----
server.tool(
  'list_objects',
  {
    prefix: z.string().default(''),
    max_results: z.number().int().min(1).max(1000).default(100),
    page_token: z.string().optional()
  },
  async ({ prefix, max_results, page_token }) => {
    const [files, , metadata] = await bucket.getFiles({
      prefix,
      maxResults: max_results,
      pageToken: page_token
    });

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          objects: files.map(f => ({
            name: f.name,
            size: f.metadata.size,
            content_type: f.metadata.contentType,
            updated: f.metadata.updated,
            md5_hash: f.metadata.md5Hash,
            generation: f.metadata.generation  // GCS version number
          })),
          next_page_token: metadata?.pageToken ?? null,
          count: files.length
        }, null, 2)
      }]
    };
  }
);

// ---- get_object ----
server.tool(
  'get_object',
  {
    name: z.string().min(1).max(1024),
    encoding: z.enum(['utf-8', 'base64']).default('utf-8')
  },
  async ({ name, encoding }) => {
    validateObjectName(name);
    const file = bucket.file(name);

    try {
      const [contents] = await file.download();
      const text = encoding === 'base64'
        ? contents.toString('base64')
        : contents.toString('utf-8');

      const [metadata] = await file.getMetadata();

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            name,
            content: text,
            encoding,
            content_type: metadata.contentType,
            size: metadata.size,
            updated: metadata.updated,
            generation: metadata.generation
          })
        }]
      };
    } catch (err: any) {
      if (err.code === 404) {
        throw new McpError(ErrorCode.InvalidParams, `Object not found: ${name}`);
      }
      throw err;
    }
  }
);

// ---- put_object ----
server.tool(
  'put_object',
  {
    name: z.string().min(1).max(1024),
    content: z.string(),
    content_type: z.string().default('text/plain; charset=utf-8'),
    encoding: z.enum(['utf-8', 'base64']).default('utf-8')
  },
  async ({ name, content, content_type, encoding }) => {
    validateObjectName(name);
    const file = bucket.file(name);

    const body = encoding === 'base64'
      ? Buffer.from(content, 'base64')
      : Buffer.from(content, 'utf-8');

    await file.save(body, {
      contentType: content_type,
      resumable: body.length > 5 * 1024 * 1024  // resumable upload for files > 5 MB
    });

    const [metadata] = await file.getMetadata();

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          name,
          bytes: body.length,
          generation: metadata.generation,
          md5_hash: metadata.md5Hash
        })
      }]
    };
  }
);

// ---- delete_object ----
server.tool(
  'delete_object',
  {
    name: z.string().min(1).max(1024),
    generation: z.string().optional()
      .describe('Delete a specific generation (version). Omit to delete the live object.'),
    confirm: z.literal(true)
  },
  async ({ name, generation }) => {
    validateObjectName(name);
    const file = bucket.file(name, generation ? { generation } : undefined);

    try {
      await file.delete();
    } catch (err: any) {
      if (err.code === 404) {
        throw new McpError(ErrorCode.InvalidParams, `Object not found: ${name}`);
      }
      throw err;
    }

    return {
      content: [{ type: 'text', text: JSON.stringify({ deleted: name, generation: generation ?? 'live' }) }]
    };
  }
);

The generation parameter on delete_object enables conditional deletes — if you specify a generation number, GCS will only delete the object if it matches that exact version. This prevents a race condition where your tool reads an object, something else updates it, and then your delete removes the newer version instead of the one you intended to remove. For most MCP tools, passing generation is optional, but for tools operating on shared documents, it's a meaningful guard.

Signed URLs for temporary download and upload access

Signed URLs are time-limited, pre-authenticated URLs that let anyone with the URL access a specific GCS object without needing Google credentials. They're essential for MCP tools that need to give users download links without routing file content through the tool response.

GCS signed URLs require the service account to sign the URL using its own private key. When running on GCP with ADC (no key file), the service account must have the roles/iam.serviceAccountTokenCreator role to use the IAM signBlob API.

// ---- generate_download_url ----
server.tool(
  'generate_download_url',
  {
    name: z.string().min(1).max(1024),
    expires_in_seconds: z.number().int().min(60).max(604800).default(3600)
  },
  async ({ name, expires_in_seconds }) => {
    validateObjectName(name);
    const file = bucket.file(name);

    // Verify the object exists before signing a URL for it
    const [exists] = await file.exists();
    if (!exists) {
      throw new McpError(ErrorCode.InvalidParams, `Object not found: ${name}`);
    }

    const [url] = await file.getSignedUrl({
      action: 'read',
      expires: Date.now() + expires_in_seconds * 1000,
      version: 'v4'  // v4 signing is required for URLs > 7 days; use v4 consistently
    });

    const expiresAt = new Date(Date.now() + expires_in_seconds * 1000);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          url,
          name,
          expires_at: expiresAt.toISOString(),
          expires_in_seconds
        })
      }]
    };
  }
);

// ---- generate_upload_url ----
// Returns a signed URL that lets the caller PUT a file directly to GCS
server.tool(
  'generate_upload_url',
  {
    name: z.string().min(1).max(1024),
    content_type: z.string().default('application/octet-stream'),
    expires_in_seconds: z.number().int().min(60).max(3600).default(900)
  },
  async ({ name, content_type, expires_in_seconds }) => {
    validateObjectName(name);
    const file = bucket.file(name);

    const [url] = await file.getSignedUrl({
      action: 'write',
      contentType: content_type,
      expires: Date.now() + expires_in_seconds * 1000,
      version: 'v4'
    });

    const expiresAt = new Date(Date.now() + expires_in_seconds * 1000);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          upload_url: url,
          name,
          content_type,
          expires_at: expiresAt.toISOString(),
          instructions: 'PUT the file body to upload_url with Content-Type header matching content_type'
        })
      }]
    };
  }
);
Signing method When it works Requirement
Key file signing Always, when you have the private key JSON GOOGLE_APPLICATION_CREDENTIALS pointing to service account key
IAM signBlob API On GCP compute (Cloud Run, GCE, GKE) Service account needs roles/iam.serviceAccountTokenCreator on itself
Workload Identity GKE with Workload Identity enabled Kubernetes service account mapped to GCP service account with signBlob permission

If getSignedUrl() throws SigningError: Could not load the default credentials, you're running with ADC but without the iam.serviceAccounts.signBlob permission. Grant roles/iam.serviceAccountTokenCreator to the service account on itself: gcloud iam service-accounts add-iam-policy-binding SA_EMAIL --member="serviceAccount:SA_EMAIL" --role="roles/iam.serviceAccountTokenCreator".

Health endpoint: /health/gcs

import express from 'express';
import { bucket, BUCKET_NAME } from './gcs-client.js';

const app = express();

app.get('/health/gcs', async (_req, res) => {
  const start = Date.now();
  try {
    // getMetadata() verifies: (1) bucket exists, (2) credentials valid, (3) GCS reachable
    const [metadata] = await bucket.getMetadata();
    const latencyMs = Date.now() - start;

    res.json({
      status: 'ok',
      bucket: BUCKET_NAME,
      location: metadata.location,
      storage_class: metadata.storageClass,
      latency_ms: latencyMs
    });
  } catch (err: any) {
    const latencyMs = Date.now() - start;

    // err.code is the HTTP status: 403 = bad credentials, 404 = bucket missing
    res.status(503).json({
      status: 'error',
      error: err.message,
      code: err.code,
      elapsed_ms: latencyMs,
      // Common causes:
      // 403 — IAM credentials missing storage permissions or key revoked
      // 404 — Bucket does not exist or wrong project
      // ENOTFOUND — DNS failure (network issue or wrong project region)
    });
  }
});

app.listen(3001);
GCS failure mode getMetadata response Detected by /health/gcs
Service account key revoked 403 Forbidden Yes — returns 503
Bucket deleted 404 Not Found Yes — returns 503
GCS regional outage Timeout or 5xx Yes — returns 503 after SDK timeout
Missing storage.buckets.get IAM 403 Forbidden Yes — credential has list permission but not getMetadata
Object-level IAM denial 200 (bucket OK) No — bucket is reachable, specific object access is not verified

Note: bucket.getMetadata() requires the caller to have storage.buckets.get permission, which is included in roles/storage.legacyBucketReader and roles/storage.admin. If your service account only has roles/storage.objectViewer (object-level, not bucket-level), the health check will return 403 even though object reads work fine. In that case, use a lightweight object exists check instead: await bucket.file('.health-probe').exists() (create a placeholder file at deployment time).

Using the S3-compatible XML API with HMAC keys

GCS exposes an S3-compatible XML API at https://storage.googleapis.com, which lets you reuse the AWS SDK (@aws-sdk/client-s3) instead of the native GCS SDK. This is useful if you already have AWS S3 tools and want to add GCS support without writing a separate SDK integration.

import { S3Client } from '@aws-sdk/client-s3';

// GCS HMAC keys are NOT service account JSON keys —
// create them separately in the Cloud Console:
// IAM & Admin → Service Accounts → [your SA] → Keys → Add HMAC key

const gcsS3Client = new S3Client({
  region: 'auto',  // GCS ignores this but SDK requires it
  endpoint: 'https://storage.googleapis.com',
  credentials: {
    accessKeyId: process.env.GCS_HMAC_ACCESS_KEY!,      // starts with GOOG...
    secretAccessKey: process.env.GCS_HMAC_SECRET_KEY!
  },
  forcePathStyle: true  // Required for GCS XML API compatibility
});

// Now all @aws-sdk/client-s3 operations work against GCS:
// GetObjectCommand, PutObjectCommand, HeadBucketCommand, etc.

HMAC keys are separate from service account JSON keys — create them via the Cloud Console under IAM & Admin → Service Accounts → [your service account] → HMAC Keys tab. HMAC keys give S3-compatible access but don't support GCS-specific features like object generations, uniform bucket-level access policies, or customer-managed encryption keys (CMEK). Use the native SDK (@google-cloud/storage) if you need those features.

Frequently asked questions

How do I handle GCS objects larger than the MCP response size limit?

For files too large to include in tool content, return a signed read URL instead of file contents. The generate_download_url tool pattern above handles this. For files agents need to process in chunks (e.g., large CSVs), implement a get_object_range tool that accepts start_byte and end_byte parameters — use file.createReadStream({ start, end }) to read only the requested byte range. GCS supports HTTP Range requests natively, so partial reads don't transfer the whole file. For very large files (>1 GB), consider whether the agent should be operating on GCS directly or via a BigQuery export — BigQuery can query GCS Parquet/CSV files without loading them.

What's the difference between uniform and fine-grained bucket access?

GCS buckets can use either uniform bucket-level access (UBLA) or fine-grained (legacy ACL) access. With UBLA enabled, all access is controlled exclusively through IAM — there are no per-object ACLs. With fine-grained access, individual objects can have their own ACLs in addition to bucket IAM. For MCP servers, enable UBLA — it's simpler to reason about (one IAM policy controls everything), eliminates accidental public-read ACLs on individual objects, and is required if you want to use uniform IAM deny conditions. Check UBLA status: const [meta] = await bucket.getMetadata(); console.log(meta.iamConfiguration?.uniformBucketLevelAccess?.enabled). Enable it: await bucket.setMetadata({ iamConfiguration: { uniformBucketLevelAccess: { enabled: true } } }).

How do generation numbers work and when should I use them?

Every time you write an object to GCS, the object gets a new generation number — a 64-bit integer assigned by GCS. The live version of the object has the highest generation. If versioning is enabled on the bucket, previous generations are retained as non-current versions. In your MCP tools, the generation number is useful as an optimistic concurrency guard: read the object and record its generation, perform your computation, then write back with an ifGenerationMatch precondition — GCS will reject the write with 412 Precondition Failed if another writer updated the object between your read and write. This is important for MCP tools operating on shared configuration files or ledgers where two agent instances could write simultaneously. Implement it: await file.save(newContent, { preconditionOpts: { ifGenerationMatch: previousGeneration } }).

Can I test GCS tools locally without real GCP credentials?

Yes — use the GCS emulator. The @google-cloud/storage package supports an apiEndpoint option: const storage = new Storage({ apiEndpoint: 'http://localhost:9023', projectId: 'test-project' }). Run the emulator with docker run -p 9023:9023 oittaa/gcp-storage-emulator. The emulator accepts any credentials and stores objects in memory (or on disk with the --data-dir flag). For CI, add the emulator as a Docker service and set STORAGE_EMULATOR_HOST=http://localhost:9023 — the SDK automatically redirects to the emulator when that environment variable is set, no code changes needed.

Further reading

Know when your GCS credentials expire before your tools go silent

AliveMCP monitors your /health/gcs endpoint every 60 seconds and alerts you the moment a revoked service account key or IAM change breaks your bucket access — before users hit 403 errors from your MCP tools.

Start monitoring free