Guide · Cloud Storage
MCP Tools for Google Cloud Storage — ADC credentials, signed URLs, uniform bucket access, generation numbers
Three GCS behaviours catch developers building MCP storage tools: signed URLs require the service account to have the iam.serviceAccounts.signBlob permission — on GCE VMs and GKE pods using the default compute service account, that permission is not granted by default; calling file.getSignedUrl() returns a "SigningError: Cannot sign data without `auth.credentials.private_key`" error, and the fix is to add the Service Account Token Creator role to the service account or use a downloaded service account key JSON for signing; uniform bucket-level access removes per-object ACLs permanently — once iamConfiguration.uniformBucketLevelAccess.enabled is set to true, per-object ACL calls return errors and the setting cannot be reverted after 90 days, so MCP servers should use IAM bindings on the bucket (not file.makePublic()) for access control; and object versioning creates a new generation on every write, not a new key — storage.bucket('b').file('key').save(data) on a versioning-enabled bucket does not overwrite the previous object; it creates a new generation number, and the previous content is accessible via file.get({ generation: N }) until deleted explicitly.
TL;DR
Client: new Storage({ projectId, keyFilename }) or ADC with new Storage(). Upload: bucket.file(key).save(data, { contentType }). Download: file.download() → Buffer. Signed URL: file.getSignedUrl({ action: 'read', expires: Date }). List: bucket.getFiles({ prefix, delimiter }). Health: bucket.exists() — [true] = accessible, error = permission denied or not found.
Authentication — Application Default Credentials vs service account key
The @google-cloud/storage library loads credentials from Application Default Credentials (ADC): the GOOGLE_APPLICATION_CREDENTIALS environment variable pointing to a service account JSON key file → user credentials from gcloud auth application-default login → the metadata server on GCE/GKE/Cloud Run/App Engine. For local development, either run gcloud auth application-default login or set GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json. The common error "Could not load the default credentials" means neither path produced a credential.
import { Storage } from '@google-cloud/storage';
// ADC — works on GCP infrastructure and after 'gcloud auth application-default login'
const storage = new Storage({ projectId: process.env.GCP_PROJECT_ID });
// Explicit key file — for environments where ADC is not available
const storageWithKey = new Storage({
projectId: process.env.GCP_PROJECT_ID,
keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS,
// or pass key JSON inline:
// credentials: JSON.parse(process.env.GCS_SERVICE_ACCOUNT_JSON!),
});
const bucket = storage.bucket(process.env.GCS_BUCKET!);
// Detect missing ADC at startup before accepting MCP connections
async function verifyGCSCredentials() {
try {
const [exists] = await bucket.exists();
if (!exists) throw new Error(`Bucket ${process.env.GCS_BUCKET} not found`);
console.log('GCS credentials verified');
} catch (err: any) {
if (err.code === 401 || err.message.includes('Could not load the default credentials')) {
throw new Error(
'GCS: no credentials found. Set GOOGLE_APPLICATION_CREDENTIALS or run ' +
'`gcloud auth application-default login`'
);
}
throw err;
}
}
On Cloud Run and GKE with Workload Identity, the metadata server provides credentials automatically — no key file or env var is needed, and the workload identity service account controls what GCS buckets it can access. Prefer Workload Identity over key files in production: key files can be leaked, while Workload Identity credentials are never materialised as a file and are automatically rotated.
Uploading and downloading objects
The GCS Node SDK file.save() accepts a Buffer, string, or readable stream. For streaming uploads (e.g., forwarding an incoming request body directly to GCS), use file.createWriteStream() and pipe into it. GCS does not have S3's 5 GB single-operation limit — single-shot uploads can be up to 5 TB via the resumable upload protocol, which the SDK uses automatically for objects over 5 MB.
// Upload a Buffer
async function uploadBuffer(key: string, data: Buffer, contentType: string) {
const file = bucket.file(key);
await file.save(data, {
contentType,
metadata: {
// Custom metadata — arbitrary key-value pairs (all values are strings)
uploadedBy: 'mcp-server',
sessionId: 'sess_abc',
},
// Resumable uploads (default for >5MB) — add progress events
resumable: data.length > 5 * 1024 * 1024,
});
return { bucket: bucket.name, name: key };
}
// Upload from a stream (e.g., an HTTP response body)
async function uploadStream(
key: string,
readableStream: NodeJS.ReadableStream,
contentType: string
): Promise {
return new Promise((resolve, reject) => {
const writeStream = bucket.file(key).createWriteStream({
contentType,
resumable: false, // set true for large files to enable automatic retry
});
readableStream
.pipe(writeStream)
.on('error', reject)
.on('finish', resolve);
});
}
// Download to Buffer
async function downloadBuffer(key: string): Promise {
const [contents] = await bucket.file(key).download();
return contents;
}
// Download to stream (for large objects)
async function downloadStream(key: string): Promise {
return bucket.file(key).createReadStream();
}
// Copy within GCS (server-side — no download/re-upload bandwidth)
async function copyObject(srcKey: string, destKey: string) {
await bucket.file(srcKey).copy(bucket.file(destKey));
}
Signed URLs — V4 signing and the signBlob permission
GCS signed URLs use HMAC-SHA256 with the service account's private key. The file.getSignedUrl() call requires the private key to be available — either from a downloaded service account JSON key or via the IAM signBlob API. On GCE/GKE using the default compute service account, the private key is not directly available, so the SDK calls IAM.signBlob on your behalf. This requires the service account to have the roles/iam.serviceAccountTokenCreator role on itself, which is not granted by default.
// Signed download URL (V4, default for Node SDK >=6)
async function getDownloadUrl(key: string, expiresInMinutes = 60): Promise {
const [url] = await bucket.file(key).getSignedUrl({
version: 'v4',
action: 'read',
expires: Date.now() + expiresInMinutes * 60 * 1000,
});
return url;
}
// Signed upload URL — client can PUT directly to GCS
async function getUploadUrl(key: string, contentType: string, expiresInMinutes = 15): Promise {
const [url] = await bucket.file(key).getSignedUrl({
version: 'v4',
action: 'write',
expires: Date.now() + expiresInMinutes * 60 * 1000,
contentType, // client must send matching Content-Type header on the PUT
});
return url;
}
// If running on GCE without explicit credentials, signBlob requires the SA to have
// roles/iam.serviceAccountTokenCreator on itself. Grant it via:
// gcloud iam service-accounts add-iam-policy-binding SA_EMAIL \
// --role=roles/iam.serviceAccountTokenCreator \
// --member="serviceAccount:SA_EMAIL"
//
// Alternative: instantiate Storage with an explicit keyFilename for signing
const storageForSigning = new Storage({
projectId: process.env.GCP_PROJECT_ID,
keyFilename: process.env.SIGNING_KEY_FILE, // explicit SA key for signing
});
V4 signed URLs expire in at most 7 days (604800 seconds). V2 URLs (deprecated) expire in at most 7 days as well. For sharing links beyond 7 days, use public object ACLs (fine-grained ACL buckets) or publish the object publicly and share the https://storage.googleapis.com/bucket/key URL directly. On uniform access control buckets, allUsers bucket IAM binding is the equivalent of public read.
Object versioning and generation numbers
When bucket versioning is enabled, every upload creates a new generation — an integer that monotonically increases. The "live" version is the one with the highest generation number that isn't marked as a noncurrent version. To access a specific historical version, pass { generation: N } to file operations. Deleting a versioned object (without specifying a generation) inserts a delete marker as the new live version — the content is still accessible by generation number until you explicitly delete all generations.
// List all versions of an object (including noncurrent)
async function listVersions(key: string) {
const [files] = await bucket.getFiles({
prefix: key,
versions: true, // include noncurrent generations
});
return files
.filter(f => f.name === key)
.map(f => ({
generation: f.generation,
size: f.metadata.size,
timeCreated: f.metadata.timeCreated,
updated: f.metadata.updated,
}));
}
// Download a specific historical version
async function downloadVersion(key: string, generation: string | number): Promise {
const [contents] = await bucket.file(key, { generation: String(generation) }).download();
return contents;
}
// Restore a previous version (copy noncurrent generation to live)
async function restoreVersion(key: string, generation: string | number) {
const src = bucket.file(key, { generation: String(generation) });
await src.copy(bucket.file(key)); // copies to a new live generation
}
// Conditionally write only if the expected generation matches (optimistic concurrency)
async function conditionalWrite(key: string, data: Buffer, expectedGeneration?: number) {
const file = bucket.file(key);
const opts = expectedGeneration !== undefined
? { preconditionOpts: { ifGenerationMatch: expectedGeneration } }
: { preconditionOpts: { ifGenerationMatch: 0 } }; // 0 = expect no live version
await file.save(data, { ...opts, contentType: 'application/octet-stream' });
// Throws 412 Precondition Failed if generation doesn't match — concurrent write detected
}
Health probe — bucket existence and IAM permissions
bucket.exists() is the minimal GCS health check. It calls HEAD https://storage.googleapis.com/storage/v1/b/{bucket} which requires storage.buckets.get permission. If you only need to verify connectivity and object-level access, use bucket.file('health-check.txt').exists() which requires storage.objects.get instead of bucket metadata read.
async function checkGCSHealth(): Promise
For MCP servers that only have object-level access (not bucket metadata access), skip bucket.exists() and start with file.exists() on a known sentinel object, or attempt a write and catch 403. The IAM error code for insufficient permissions is 403 with PERMISSION_DENIED as the status — distinct from 404 NOT_FOUND.
Related guides
- MCP Tools for AWS S3 — presigned URLs, multipart upload, credential chain, lifecycle rules
- MCP Tools for Azure Blob Storage — managed identity, SAS tokens, block vs append blobs
- MCP Tools for Cloudflare R2 — S3 API compatibility, Workers binding, egress-free storage
- MCP Tools for MinIO — self-hosted S3 API, bucket policies, presigned URL generation
- MCP server health check patterns
- MCP server uptime monitoring