McpTelemetry source
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at source.md, and llms.txt lists every page.The source of the McpTelemetry sample.
Program.cs
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using OpenTelemetry.Metrics;
using Orleans.Lattice;
using Orleans.Lattice.Api.Data;
using Orleans.Lattice.Api.Auth;
using Orleans.Lattice.Api.Mcp;
using Orleans.Lattice.Api.Mcp.Telemetry;
using Orleans.Lattice.Api.State;
using Orleans.Lattice.Api.Telemetry;
using Orleans.Lattice.Auth;
using Orleans.Lattice.Membership;
using Orleans.Lattice.Samples.McpTelemetry;
// ---------------------------------------------------------------------------
// Orleans.Lattice.Api.Mcp.Telemetry sample: a co-hosted single silo that
// exposes its OpenTelemetry metrics to an AI agent over MCP, proxied through a
// REAL Prometheus instance running in Docker.
//
// Topology:
//
// [ this process ] [ docker compose ]
// Orleans silo + Lattice prometheus:9090
// | emits orleans.lattice metrics ^ |
// | /metrics (OTel Prometheus exporter) --/ | PromQL HTTP API
// | v
// MCP server + AddTelemetryTools -----------> queries Prometheus
// ^
// | streamable HTTP + MCP client (in-process)
// AI agent journey
//
// The Prometheus container scrapes this process's /metrics endpoint and this
// process's telemetry tools query that same Prometheus over its HTTP API. So a
// telemetry answer is a genuine round-trip: silo -> Prometheus scrape -> PromQL
// query -> MCP tool result.
//
// The sample proves the headline properties of the telemetry surface:
//
// 1. A caller granted the cluster-wide LatticeOperation.Telemetry capability
// discovers the four read-only lattice_telemetry_* tools and runs a live
// PromQL query end-to-end over MCP against real Prometheus.
// 2. Permission-scoping: the same caller does NOT see the state tools it was
// not granted, and an unauthenticated caller is offered nothing at all.
// 3. The dual-credential boundary: the tools authenticate to Prometheus with a
// backend credential the host configures, never the caller's identity.
//
// Prerequisite: start Prometheus first with `docker compose up -d` in this
// directory. See README.md.
// ---------------------------------------------------------------------------
const string DemoTree = "catalog";
const string Agent = DemoCredentialBridge.AgentSubject;
const string Scheme = DemoAuthenticator.Scheme;
const int Port = 5290;
const string PrometheusAddress = "http://localhost:9090/";
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
// Bind on all interfaces so the Prometheus container can scrape /metrics via
// host.docker.internal, and the in-process MCP client can reach it via
// localhost.
builder.WebHost.UseUrls($"http://0.0.0.0:{Port}");
builder.Host.UseOrleans(silo =>
{
silo.UseLocalhostClustering();
silo.AddMemoryGrainStorageAsDefault();
silo.UseInMemoryReminderService();
silo.AddLattice((services, name) => services.AddMemoryGrainStorage(name));
// Membership resolves the ambient caller credential into a subject.
silo.AddLatticeMembership();
// Auth installs the default-deny enforcement gate. "root-admin" is a
// bootstrap administrator so the sample can seed the user + rule before any
// rule exists.
silo.AddLatticeAuth(options =>
{
options.DefaultEffect = LatticeEffect.Deny;
options.BootstrapAdministrators.Add("root-admin");
});
silo.Services.AddSingleton<ILatticeCredentialAuthenticator, DemoAuthenticator>();
// The facades whose tools we register (state is added purely to demonstrate
// that a Telemetry-only grant does not unlock them). The auth facade is
// registered so the MCP discovery core can resolve the caller's effective
// permissions - it is not exposed as a tool group here.
silo.AddLatticeStateApi();
silo.AddLatticeDataApi();
silo.AddLatticeAuthApi();
});
// Export the orleans.lattice meter over Prometheus at /metrics so the Docker
// Prometheus service can scrape this silo.
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter(LatticeMetrics.MeterName)
.AddPrometheusExporter());
// The demo credential bridge is registered BEFORE AddLatticeMcp so its
// TryAdd-registered HttpContext bridge is skipped and ours wins.
builder.Services.AddSingleton<ILatticeApiMcpCredentialBridge, DemoCredentialBridge>();
// The MCP server front door, mounted at /mcp so it coexists with /metrics.
// RequireAuthorization is disabled purely to keep the sample one-command
// runnable; discovery is still fail-closed and permission-scoped underneath.
builder.Services.AddLatticeMcp(options =>
{
options.RequireAuthorization = false;
options.TransportPattern = "/mcp";
});
// Register the state tools (never granted to the agent below) and the telemetry
// tools, pointing the telemetry proxy at the real Prometheus. AuthMode is None
// because the sample's Prometheus is unauthenticated; a real backend uses
// Bearer / Basic / MutualTls with a configured backend credential.
builder.Services.AddStateTools();
builder.Services.AddTelemetryTools(o =>
{
o.BackendAddress = new Uri(PrometheusAddress);
o.AuthMode = LatticeTelemetryBackendAuthMode.None;
});
var app = builder.Build();
app.MapPrometheusScrapingEndpoint();
app.MapLatticeMcp();
await app.StartAsync();
Console.WriteLine($"Silo + MCP server started; /metrics on http://localhost:{Port}/metrics\n");
var store = app.Services.GetRequiredService<ILatticeAuthorizationPolicyStore>();
var grainFactory = app.Services.GetRequiredService<IGrainFactory>();
var tree = grainFactory.GetGrain<ILattice>(DemoTree);
// -- Seed the agent, its Telemetry-only grant, and drive some load -----------
// Seeding writes the reserved policy tree, which requires Admin, so it runs as
// the bootstrap administrator (which bypasses the gate). The writes also drive
// the orleans.lattice meter so Prometheus has cluster metrics to serve.
Console.WriteLine("Seeding an 'agent' subject with a cluster-wide Telemetry grant, and driving load...");
using (LatticeCredentialContext.Use("root-admin", scheme: Scheme))
{
// A cluster-wide Telemetry grant: an Allow rule over the all-trees sentinel
// scope, conferring only LatticeOperation.Telemetry.
await store.PutRuleAsync(new LatticeAuthorizationRule(
"agent-telemetry",
LatticeSubjectSelector.User(Agent),
LatticeScope.ClusterWide(),
LatticeOperation.Telemetry,
LatticeEffect.Allow));
for (var i = 0; i < 50; i++)
{
await tree.SetAsync($"item/{i:D3}", Encoding.UTF8.GetBytes($"value-{i}"));
}
for (var i = 0; i < 50; i++)
{
_ = await tree.GetAsync($"item/{i:D3}");
}
}
// -- Act 1: the authenticated agent discovers and uses the telemetry tools ---
Console.WriteLine("\n== Act 1: a Telemetry-granted agent queries live metrics over MCP ==");
await using var agentClient = await ConnectAsync(withAgentHeader: true);
// The compiled policy snapshot rebuilds off the policy-tree change feed, so poll
// the advertised tool list until the Telemetry grant surfaces the telemetry
// tools (the meta-tool alone is always present for an authenticated caller).
var agentTools = await WaitForTelemetryToolsAsync(agentClient, TimeSpan.FromSeconds(20));
var toolNames = agentTools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
var telemetryTools = toolNames.Where(n => n.StartsWith("lattice_telemetry_", StringComparison.Ordinal)).ToArray();
var stateTools = toolNames.Where(n => n.StartsWith("lattice_state_", StringComparison.Ordinal)).ToArray();
Console.WriteLine($" agent sees {telemetryTools.Length} telemetry tool(s):");
foreach (var name in telemetryTools)
{
Console.WriteLine($" - {name}");
}
Console.WriteLine($" agent sees {stateTools.Length} state tool(s) (it was NOT granted state access).");
// Wait for Prometheus to scrape this silo at least once (up == 1), which also
// confirms the Docker Prometheus is running and reachable.
Console.WriteLine($"\n Waiting for Prometheus ({PrometheusAddress}) to scrape this silo...");
var scraped = await WaitForScrapeAsync(agentClient, TimeSpan.FromSeconds(45));
if (!scraped)
{
Console.WriteLine(" [!] Prometheus did not report this silo as up.");
Console.WriteLine(" Start it first with: docker compose up -d");
await app.StopAsync();
return 2;
}
Console.WriteLine(" Prometheus is scraping this silo. Running live telemetry queries:\n");
// A guaranteed-present instant query: the scrape-health of this silo.
var up = await agentClient.CallToolAsync(
"lattice_telemetry_query",
new Dictionary<string, object?> { ["query"] = "up{job=\"lattice-silo\"}" });
Console.WriteLine($" lattice_telemetry_query(up{{job=\"lattice-silo\"}}) -> {Structured(up)}");
// List the metric names Prometheus exposes and show the cluster's own metrics.
var metricsResult = await agentClient.CallToolAsync(
"lattice_telemetry_list_metrics",
new Dictionary<string, object?>());
var latticeMetrics = ExtractLatticeMetricNames(metricsResult).ToArray();
Console.WriteLine($" lattice_telemetry_list_metrics -> {latticeMetrics.Length} orleans.lattice metric(s) discovered, e.g.:");
foreach (var name in latticeMetrics.Take(5))
{
Console.WriteLine($" - {name}");
}
// If we discovered a real lattice metric, query it too.
if (latticeMetrics.Length > 0)
{
var metric = latticeMetrics[0];
var series = await agentClient.CallToolAsync(
"lattice_telemetry_query",
new Dictionary<string, object?> { ["query"] = metric });
Console.WriteLine($" lattice_telemetry_query({metric}) -> {Structured(series)}");
}
// -- Act 2: the anonymous caller (fail-closed) -------------------------------
Console.WriteLine("\n== Act 2: an unauthenticated caller is offered nothing ==");
await using var anonClient = await ConnectAsync(withAgentHeader: false);
var anonTools = await anonClient.ListToolsAsync();
Console.WriteLine($" anonymous caller sees {anonTools.Count} tools (fail-closed).");
var ok = telemetryTools.Length == 4 && stateTools.Length == 0 && anonTools.Count == 0 && scraped;
Console.WriteLine();
Console.WriteLine(ok
? "[OK] the Telemetry grant unlocked exactly the telemetry tools; the query hit real Prometheus; the anonymous caller got none."
: "[FAIL] the sample did not reach the expected end state.");
await app.StopAsync();
return ok ? 0 : 1;
// --- helpers ---------------------------------------------------------------
// Connects a real MCP client over streamable HTTP to the /mcp endpoint. When
// withAgentHeader is set, the request carries the marker header the demo bridge
// maps to the agent.
async Task<McpClient> ConnectAsync(bool withAgentHeader)
{
var options = new HttpClientTransportOptions
{
Endpoint = new Uri($"http://localhost:{Port}/mcp"),
Name = withAgentHeader ? "agent" : "anonymous",
};
if (withAgentHeader)
{
options.AdditionalHeaders = new Dictionary<string, string>
{
[DemoCredentialBridge.AgentHeader] = "true",
};
}
return await McpClient.CreateAsync(new HttpClientTransport(options));
}
// Polls the advertised tool list until a lattice_telemetry_* tool appears (the
// Telemetry grant has compiled into the policy snapshot) or the budget elapses.
async Task<IList<McpClientTool>> WaitForTelemetryToolsAsync(McpClient client, TimeSpan budget)
{
var deadline = DateTime.UtcNow + budget;
while (DateTime.UtcNow < deadline)
{
var tools = await client.ListToolsAsync();
if (tools.Any(t => t.Name.StartsWith("lattice_telemetry_", StringComparison.Ordinal)))
{
return tools;
}
await Task.Delay(TimeSpan.FromMilliseconds(500));
}
return await client.ListToolsAsync();
}
// Polls lattice_telemetry_query for this silo's scrape-health until Prometheus
// reports it up (a non-empty vector), or the budget elapses.
async Task<bool> WaitForScrapeAsync(McpClient client, TimeSpan budget)
{
var deadline = DateTime.UtcNow + budget;
while (DateTime.UtcNow < deadline)
{
var result = await client.CallToolAsync(
"lattice_telemetry_query",
new Dictionary<string, object?> { ["query"] = "up{job=\"lattice-silo\"}" });
if (TryReadStructured(result, out var root)
&& root.TryGetProperty("success", out var success)
&& success.GetBoolean()
&& root.TryGetProperty("series", out var series)
&& series.ValueKind == JsonValueKind.Array
&& series.GetArrayLength() > 0)
{
return true;
}
await Task.Delay(TimeSpan.FromSeconds(1));
}
return false;
}
// Renders a tool result's structured content compactly for the console.
string Structured(CallToolResult result) =>
TryReadStructured(result, out var root) ? root.GetRawText() : "(no structured content)";
// Pulls the orleans.lattice metric names out of a list_metrics result.
IEnumerable<string> ExtractLatticeMetricNames(CallToolResult result)
{
if (!TryReadStructured(result, out var root)
|| !root.TryGetProperty("metrics", out var metrics)
|| metrics.ValueKind != JsonValueKind.Array)
{
yield break;
}
foreach (var element in metrics.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.String
&& element.GetString() is { } name
&& name.Contains("lattice", StringComparison.OrdinalIgnoreCase))
{
yield return name;
}
}
}
// Parses the tool result's structured content into a JsonElement.
static bool TryReadStructured(CallToolResult result, out JsonElement root)
{
if (result.StructuredContent is { } element)
{
root = element.Clone();
return true;
}
root = default;
return false;
}
DemoAuthenticator.cs
using Orleans.Lattice.Membership;
namespace Orleans.Lattice.Samples.McpTelemetry;
/// <summary>
/// A minimal demo <see cref="ILatticeCredentialAuthenticator"/> that trusts the
/// ambient credential's token as the caller subject id. It handles only
/// credentials stamped with <see cref="Scheme"/>, so it never shadows the
/// built-in anonymous authenticator for an unstamped (system-origin) turn.
///
/// A real deployment resolves the subject from a validated JWT or Entra token
/// (see the authenticators shipped with the Membership package); this sample uses
/// a trivial trusted-token authenticator so the whole flow runs on one silo with
/// no identity provider.
/// </summary>
internal sealed class DemoAuthenticator : ILatticeCredentialAuthenticator
{
/// <summary>The scheme hint this authenticator claims.</summary>
public const string Scheme = "demo-scheme";
/// <summary>The issuer stamped on the resolved principal.</summary>
public const string Issuer = "https://issuer.mcp-telemetry.sample/";
/// <inheritdoc />
public bool CanHandle(in LatticeCredential credential) =>
string.Equals(credential.Scheme, Scheme, StringComparison.Ordinal);
/// <inheritdoc />
public ValueTask<LatticePrincipal?> AuthenticateAsync(
LatticeCredential credential,
CancellationToken cancellationToken = default) =>
new(new LatticePrincipal(credential.Token, Issuer));
}
DemoCredentialBridge.cs
using Microsoft.AspNetCore.Http;
using Orleans.Lattice.Api.Mcp;
using Orleans.Lattice.Auth;
namespace Orleans.Lattice.Samples.McpTelemetry;
/// <summary>
/// A minimal demo <see cref="ILatticeApiMcpCredentialBridge"/> that stands in for
/// a real authentication integration. In a production host the built-in
/// HttpContext bridge lifts an authenticated ASP.NET Core principal onto the
/// ambient Lattice credential; here, to keep the sample runnable with no identity
/// provider, this bridge simply maps a request that carries the
/// <see cref="AgentHeader"/> marker header onto a fixed <c>agent</c> credential,
/// and treats every other request as anonymous.
///
/// The credential's scheme is <see cref="DemoAuthenticator.Scheme"/> so the
/// cluster's authenticator resolves it to the <c>agent</c> subject when the agent
/// actually invokes a tool, and its <c>PrincipalId</c> is <c>agent</c> so the MCP
/// discovery core scopes the advertised tool list to that subject's grants.
/// </summary>
internal sealed class DemoCredentialBridge : ILatticeApiMcpCredentialBridge
{
/// <summary>The marker header a request sends to be treated as the agent.</summary>
public const string AgentHeader = "x-demo-agent";
/// <summary>The subject id the agent request resolves to.</summary>
public const string AgentSubject = "agent";
/// <inheritdoc />
public LatticeCredential? Resolve(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
// Fail closed: only a request carrying the marker header is the agent;
// everything else is anonymous and is offered no tools.
if (!context.Request.Headers.ContainsKey(AgentHeader))
{
return null;
}
return new LatticeCredential(
token: AgentSubject,
scheme: DemoAuthenticator.Scheme,
principalId: AgentSubject);
}
}
docker-compose.yml
# McpTelemetry sample - a single real Prometheus that scrapes the sample process.
#
# Start it before running the sample:
#
# docker compose up -d
# dotnet run
#
# Prometheus scrapes the host process's /metrics endpoint (host.docker.internal
# -> the machine running `dotnet run`) and publishes its HTTP API on
# http://localhost:9090, which the sample's telemetry tools query over PromQL.
#
# Tear down with: docker compose down
services:
prometheus:
image: prom/prometheus:v3.1.0
container_name: lattice-mcp-telemetry-prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
extra_hosts:
# Lets the container reach the host process on Linux as well as Docker
# Desktop, where host.docker.internal is already provided.
- "host.docker.internal:host-gateway"
McpTelemetry.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Orleans.Lattice.Samples.McpTelemetry</RootNamespace>
<AssemblyName>Orleans.Lattice.Samples.McpTelemetry</AssemblyName>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Orleans.Server" Version="10.2.2" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.3-beta.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\lattice\Orleans.Lattice.csproj" />
<ProjectReference Include="..\..\src\lattice.membership\Orleans.Lattice.Membership.csproj" />
<ProjectReference Include="..\..\src\lattice.auth\Orleans.Lattice.Auth.csproj" />
<ProjectReference Include="..\..\src\lattice.api.state\Orleans.Lattice.Api.State.csproj" />
<ProjectReference Include="..\..\src\lattice.api.data\Orleans.Lattice.Api.Data.csproj" />
<ProjectReference Include="..\..\src\lattice.api.auth\Orleans.Lattice.Api.Auth.csproj" />
<ProjectReference Include="..\..\src\lattice.api.mcp\Orleans.Lattice.Api.Mcp.csproj" />
<ProjectReference Include="..\..\src\lattice.api.mcp.telemetry\Orleans.Lattice.Api.Mcp.Telemetry.csproj" />
</ItemGroup>
</Project>
prometheus.yml
# Prometheus scrape config for the McpTelemetry sample.
#
# Prometheus runs in Docker and scrapes the sample process, which runs on the
# host and exposes the orleans.lattice meter at /metrics via the OpenTelemetry
# Prometheus exporter (see Program.cs). host.docker.internal resolves to the
# host from inside the container (Docker Desktop provides it; the compose file
# adds a host-gateway mapping so it also works on Linux).
#
# The job_name is "lattice-silo" so the sample's `up{job="lattice-silo"}`
# scrape-health query resolves.
global:
scrape_interval: 2s
evaluation_interval: 2s
scrape_configs:
- job_name: lattice-silo
metrics_path: /metrics
static_configs:
- targets:
- host.docker.internal:5290