Guide · MCP Cloud Storage Integration

MCP Server Azure Blob Storage — list blobs, download, upload, SAS URLs, and /health/azure-blob

Azure Blob Storage is the object store at the center of the Azure ecosystem — backing Azure ML datasets, Function App outputs, and enterprise data lakes. This guide covers building TypeScript MCP tools against Azure Blob Storage using the @azure/storage-blob SDK: DefaultAzureCredential vs connection string authentication, list_blobs, get_blob, put_blob, and delete_blob tool implementations, generating SAS URLs for temporary access, understanding access tier traps (Hot/Cool/Archive), and wiring a /health/azure-blob endpoint via ContainerClient.getProperties() before AliveMCP catches silent credential failures.

TL;DR

Use @azure/storage-blob with DefaultAzureCredential from @azure/identity for production (works with managed identity, environment variables, and Visual Studio Code — no connection string needed). Reserve connection strings for local dev only, as they embed the account key which grants full storage account access. For SAS URL generation from code, you need a StorageSharedKeyCredential (account key) — there is no way to generate SAS tokens using managed identity alone without calling the User Delegation Key API first. Health probe: await containerClient.getProperties() catches credential errors, missing containers, and network failures before your MCP tools start returning 404 silently.

SDK setup and authentication

The @azure/storage-blob package is the official Azure SDK for Blob Storage. Authentication is handled separately by @azure/identity, which provides DefaultAzureCredential — a credential chain that tries multiple auth methods in order.

import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob';
import { DefaultAzureCredential } from '@azure/identity';

const ACCOUNT_NAME = process.env.AZURE_STORAGE_ACCOUNT_NAME!;
const CONTAINER_NAME = process.env.AZURE_STORAGE_CONTAINER_NAME!;
if (!ACCOUNT_NAME || !CONTAINER_NAME) {
  throw new Error('AZURE_STORAGE_ACCOUNT_NAME and AZURE_STORAGE_CONTAINER_NAME are required');
}

// Option 1: DefaultAzureCredential — recommended for production
// Credential chain (tried in order):
//   - EnvironmentCredential: AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET
//   - WorkloadIdentityCredential: AKS/Azure Container Apps
//   - ManagedIdentityCredential: App Service, Azure Functions, ACA
//   - AzureCliCredential: local dev with `az login`
//   - VisualStudioCodeCredential: VS Code Azure extension
const credential = new DefaultAzureCredential();
const blobServiceClient = new BlobServiceClient(
  `https://${ACCOUNT_NAME}.blob.core.windows.net`,
  credential
);

// Option 2: Connection string — local dev only; never commit to source
// const blobServiceClient = BlobServiceClient.fromConnectionString(
//   process.env.AZURE_STORAGE_CONNECTION_STRING!
// );

// Lock the container at startup — never accept container names from callers
const containerClient = blobServiceClient.getContainerClient(CONTAINER_NAME);

export { blobServiceClient, containerClient, ACCOUNT_NAME, CONTAINER_NAME };

Azure RBAC roles for the MCP server's managed identity or service principal:

Role What it grants Scope
Storage Blob Data Reader Read and list blobs Storage account or container level
Storage Blob Data Contributor Read, write, delete blobs and list containers Storage account or container level
Storage Blob Data Owner Full access including POSIX ACL management Storage account level — overly broad for MCP servers
Storage Blob Delegator Get User Delegation Key for SAS token generation Required for generating SAS tokens via managed identity

Grant roles at the container level, not the storage account level: az role assignment create --assignee PRINCIPAL_ID --role "Storage Blob Data Contributor" --scope "/subscriptions/SUBSCRIPTION/resourceGroups/RG/providers/Microsoft.Storage/storageAccounts/ACCOUNT/blobServices/default/containers/CONTAINER".

Core blob tool patterns

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

function validateBlobName(name: string): void {
  if (!name || name.includes('..') || /[\0\r\n]/.test(name)) {
    throw new McpError(ErrorCode.InvalidParams, `Invalid blob name: ${name}`);
  }
}

// ---- list_blobs ----
server.tool(
  'list_blobs',
  {
    prefix: z.string().default(''),
    max_results: z.number().int().min(1).max(5000).default(100)
  },
  async ({ prefix, max_results }) => {
    const blobs: object[] = [];
    let count = 0;

    for await (const item of containerClient.listBlobsFlat({ prefix })) {
      if (count >= max_results) break;
      blobs.push({
        name: item.name,
        size: item.properties.contentLength,
        content_type: item.properties.contentType,
        last_modified: item.properties.lastModified?.toISOString(),
        access_tier: item.properties.accessTier,  // Hot, Cool, or Archive
        etag: item.properties.etag
      });
      count++;
    }

    return {
      content: [{ type: 'text', text: JSON.stringify({ blobs, count }, null, 2) }]
    };
  }
);

// ---- get_blob ----
server.tool(
  'get_blob',
  {
    name: z.string().min(1).max(1024),
    encoding: z.enum(['utf-8', 'base64']).default('utf-8')
  },
  async ({ name, encoding }) => {
    validateBlobName(name);
    const blobClient = containerClient.getBlobClient(name);

    try {
      const downloadResponse = await blobClient.download(0);

      if (!downloadResponse.readableStreamBody) {
        throw new McpError(ErrorCode.InternalError, 'Empty response from Azure Blob Storage');
      }

      // Collect stream chunks
      const chunks: Buffer[] = [];
      for await (const chunk of downloadResponse.readableStreamBody) {
        chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk));
      }
      const buffer = Buffer.concat(chunks);

      const content = encoding === 'base64'
        ? buffer.toString('base64')
        : buffer.toString('utf-8');

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            name,
            content,
            encoding,
            content_type: downloadResponse.contentType,
            content_length: downloadResponse.contentLength,
            last_modified: downloadResponse.lastModified?.toISOString(),
            etag: downloadResponse.etag
          })
        }]
      };
    } catch (err: any) {
      // Archive-tier blobs return 409 Conflict — must be rehydrated first
      if (err.statusCode === 409 && err.code === 'BlobArchived') {
        throw new McpError(
          ErrorCode.InvalidParams,
          `Blob "${name}" is in Archive tier and cannot be read. Rehydrate it first by calling rehydrate_blob.`
        );
      }
      if (err.statusCode === 404) {
        throw new McpError(ErrorCode.InvalidParams, `Blob not found: ${name}`);
      }
      throw err;
    }
  }
);

// ---- put_blob ----
server.tool(
  'put_blob',
  {
    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'),
    access_tier: z.enum(['Hot', 'Cool']).default('Hot')
      .describe('Storage access tier. Archive cannot be set at upload time.')
  },
  async ({ name, content, content_type, encoding, access_tier }) => {
    validateBlobName(name);
    const blockBlobClient = containerClient.getBlockBlobClient(name);

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

    const response = await blockBlobClient.uploadData(body, {
      blobHTTPHeaders: { blobContentType: content_type },
      tier: access_tier
    });

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          name,
          bytes: body.length,
          etag: response.etag,
          access_tier
        })
      }]
    };
  }
);

// ---- delete_blob ----
server.tool(
  'delete_blob',
  {
    name: z.string().min(1).max(1024),
    confirm: z.literal(true),
    delete_snapshots: z.boolean().default(false)
      .describe('Also delete all snapshots. If false and snapshots exist, delete fails.')
  },
  async ({ name, delete_snapshots }) => {
    validateBlobName(name);
    const blobClient = containerClient.getBlobClient(name);

    try {
      await blobClient.delete({
        deleteSnapshots: delete_snapshots ? 'include' : undefined
      });
    } catch (err: any) {
      if (err.statusCode === 404) {
        throw new McpError(ErrorCode.InvalidParams, `Blob not found: ${name}`);
      }
      // 409 — blob has snapshots and delete_snapshots is false
      if (err.statusCode === 409) {
        throw new McpError(
          ErrorCode.InvalidParams,
          `Blob "${name}" has snapshots. Pass delete_snapshots: true to delete them.`
        );
      }
      throw err;
    }

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

The Archive tier trap is the most common source of silent failures in Azure Blob MCP tools: Archive blobs cannot be read directly and return HTTP 409 with code BlobArchived. Archive tier is for long-term cold storage — access requires rehydration (changing the tier to Hot or Cool), which takes up to 15 hours with standard priority or 1 hour with high priority. Always surface this error clearly in your tool response rather than letting the 409 propagate as a generic error.

SAS URLs for temporary download and upload access

SAS (Shared Access Signature) tokens are time-limited credentials embedded in a URL that grant specific permissions to a blob without exposing account keys. There are two ways to generate them from code: using the account key directly, or using a User Delegation Key obtained via the Blob Service API (requires Storage Blob Delegator role on the storage account).

import {
  generateBlobSASQueryParameters,
  BlobSASPermissions,
  StorageSharedKeyCredential,
  BlobServiceClient
} from '@azure/storage-blob';

// Method 1: Account key SAS (simpler, but requires account key in env)
const sharedKeyCredential = new StorageSharedKeyCredential(
  ACCOUNT_NAME,
  process.env.AZURE_STORAGE_ACCOUNT_KEY!
);

// ---- 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 }) => {
    validateBlobName(name);

    // Verify the blob exists
    const blobClient = containerClient.getBlobClient(name);
    const exists = await blobClient.exists();
    if (!exists) {
      throw new McpError(ErrorCode.InvalidParams, `Blob not found: ${name}`);
    }

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

    const sasToken = generateBlobSASQueryParameters(
      {
        containerName: CONTAINER_NAME,
        blobName: name,
        permissions: BlobSASPermissions.parse('r'),  // read only
        expiresOn,
        contentDisposition: `attachment; filename="${name.split('/').pop()}"`,
      },
      sharedKeyCredential
    ).toString();

    const url = `https://${ACCOUNT_NAME}.blob.core.windows.net/${CONTAINER_NAME}/${encodeURIComponent(name)}?${sasToken}`;

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

// Method 2: User Delegation Key SAS (no account key needed — uses managed identity)
// Requires 'Storage Blob Delegator' role on the storage account
async function generateUserDelegationSas(name: string, expiresInSeconds: number): Promise<string> {
  const startsOn = new Date();
  const expiresOn = new Date(Date.now() + expiresInSeconds * 1000);

  const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);

  const sasToken = generateBlobSASQueryParameters(
    {
      containerName: CONTAINER_NAME,
      blobName: name,
      permissions: BlobSASPermissions.parse('r'),
      expiresOn,
      startsOn
    },
    userDelegationKey,
    ACCOUNT_NAME
  ).toString();

  return `https://${ACCOUNT_NAME}.blob.core.windows.net/${CONTAINER_NAME}/${encodeURIComponent(name)}?${sasToken}`;
}
SAS method Credential required Max token lifetime Best for
Account key SAS Storage account key No limit Simple setups, local dev
User Delegation SAS Managed identity + Blob Delegator role 7 days Production — no key rotation needed

Health endpoint: /health/azure-blob

import express from 'express';
import { containerClient, CONTAINER_NAME } from './azure-client.js';

const app = express();

app.get('/health/azure-blob', async (_req, res) => {
  const start = Date.now();
  try {
    // getProperties() verifies: container exists, credentials valid, endpoint reachable
    const properties = await containerClient.getProperties();
    const latencyMs = Date.now() - start;

    res.json({
      status: 'ok',
      container: CONTAINER_NAME,
      public_access: properties.blobPublicAccess ?? 'none',
      lease_state: properties.leaseState,
      latency_ms: latencyMs
    });
  } catch (err: any) {
    const latencyMs = Date.now() - start;

    res.status(503).json({
      status: 'error',
      error: err.message,
      status_code: err.statusCode,
      error_code: err.code,
      elapsed_ms: latencyMs,
      // Common causes:
      // 403 — credential doesn't have container-level read permission
      // 404 — container does not exist
      // 401 — token expired (DefaultAzureCredential should auto-refresh, but check)
      // ENOTFOUND — wrong account name or network issue
    });
  }
});

app.listen(3001);
Azure failure mode getProperties response Detected
Managed identity not assigned 401 Unauthorized Yes — returns 503
Missing Storage Blob Data Reader role 403 Forbidden Yes — returns 503
Container deleted 404 Not Found Yes — returns 503
Storage account in different subscription 403 Forbidden Yes — wrong account URL resolves but credentials denied
Blob in Archive tier 200 (container OK) No — only blob-level read triggers the Archive error
Account key rotated (connection string) 403 Forbidden Yes — old key in connection string rejected

DefaultAzureCredential automatically refreshes tokens before expiry — you don't need to handle token refresh in your health probe. However, if the managed identity is removed from the resource after startup, the next token refresh will fail. The health probe catches this because getProperties() forces a live API call rather than using a cached token. Set the health probe interval to less than the token TTL (typically 1 hour for Azure AD tokens) to catch mid-session identity removals.

Frequently asked questions

What's the difference between Block Blobs, Page Blobs, and Append Blobs?

Azure Blob Storage has three blob types with different write semantics. Block blobs (used in uploadData() above) store content as a sequence of blocks — each block up to 4 GB, total blob up to 190.7 TB in recent service versions. Blocks are uploaded individually and committed together, enabling parallel and resumable uploads. Block blobs are the correct type for MCP tools handling documents, model outputs, and files. Page blobs are 512-byte aligned random-write structures designed for Azure VM disks (VHDs) — never use these in MCP tools. Append blobs only support appending new blocks, making them useful for log aggregation where an agent appends audit records. The getBlockBlobClient() call in your tool defaults to block blobs. If you call getAppendBlobClient(), the blob is created as an append blob and future uploadData() calls will fail with a conflict error.

How do I upload files larger than 256 MB?

The single-call uploadData() method internally splits large uploads into blocks when the data exceeds the blockSize threshold (default 4 MB). For very large files, use uploadStream() which accepts a Node.js readable stream and pipelines the upload: await blockBlobClient.uploadStream(readableStream, bufferSize, maxConcurrency, { onProgress: (p) => console.log(p.loadedBytes) }). The bufferSize and maxConcurrency parameters control memory use and parallel upload of blocks. A good starting point: bufferSize: 4 * 1024 * 1024 (4 MB), maxConcurrency: 5. For MCP tools that receive file content as base64 strings, decode to a Buffer first, then wrap in a Readable stream from Readable.from(buffer). Files above ~256 MB that come in as tool parameters are rare — at that point, returning an upload SAS URL and letting the caller upload directly to Azure is the better pattern.

How do I rehydrate an Archive-tier blob?

Rehydration changes the blob's access tier from Archive to Hot or Cool, after which it can be read. Call await blobClient.setAccessTier('Hot', { rehydratePriority: 'Standard' }). Standard priority takes up to 15 hours. High priority ('High') takes under 1 hour for objects under 10 GB, but costs significantly more. Track rehydration status by polling await blobClient.getProperties() and checking properties.archiveStatus — it will read 'rehydrate-pending-to-hot' while in progress and become undefined (no archive status) when complete. If your MCP tools frequently hit Archive tier blobs, add a rehydrate_blob tool that triggers rehydration and a get_blob_tier tool that reports the current tier and estimated rehydration completion time.

Can I test Azure Blob tools locally without an Azure account?

Yes — use Azurite, the official Azure storage emulator. Run it with Docker: docker run -p 10000:10000 mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0. Connect using the well-known development connection string: DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1. Azurite supports all core blob operations, SAS tokens, and container management. For the DefaultAzureCredential path in tests, use StorageSharedKeyCredential with the Azurite credentials instead — DefaultAzureCredential doesn't resolve in environments without Azure auth sources. The Azure Storage Explorer desktop app connects to Azurite and provides a GUI for verifying test results.

Further reading

Know when your Azure Blob credentials expire before tools fail silently

AliveMCP polls your /health/azure-blob endpoint every 60 seconds — catching expired managed identity tokens, missing role assignments, and deleted containers before your MCP tools start returning 403 to agents.

Start monitoring free