Building an Azure Cosmos DB MCP Server with .NET 9 and Blazor

The Model Context Protocol (MCP) is an open standard that lets Large Language Models (LLMs) such as Claude, ChatGPT, and GitHub Copilot interact with external systems through a well-defined tool interface. In this article, we will build a complete solution that exposes Azure Cosmos DB operations as MCP tools, and a Blazor Server client that can discover and invoke those tools through a browser-based UI.

By the end of this article you will have:

  • An ASP.NET Core MCP Server that wraps Azure Cosmos DB CRUD operations as callable MCP tools
  • A Blazor Server Client that connects to the MCP Server, lists available tools, and lets you invoke them interactively
  • A clear understanding of MCP architecture, tool registration, service design, and error handling

Architecture Overview

The solution consists of two projects. The CosmosDbMcpServer (ASP.NET Core Web API) hosts the MCP endpoint and talks to Azure Cosmos DB. The CosmosDbMcpClient (Blazor Server) provides a web UI that connects to the MCP Server using the MCP Client SDK.

 

Figure 1: The Application Implementation

Prerequisites

  • .NET 9 SDK
  • An Azure Cosmos DB account (with a database and container created)
  • Visual Studio 2022 or VS Code

1. Solution Structure is as follows in Visual Studio:


CosmosDbMcpServer/                          (Solution Root)
├── CosmosDbMcpServer.csproj                 (MCP Server – ASP.NET Core Web API)
│   ├── Program.cs                           (Entry point, DI, MCP registration)
│   ├── Services/
│   │   └── CosmosDbService.cs               (Cosmos DB SDK wrapper)
│   ├── Tools/
│   │   └── CosmosDbTools.cs                 (MCP tool definitions)
│   └── appsettings.json                     (Cosmos DB connection config)
│
├── CosmosDbMcpClient/                       (Blazor Server Client)
│   ├── CosmosDbMcpClient.csproj
│   ├── Program.cs                           (Blazor app setup)
│   ├── Services/
│   │   └── McpClientService.cs              (MCP client connector)
│   ├── Components/
│   │   ├── Layout/
│   │   │   ├── MainLayout.razor
│   │   │   └── NavMenu.razor
│   │   └── Pages/
│   │       └── McpToolExplorer.razor            (Primary interactive UI)
│   └── appsettings.json                     (MCP Server URL config)
│
└── CosmosDbMcpServer.sln

2. The MCP Server Project

2.1 NuGet Packages (CosmosDbMcpServer.csproj)

The server project targets .NET 9 and references the following key packages:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>CosmosDbMcpServer</RootNamespace>
    <AssemblyName>CosmosDbMcpServer</AssemblyName>
    <UserSecretsId>cosmosdb-mcp-server</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <!-- MCP Server SDK for ASP.NET Core (SSE/HTTP transport) -->
    <PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.1.0-preview.11.25171.1" />

    <!-- Azure CosmosDB SDK -->
    <PackageReference Include="Microsoft.Azure.Cosmos" Version="3.46.1" />
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

    <!-- Azure Identity for Managed Identity + DefaultAzureCredential -->
    <PackageReference Include="Azure.Identity" Version="1.13.2" />

    <!-- Logging -->
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
  </ItemGroup>

</Project>

2.2 Application Entry Point (Program.cs)

The Program.cs file wires up Cosmos DB, MCP server, and health checks. It supports two authentication modes: a connection string for development and Azure Managed Identity for production. In this case we will be using the connection string based authentication mode.

using Azure.Identity;
using CosmosDbMcpServer.Services;
using CosmosDbMcpServer.Tools;
using Microsoft.Azure.Cosmos;

var builder = WebApplication.CreateBuilder(args);

// ── CosmosDB Client ─────────────────────────────────────────────
builder.Services.AddSingleton<CosmosClient>(sp =>
{
    var config  = sp.GetRequiredService<IConfiguration>();
    var section = config.GetSection("CosmosDb");

    // Option A – Connection String (development / simple setup)
    var connectionString = section["ConnectionString"];
    if (!string.IsNullOrWhiteSpace(connectionString))
    {
        return new CosmosClient(connectionString, new CosmosClientOptions
        {
            SerializerOptions = new CosmosSerializationOptions
            {
                PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
            },
            ApplicationName = "CosmosDbMcpServer"
        });
    }

    
});

builder.Services.AddSingleton<CosmosDbService>();

// ── MCP Server (HTTP/SSE transport) ─────────────────────────────
builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithTools<CosmosDbTools>();

// ── Health check (used by Azure App Service) ───────────────────
builder.Services.AddHealthChecks();

builder.Logging.AddConsole();

var app = builder.Build();

app.UseRouting();

app.MapHealthChecks("/health");
app.MapMcp("/mcp");          // The SSE endpoint:  GET  /mcp
                              // Message endpoint: POST /mcp

// Root endpoint for the human-readable info
app.MapGet("/", () => Results.Json(new
{
    name        = "CosmosDB MCP Server",
    version     = "1.0.0",
    description = "MCP server exposing Azure CosmosDB operations as tools to access by client",
    mcpEndpoint = "/mcp",
    healthCheck = "/health"
}));

app.Run();

Important Key Features:

  • AddMcpServer().WithHttpTransport().WithTools<CosmosDbTools>() — registers the MCP server with HTTP/SSE transport and auto-discovers all [McpServerTool]-decorated methods inside CosmosDbTools.
  • MapMcp("/mcp") — maps the MCP endpoint at /mcp. Clients connect via GET /mcp (SSE) and send tool calls via POST /mcp.
  • The CosmosClient is registered as a singleton with two authentication options — connection string or Managed Identity via DefaultAzureCredential.

2.3 Application Configuration (appsettings.json) for Azure Cosmos DB Connection String

{
  "CosmosDb": {
    // Option A – Connection String (for local dev)
    "ConnectionString": "AccountEndpoint=https://YOUR_ACCOUNT.documents.azure.com:443/;AccountKey=YOUR_KEY;",

    // Option B – Endpoint only (use with Managed Identity on Azure)
    "AccountEndpoint": ""
  },

  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.Azure.Cosmos": "Warning"
    }
  },
  "AllowedHosts": "*"
}

3. The Tools class for Defining MCP Tools (CosmosDbTools.cs)

Each public method decorated with [McpServerTool] automatically becomes a callable tool visible to any MCP client for invoking. The [Description] attribute provides documentation that the LLMs use to decide when to invoke a tool based on the request received from the MCP client.

using CosmosDbMcpServer.Services;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json;

namespace CosmosDbMcpServer.Tools;

[McpServerToolType]
public sealed class CosmosDbTools
{
    private readonly CosmosDbService _cosmos;

    private static readonly JsonSerializerOptions _jsonOptions = new()
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public CosmosDbTools(CosmosDbService cosmos) => _cosmos = cosmos;

    // ── Discovery ──────────────────────────────────────────────────

    [McpServerTool]
    [Description(
        "List all databases in the Azure CosmosDB account. " +
        "Returns each database id and ETag.")]
    public Task<string> ListDatabases(CancellationToken ct) =>
        _cosmos.ListDatabasesAsync(ct);

    [McpServerTool]
    [Description(
        "List all containers (collections) inside a specific CosmosDB database. " +
        "Returns each container id, partition-key path, and ETag.")]
    public Task<string> ListContainers(
        [Description("The CosmosDB database id to inspect.")]
        string databaseId,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(databaseId))
            return Task.FromResult(ValidationError("ListContainers",
                "databaseId is required. Use ListDatabases first."));

        return _cosmos.ListContainersAsync(databaseId, ct);
    }

    [McpServerTool]
    [Description(
        "Execute a CosmosDB SQL query against a container and return matching documents.")]
    public Task<string> QueryItems(
        [Description("The CosmosDB database id.")]
        string databaseId,
        [Description("The container (collection) id to query.")]
        string containerId,
        [Description("A valid CosmosDB SQL query string.")]
        string sql,
        [Description("Maximum number of items to return (1-1000). Default is 100.")]
        int maxItems = 100,
        CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(databaseId))
            return Task.FromResult(ValidationError("QueryItems", "databaseId is required."));
        if (string.IsNullOrWhiteSpace(containerId))
            return Task.FromResult(ValidationError("QueryItems", "containerId is required."));
        if (string.IsNullOrWhiteSpace(sql))
            return Task.FromResult(ValidationError("QueryItems", "sql query is required."));

        return _cosmos.QueryItemsAsync(databaseId, containerId, sql,
            Math.Clamp(maxItems, 1, 1000), ct);
    }

    [McpServerTool]
    [Description(
        "Read a single document from a container by its 'id' and partition-key value.")]
    public Task<string> GetItem(
        [Description("The CosmosDB database id.")] string databaseId,
        [Description("The container id.")] string containerId,
        [Description("The document's 'id' field value.")] string itemId,
        [Description("The partition-key value.")] string partitionKeyValue,
        CancellationToken ct)
    {
        // ... validation omitted for brevity ...
        return _cosmos.GetItemAsync(databaseId, containerId, itemId, partitionKeyValue, ct);
    }

    [McpServerTool]
    [Description("Create a new document in a container.")]
    public Task<string> CreateItem(
        [Description("The CosmosDB database id.")] string databaseId,
        [Description("The container id.")] string containerId,
        [Description("The full document as a JSON string.")] string itemJson,
        CancellationToken ct)
    {
        // ... validation omitted for brevity ...
        return _cosmos.CreateItemAsync(databaseId, containerId, itemJson, ct);
    }

    [McpServerTool]
    [Description("Upsert (create or replace) a document in a container.")]
    public Task<string> UpsertItem(
        [Description("The CosmosDB database id.")] string databaseId,
        [Description("The container id.")] string containerId,
        [Description("The full document as a JSON string.")] string itemJson,
        CancellationToken ct)
    {
        // ... validation omitted for brevity ...
        return _cosmos.UpsertItemAsync(databaseId, containerId, itemJson, ct);
    }

    [McpServerTool]
    [Description("Delete a document from a container by its 'id' and partition-key value.")]
    public Task<string> DeleteItem(
        [Description("The CosmosDB database id.")] string databaseId,
        [Description("The container id.")] string containerId,
        [Description("The document's 'id' field value.")] string itemId,
        [Description("The partition-key value.")] string partitionKeyValue,
        CancellationToken ct)
    {
        // ... validation omitted for brevity ...
        return _cosmos.DeleteItemAsync(databaseId, containerId, itemId, partitionKeyValue, ct);
    }

    private static string ValidationError(string operation, string message) =>
        JsonSerializer.Serialize(new
        {
            error = true,
            operation,
            statusCode = 400,
            status = "ValidationError",
            message
        }, _jsonOptions);
}

The [McpServerToolType] attribute on the class tells the MCP SDK to scan it for tool methods. Each method parameter annotated with [Description] tells the LLM what value to provide. Input validation returns structured JSON errors rather than throwing exceptions.

4. The Cosmos DB Service Layer (CosmosDbService.cs)

The CosmosDbService class wraps the Azure Cosmos DB SDK. Every method returns a JSON string either a success payload or a structured error. This design lets the MCP tool layer pass results directly to the LLM without further transformation.

using Microsoft.Azure.Cosmos;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace CosmosDbMcpServer.Services;

public sealed class CosmosDbService
{
    private readonly CosmosClient _client;
    private readonly ILogger<CosmosDbService> _logger;

    private static readonly JsonSerializerOptions _jsonOptions = new()
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public CosmosDbService(CosmosClient client, ILogger<CosmosDbService> logger)
    {
        _client = client;
        _logger = logger;
    }

    // ── Listing Databases in Cosmos DB──────────────────────────────────────────────

    public async Task<string> ListDatabasesAsync(CancellationToken ct = default)
    {
        try
        {
            var databases = new List<object>();

            using var iter = _client.GetDatabaseQueryIterator<DatabaseProperties>();
            while (iter.HasMoreResults)
            {
                foreach (var db in await iter.ReadNextAsync(ct))
                    databases.Add(new { id = db.Id, etag = db.ETag });
            }

            return Serialize(new { count = databases.Count, databases });
        }
        catch (CosmosException ex)
        {
            return HandleCosmosException(ex, "ListDatabases");
        }
        catch (Exception ex)
        {
            return HandleGeneralException(ex, "ListDatabases");
        }
    }

    // ── Query Items to container─────────────────────────────────────────────────

    public async Task<string> QueryItemsAsync(
        string databaseId, string containerId,
        string sql, int maxItems = 100,
        CancellationToken ct = default)
    {
        try
        {
            var container = _client.GetContainer(databaseId, containerId);
            var queryDef  = new QueryDefinition(sql);

            var itemJsonStrings = new List<string>();
            using var iter = container.GetItemQueryStreamIterator(
                queryDef,
                requestOptions: new QueryRequestOptions { MaxItemCount = maxItems });

            while (iter.HasMoreResults && itemJsonStrings.Count < maxItems)
            {
                var response = await iter.ReadNextAsync(ct);
                using var streamReader = new StreamReader(response.Content);
                var pageJson = await streamReader.ReadToEndAsync(ct);
                var pageDoc = JsonDocument.Parse(pageJson);

                foreach (var item in pageDoc.RootElement
                    .GetProperty("Documents").EnumerateArray())
                {
                    itemJsonStrings.Add(item.GetRawText());
                    if (itemJsonStrings.Count >= maxItems) break;
                }
            }

            var metaJson = Serialize(new { databaseId, containerId,
                query = sql, count = itemJsonStrings.Count });
            var itemsArray = "[" + string.Join(",", itemJsonStrings) + "]";
            var closingBrace = metaJson.LastIndexOf('}');
            return metaJson[..closingBrace] + ",\n  \"items\": " + itemsArray + "\n}";
        }
        catch (CosmosException ex)
        {
            return HandleCosmosException(ex, "QueryItems", databaseId, containerId);
        }
        catch (Exception ex)
        {
            return HandleGeneralException(ex, "QueryItems", databaseId, containerId);
        }
    }

    // ── Create Item in a container─────────────────────────────────────────────────

    public async Task<string> CreateItemAsync(
        string databaseId, string containerId,
        string itemJson, CancellationToken ct = default)
    {
        try
        {
            var container = _client.GetContainer(databaseId, containerId);
            var pk = await ExtractPartitionKeyFromDocAsync(container, itemJson, ct);

            using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(itemJson));
            var response = await container.CreateItemStreamAsync(stream, pk, cancellationToken: ct);
            response.EnsureSuccessStatusCode();

            string createdItemJson = "{}";
            if (response.Content is not null)
            {
                using var reader = new StreamReader(response.Content);
                createdItemJson = await reader.ReadToEndAsync(ct);
            }

            var createdItem = JsonDocument.Parse(createdItemJson).RootElement;
            return SerializeWithRawItem(new
            {
                databaseId, containerId,
                statusCode    = (int)response.StatusCode,
                requestCharge = response.Headers.RequestCharge
            }, createdItem);
        }
        catch (CosmosException ex)
        { return HandleCosmosException(ex, "CreateItem", databaseId, containerId); }
        catch (Exception ex)
        { return HandleGeneralException(ex, "CreateItem", databaseId, containerId); }
    }

    // ── Partition Key Helpers that 
    /// Reads the container's partition key definition and extracts
    /// values from the document JSON. Handles single + hierarchical keys.
    private async Task<PartitionKey> ExtractPartitionKeyFromDocAsync(
        Container container, string itemJson, CancellationToken ct)
    {
        var containerResponse = await container.ReadContainerAsync(cancellationToken: ct);
        var props = containerResponse.Resource;

        IReadOnlyList<string> paths;
        try { paths = props.PartitionKeyPaths; }
        catch (NotImplementedException) { paths = new[] { props.PartitionKeyPath }; }

        var doc = JsonDocument.Parse(itemJson);

        if (paths.Count == 1)
        {
            var fieldName = paths[0].TrimStart('/');
            if (doc.RootElement.TryGetProperty(fieldName, out var val))
                return new PartitionKey(val.GetString());
            return PartitionKey.None;
        }

        // Hierarchical partition key
        var builder = new PartitionKeyBuilder();
        foreach (var path in paths)
        {
            var fieldName = path.TrimStart('/');
            if (doc.RootElement.TryGetProperty(fieldName, out var val))
                builder.Add(val.GetString() ?? "");
            else
                builder.Add("");
        }
        return builder.Build();
    }

    /// Builds a PartitionKey from a pipe-separated string.
    /// Single key: "value1"  |  Hierarchical: "value1|value2"
    private static PartitionKey BuildPartitionKey(string partitionKeyValue)
    {
        var parts = partitionKeyValue.Split('|');
        if (parts.Length == 1)
            return new PartitionKey(parts[0]);

        var builder = new PartitionKeyBuilder();
        foreach (var part in parts)
            builder.Add(part);
        return builder.Build();
    }

    // ── Error Handling ──────────────────────────────────────────────

    private string HandleCosmosException(CosmosException ex, string operation,
        string? databaseId = null, string? containerId = null)
    {
        _logger.LogError(ex, "CosmosException in {Operation}", operation);

        var errorResponse = new Dictionary<string, object?>
        {
            ["error"] = true,
            ["operation"] = operation,
            ["statusCode"] = (int)ex.StatusCode,
            ["status"] = ex.StatusCode.ToString(),
            ["message"] = GetFriendlyMessage(ex),
            ["activityId"] = ex.ActivityId
        };

        return Serialize(errorResponse);
    }

    private static string Serialize(object value) =>
        JsonSerializer.Serialize(value, _jsonOptions);
}

Important Key Design highlighting features:

  • Stream-based readsQueryItemsAsync uses GetItemQueryStreamIterator to avoid JsonElement disposal issues and builds the JSON response by string manipulation for maximum control.
  • Hierarchical partition key support — the service reads the container's partition key definition at runtime and constructs the correct PartitionKey (single or multi-level) using PartitionKeyBuilder.
  • Friendly error messages — HTTP status codes (404, 409, 429, etc.) are translated into human-readable messages that help LLMs understand what went wrong.

5. The Blazor Server Client to access theMCP Srever

5.1 Client NuGet Packages (CosmosDbMcpClient.csproj)

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="ModelContextProtocol" Version="1.2.0" />
  </ItemGroup>
</Project>

5.2 Client Program.cs

using CosmosDbMcpClient.Components;
using CosmosDbMcpClient.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

// Register MCP client service
builder.Services.AddSingleton<McpClientService>();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
}

app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

5.3 MCP Client Service (McpClientService.cs)

This service establishes an HTTP connection to the MCP server and provides ListToolsAsync and CallToolAsync methods. It uses the ModelContextProtocol NuGet package with HttpClientTransport. This is used to make call to MCP Server running on 62041 port.

using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Text.Json;

namespace CosmosDbMcpClient.Services;

public class McpClientService : IAsyncDisposable
{
    private readonly string _mcpServerUrl;
    private readonly ILogger<McpClientService> _logger;
    private McpClient? _client;
    private readonly SemaphoreSlim _connectLock = new(1, 1);

    public McpClientService(IConfiguration configuration, ILogger<McpClientService> logger)
    {
        _mcpServerUrl = configuration["McpServer:Url"] ?? "http://localhost:62041";
        _logger = logger;
    }

    private async Task<McpClient> GetClientAsync(CancellationToken ct = default)
    {
        if (_client is not null) return _client;

        await _connectLock.WaitAsync(ct);
        try
        {
            if (_client is not null) return _client;

            var transport = new HttpClientTransport(new HttpClientTransportOptions
            {
                Endpoint = new Uri($"{_mcpServerUrl}/mcp"),
                Name = "CosmosDbMcpClient"
            });

            _client = await McpClient.CreateAsync(transport, cancellationToken: ct);
            return _client;
        }
        finally { _connectLock.Release(); }
    }

    public async Task<IList<McpClientTool>> ListToolsAsync(CancellationToken ct = default)
    {
        var client = await GetClientAsync(ct);
        return await client.ListToolsAsync(cancellationToken: ct);
    }

    public async Task<string> CallToolAsync(string toolName,
        Dictionary<string, object?> arguments,
        CancellationToken ct = default)
    {
        try
        {
            var client = await GetClientAsync(ct);
            var result = await client.CallToolAsync(toolName, arguments, cancellationToken: ct);

            var textParts = result.Content
                .OfType<TextContentBlock>()
                .Select(c => c.Text ?? "")
                .ToList();

            return string.Join("\n", textParts);
        }
        catch (Exception ex)
        {
            return JsonSerializer.Serialize(new
            {
                error = true,
                operation = toolName,
                status = "ClientError",
                message = $"Failed to call tool '{toolName}': {ex.Message}"
            });
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_client is not null)
        {
            await _client.DisposeAsync();
            _client = null;
        }
        _connectLock.Dispose();
    }
}

5.4 MCP Tool Explorer UI (McpToolExplorer.razor)

This Blazor component provides an interactive UI to select an MCP tool and then enter JSON parameters to invoke various MCP Server tool, and display results in a formatted table.

@page "/mcp-tools"
@rendermode InteractiveServer
@using CosmosDbMcpClient.Services
@using System.Text.Json
@inject McpClientService McpService

<PageTitle>MCP Tool Explorer</PageTitle>
<h3>CosmosDB MCP Tool Explorer</h3>

@if (_tools is null)
{
    <p><em>Loading tools from MCP server...</em></p>
}
else
{
    <div class="card mt-3">
        <div class="card-header"><strong>Select a Tool</strong></div>
        <div class="card-body">
            @foreach (var tool in _tools)
            {
                <div class="form-check">
                    <input class="form-check-input" type="radio"
                           name="toolSelect"
                           @onchange="() => OnToolSelected(tool.Name)" />
                    <label class="form-check-label">
                        <strong>@tool.Name</strong> &mdash; @tool.Description
                    </label>
                </div>
            }
        </div>
    </div>

    @if (_selectedTool is not null)
    {
        <div class="card mt-3">
            <div class="card-header"><strong>Parameters (JSON)</strong></div>
            <div class="card-body">
                <textarea class="form-control" rows="5"
                          @bind="_promptText"></textarea>
                <button class="btn btn-primary mt-3"
                        @onclick="SendRequest"
                        disabled="@_isLoading">
                    Send Request
                </button>
            </div>
        </div>
    }

    @* Response rendered as a table or raw JSON *@
    @if (_tableHeaders.Count > 0)
    {
        <table class="table table-striped table-bordered mt-3">
            <thead class="table-dark">
                <tr>
                    @foreach (var header in _tableHeaders)
                    { <th>@header</th> }
                </tr>
            </thead>
            <tbody>
                @foreach (var row in _tableRows)
                {
                    <tr>
                        @foreach (var header in _tableHeaders)
                        { <td>@(row.TryGetValue(header, out var val) ? val : "")</td> }
                    </tr>
                }
            </tbody>
        </table>
    }
}

@code {
    private IList<McpClientTool>? _tools;
    private string? _selectedTool;
    private string _promptText = "";
    private bool _isLoading;
    private List<string> _tableHeaders = new();
    private List<Dictionary<string, string>> _tableRows = new();

    protected override async Task OnInitializedAsync()
    {
        _tools = await McpService.ListToolsAsync();
    }

    private async Task SendRequest()
    {
        if (_selectedTool is null) return;

        _isLoading = true;
        var arguments = new Dictionary<string, object?>();

        if (!string.IsNullOrWhiteSpace(_promptText))
        {
            var parsed = JsonSerializer.Deserialize<Dictionary<string,
                JsonElement>>(_promptText);
            if (parsed is not null)
            {
                foreach (var kvp in parsed)
                {
                    arguments[kvp.Key] = kvp.Value.ValueKind switch
                    {
                        JsonValueKind.String => kvp.Value.GetString(),
                        JsonValueKind.Number => kvp.Value.TryGetInt64(out var l) ? l : kvp.Value.GetDouble(),
                        JsonValueKind.True => true,
                        JsonValueKind.False => false,
                        _ => kvp.Value.GetRawText()
                    };
                }
            }
        }

        var response = await McpService.CallToolAsync(_selectedTool, arguments);
        ParseResponseToTable(response);
        _isLoading = false;
    }
}

5.5 Client Configuration (appsettings.json)

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "McpServer": {
    "Url": "http://localhost:62041"
  }
}

6. List of the Exposed MCP Tools

Tool Name Parameters Description
ListDatabases none Returns all database names and ETags in the Cosmos DB account
ListContainers databaseId Lists all containers in the specified database
GetContainerInfo databaseId, containerId Returns partition key, throughput (RU/s), indexing policy, TTL
QueryItems databaseId, containerId, sql, maxItems Executes a Cosmos DB SQL query and returns matching documents
GetItem databaseId, containerId, itemId, partitionKeyValue Reads a single document by id and partition key
CreateItem databaseId, containerId, itemJson Creates a new document (409 if id exists)
UpsertItem databaseId, containerId, itemJson Creates or replaces a document
DeleteItem databaseId, containerId, itemId, partitionKeyValue Deletes a document by id and partition key

7. Running the Solution

Step 1: Start the MCP Server (runs on port 62041):

cd CosmosDbMcpServer
dotnet run

Step 2: Start the Blazor Client (runs on port 5071):

cd CosmosDbMcpClient
dotnet run

Step 3: Open your browser and navigate to http://localhost:5071/mcp-tools. You will see the MCP Tool Explorer page with all available tools listed. Select a tool, enter JSON parameters, and click Send Request to interact with your Cosmos DB data.

 

8. Important Design Decisions

  • JSON-in, JSON-out — Every MCP tool accepts and returns plain JSON strings. This avoids serialization mismatches between the Cosmos DB SDK, MCP protocol, and LLM clients.
  • Structured errors — Instead of letting exceptions propagate, every operation catches CosmosException and returns a JSON object with error: true, the HTTP status code, and a human-readable message. This is critical for LLM clients that need to understand failures.
  • Hierarchical partition keys — Azure Cosmos DB supports sub-partitioning with up to 3 levels. The service automatically detects whether a container uses single or hierarchical keys and builds the correct PartitionKey at runtime.
  • Dual authentication — Connection string for local development, DefaultAzureCredential (Managed Identity) for Azure deployments. No code changes needed — just flip the config.
  • Thread-safe client — The McpClientService uses a SemaphoreSlim with double-check locking to ensure only one MCP connection is created even under concurrent requests.

9. The Result

You can run the application and from the Browser, the call can be made  from Client to MCP Server to access various tools. The following video shows the result.


   

Conclusion

In this article, we built a Model Context Protocol (MCP) Server that exposes Azure Cosmos DB operations as callable tools for LLMs and AI assistants. We also created a Blazor Server client that demonstrates how to discover and invoke MCP tools through a browser-based UI.

The MCP standard is rapidly becoming the bridge between AI models and enterprise data. By wrapping your Cosmos DB operations behind MCP tools, you enable Claude, GitHub Copilot, VS Code extensions, and any future MCP-compatible client to query, create, update, and delete documents in your database — all through a standardized, discoverable protocol.

The complete source code for this solution is available on GitHub. You can extend it by adding more tools (e.g., bulk operations, change feed), securing the MCP endpoint with authentication, or deploying to Azure App Service with Managed Identity.

 

Popular posts from this blog

ASP.NET Core 8: Creating Custom Authentication Handler for Authenticating Users for the Minimal APIs

ASP.NET Core 7: Using PostgreSQL to store Identity Information

Blazor: Using QuickGrid to Perform the CRUD Operations in Blazor WebAssembly Application