This blog post shows how to move beyond a simple agent calling a model, by building a distributed application that combines a Chat API, a Model Context Protocol (MCP) server, a RAG-based API, local Ollama models, and Keycloak-based security. The focus throughout is on how an agent can use MCP tools backed by a real retrieval pipeline, all while keeping the application observable and easy to run locally.
In the demo application, ChatApi hosts the agent and exposes /chat, OpenAI-compatible endpoints, and DevUI for local development. The agent can reach tools through MCP-SSE over streamable HTTP, or through MCP-STDIO for local development parity testing. Both MCP servers expose the same Dapr-focused RAG tool surface, while RAGApi takes care of document indexing, semantic search, grounded answer generation, and model calls through Ollama.
The blog post begins with the distributed application structure, then walks through each building block in turn: Aspire AppHost orchestration, ServiceDefaults for service discovery and OpenTelemetry, RAGApi, MCP-SSE, MCP-STDIO, ChatApi, the Web client, and the Keycloak flow used to protect the HTTP MCP endpoint. It also covers the developer tooling around the MCP endpoint, including MCP Inspector , MCP Scan, and MCP Shield.
Here's a glimpse of the final app, built up incrementally throughout this post.

NOTE: The demo app supports two modes: Development and Keycloak. Development mode skips Keycloak entirely, leaving /mcp unprotected to speed up local inner-loop development. Keycloak mode, on the other hand, exercises the full secure flow, the AppHost starts Keycloak and protects /mcp accordingly.
Prerequisites
- Docker for Desktop - Docker Desktop enhances your development experience by offering a powerful, user-friendly platform for container management.
- Microsoft Agent Framework - Microsoft Agent Framework (MAF) is an open, multi-language framework for building production-grade AI agents and multi-agent workflows in .NET and Python.
- Aspire - Aspire gives you a unified, code-first toolkit to compose, debug, and deploy distributed apps and agents, all from a single AppHost.
- MCP Inspector - The MCP Inspector is an interactive developer tool for testing and debugging MCP servers.
- MCP Scan - A tool for scanning MCP (Model Context Protocol) servers and tools for potential security findings. The MCP Scanner combines Cisco AI Defense inspect API, YARA rules and LLM-as-a-judge to detect malicious MCP tools. Scan MCP servers for potential threats & security findings.
- MCP Shield - Security scanner, compliance checker, and runtime proxy for MCP servers. Scans tools for poisoning, injection vectors, dangerous operations. Scores 0-100 with actionable recommendations.
- Ollama - Ollama is an open-source framework designed for running and managing Large Language Models (LLMs) directly on your local computer.
- Keycloak - Open Source Identity and Access Management For Modern Applications and Services
DEMO App
The demo application consists of multiple projects, each with a focused responsibility. MCP-Aspire.AppHost defines the distributed application graph, MCP-Aspire.RAGApi owns retrieval and answer generation, MCP-Aspire.MCP-SSE exposes the secured streamable HTTP MCP endpoint, MCP-Aspire.MCP-STDIO provides a local stdio MCP variant, MCP-Aspire.ChatApi hosts the agent, and MCP-Aspire.Web provides the chat UI.
The solution structure should look similar to the image given below.

The demo separates three concerns: RAGApi performs retrieval and grounded answer generation, MCP servers expose that capability as tools, and ChatApi uses Microsoft Agent Framework to decide when to call those tools during a conversation.
In Keycloak mode, ChatApi reaches the MCP tool surface through MCP-SSE using a client-credentials bearer token. In Development mode, ChatApi can call the same MCP-SSE endpoint without bearer auth or launch MCP-STDIO as a local child process. Both MCP transports call the same internal RAGApi, so the tool behavior stays aligned across secure and local workflows.
The following diagram depicts the application topology.

Let's begin
With the initial structure in place, let us start building out the rest beginning with the external dependencies and the Aspire application graph.
App Host
Aspire's AppHost is the code-first place where the distributed application is declared. In this demo, it starts Ollama, loads the chat and embedding models, optionally starts Keycloak, wires the application projects together, and adds MCP inspection and security scanning tools.
The AppHost also controls the authentication mode. The default mode is Development, which keeps the local inner loop simple. When Keycloak is enabled, the AppHost adds Keycloak to the graph and passes the required issuer, audience, token endpoint, client credentials, and MCP scopes to the relevant projects.

Add the CommunityToolkit.Aspire.Hosting.Ollama NuGet package to the AppHost. It provides the extension methods and resource definitions needed to run Ollama as part of the Aspire application and register model resources that other projects can consume through connection strings.
dotnet add package CommunityToolkit.Aspire.Hosting.Ollama --version 13.4.0Keycloak/import/mcp-aspire-realm.json
{
"realm": "mcp-aspire",
"enabled": true,
"displayName": "MCP Aspire",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"clientScopes": [
{
"name": "mcp:tools",
"description": "Allows MCP tool discovery and tool calls.",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "mcp-tools-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:5161/mcp",
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false"
}
}
]
},
{
"name": "mcp:prompts",
"description": "Allows MCP prompt discovery and prompt reads.",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "mcp-prompts-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:5161/mcp",
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false"
}
}
]
},
{
"name": "mcp:resources",
"description": "Allows MCP resource discovery and resource reads.",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "mcp-resources-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:5161/mcp",
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false"
}
}
]
}
],
"clients": [
{
"clientId": "mcp-aspire-chatapi",
"name": "MCP Aspire ChatApi",
"description": "Service client used by ChatApi to call MCP-SSE.",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"secret": "mcp-aspire-chatapi-dev-secret",
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"authorizationServicesEnabled": false,
"fullScopeAllowed": false,
"optionalClientScopes": [
"mcp:tools",
"mcp:prompts",
"mcp:resources"
],
"defaultClientScopes": []
},
{
"clientId": "mcp-aspire-web",
"name": "MCP Aspire Web",
"description": "Reserved for the later delegated Web login phase.",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"authorizationServicesEnabled": false,
"fullScopeAllowed": false,
"redirectUris": [
"https://localhost:7450/signin-oidc",
"http://localhost:5204/signin-oidc"
],
"webOrigins": [
"https://localhost:7450",
"http://localhost:5204"
],
"optionalClientScopes": [
"mcp:tools",
"mcp:prompts",
"mcp:resources"
],
"defaultClientScopes": []
}
]
}keycloak/import/mcp-aspire-realm.json defines the local Keycloak realm used by the demo app. It preconfigures the mcp-aspire realm with MCP scopes, audience mappings for http://localhost:5161/mcp, and confidential clients such as mcp-aspire-chatapi, so ChatApi and the Inspector proxy can
obtain client-credentials tokens that MCP-SSE will accept. This keeps the secure flow reproducible when AppHost starts Keycloak with realm import enabled.
AppHost.cs
using Microsoft.Extensions.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
const string keycloakMode = "Keycloak";
const string developmentMode = "Development";
string mode = builder.Configuration["Auth:Mode"] ?? developmentMode;
mode = mode switch
{
var value when value.Equals(keycloakMode, StringComparison.OrdinalIgnoreCase) => keycloakMode,
var value when value.Equals(developmentMode, StringComparison.OrdinalIgnoreCase) => developmentMode,
_ => throw new InvalidOperationException($"Unsupported Auth:Mode '{mode}'. Use '{keycloakMode}' or '{developmentMode}'.")
};
bool useKeycloak = mode == keycloakMode;
bool useDevelopment = mode == developmentMode;
if (useDevelopment && !builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("Auth:Mode=Development is allowed only when the AppHost environment is Development.");
}
// 1. Infrastructure & Other dependencies
// 1.0 Ollama & Model
const string chatModelId = "llama3.2:latest";
const string embeddingModelId = "nomic-embed-text";
const string keycloakRealm = "mcp-aspire";
const string mcpResourceUri = "http://localhost:5161/mcp";
const string mcpResourceMetadataUri = "http://localhost:5161/.well-known/oauth-protected-resource/mcp";
const string chatApiClientId = "mcp-aspire-chatapi";
string mcpInspectorCatalogPath = Path.Combine(builder.AppHostDirectory, "mcp-inspector-catalog.json");
var ollama = builder.AddOllama("ollama")
.WithContainerName("mcp-aspire-ollama")
.WithEnvironment("OLLAMA_KEEP_ALIVE", "-1")
.WithEnvironment("OLLAMA_MAX_LOADED_MODELS", "2")
.WithDataVolume()
.WithUrlForEndpoint("http", url =>
{
url.Url = "/";
url.DisplayText = "Ollama LLM";
});
var chatModel = ollama.AddModel("model", chatModelId);
var embeddingModel = ollama.AddModel("embedding-model", embeddingModelId);
// 1.2 Keycloak
IResourceBuilder<ContainerResource>? keycloak = null;
IResourceBuilder<ParameterResource>? chatApiClientSecret = null;
if (useKeycloak)
{
var keycloakAdminPassword = builder.AddParameter("keycloak-admin-password", "admin", secret: true);
chatApiClientSecret = builder.AddParameter("keycloak-chatapi-client-secret", "mcp-aspire-chatapi-dev-secret", secret: true);
string keycloakImportPath = Path.Combine(builder.AppHostDirectory, "Keycloak", "import");
keycloak = builder.AddContainer("keycloak", "quay.io/keycloak/keycloak", "26.6.2")
.WithContainerName("mcp-aspire-keycloak")
.WithHttpEndpoint(targetPort: 8080, name: "http")
.WithHttpEndpoint(targetPort: 9000, name: "management")
.WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", "admin")
.WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", keycloakAdminPassword)
.WithEnvironment("KC_HEALTH_ENABLED", "true")
.WithBindMount(keycloakImportPath, "/opt/keycloak/data/import", isReadOnly: true)
.WithArgs("start-dev", "--features=cimd", "--import-realm")
.WithHttpHealthCheck("/health/ready", endpointName: "management")
.WithUrlForEndpoint("http", url =>
{
url.Url = "/admin";
url.DisplayText = "Keycloak Admin";
})
.WithUrlForEndpoint("management", url =>
{
url.Url = "/health/ready";
url.DisplayText = "Keycloak Health";
});
}
// 2. APIs, MCPs and Web App
// 2.1 RAG API
var ragApi = builder.AddProject<Projects.MCP_Aspire_RAGApi>("ragapi")
.WithHttpHealthCheck("/health")
.WithEnvironment("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "true")
.WithReference(chatModel, "chat")
.WithReference(embeddingModel, "embedding")
.WaitFor(chatModel)
.WaitFor(embeddingModel)
.WithUrlForEndpoint("http", url =>
{
url.Url = "/Scalar";
url.DisplayText = "RAG Api";
})
.WithUrlForEndpoint("https", url =>
{
url.Url = "/Scalar";
url.DisplayText = "RAG Api";
});
// 2.2 MCP-SSE
var mcpSse = builder.AddProject<Projects.MCP_Aspire_MCP_SSE>("mcp-sse")
.WithHttpHealthCheck("/health")
.WithExternalHttpEndpoints()
.WithEnvironment("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "true")
.WithEnvironment("Authentication__Mode", mode)
.WithReference(ragApi)
.WaitFor(ragApi)
.WithUrlForEndpoint("http", url =>
{
url.Url = "/health";
url.DisplayText = "MCP SSE";
})
.WithUrlForEndpoint("https", url =>
{
url.Url = "/health";
url.DisplayText = "MCP SSE";
});
if (useKeycloak && keycloak is not null)
{
mcpSse = mcpSse
.WithMcpResourceAuthentication(
ReferenceExpression.Create($"{keycloak.GetEndpoint("http")}/realms/{keycloakRealm}"),
mcpResourceUri,
mcpResourceMetadataUri,
requireHttpsMetadata: false)
.WaitFor(keycloak);
}
// 2.4 MCP-ChatApi
var chatApi = builder.AddProject<Projects.MCP_Aspire_ChatApi>("chatapi")
.WithHttpHealthCheck("/health")
.WithEnvironment("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "true")
.WithEnvironment("McpAuth__Mode", mode)
.WithEnvironment("McpAuth__StdioTransportEnabled", useDevelopment.ToString())
.WithReference(chatModel, "chat")
.WithReference(mcpSse)
.WithReference(ragApi)
.WaitFor(chatModel)
.WaitFor(mcpSse)
.WaitFor(ragApi)
.WithUrlForEndpoint("http", url =>
{
url.Url = "/";
url.DisplayText = "Chat Api";
})
.WithUrlForEndpoint("https", url =>
{
url.Url = "/";
url.DisplayText = "Chat Api";
})
.WithUrlForEndpoint("http", url =>
{
url.Url = "/devui";
url.DisplayText = "Chat DevUI";
});
if (useKeycloak && keycloak is not null && chatApiClientSecret is not null)
{
chatApi = chatApi
.WithMcpClientCredentials(
ReferenceExpression.Create($"{keycloak.GetEndpoint("http")}/realms/{keycloakRealm}/protocol/openid-connect/token"),
chatApiClientId,
chatApiClientSecret)
.WaitFor(keycloak);
}
// 2.5 Web App
builder.AddProject<Projects.MCP_Aspire_Web>("web")
.WithExternalHttpEndpoints()
.WithEnvironment("Auth__Mode", mode)
.WithReference(chatApi)
.WaitFor(chatApi)
.WithUrlForEndpoint("http", url =>
{
url.Url = "/";
url.DisplayText = "Web Chat";
})
.WithUrlForEndpoint("https", url =>
{
url.Url = "/";
url.DisplayText = "Web Chat";
});
// 3. MCP inspection and safety tooling
// 3.0 MCP Inspector auth proxy
var mcpInspectorProxy = builder.AddProject<Projects.MCP_Aspire_MCP_InspectorProxy>("mcp-inspector-proxy")
.WithHttpHealthCheck("/health")
.WithExternalHttpEndpoints()
.WithEnvironment("McpAuth__Mode", mode)
.WithReference(mcpSse)
.WaitFor(mcpSse)
.WithUrlForEndpoint("http", url =>
{
url.Url = "/";
url.DisplayText = "MCP Inspector Auth Proxy Status";
})
.WithUrlForEndpoint("https", url =>
{
url.Url = "/";
url.DisplayText = "MCP Inspector Auth Proxy Status";
});
if (useKeycloak && keycloak is not null && chatApiClientSecret is not null)
{
mcpInspectorProxy = mcpInspectorProxy
.WithMcpClientCredentials(
ReferenceExpression.Create($"{keycloak.GetEndpoint("http")}/realms/{keycloakRealm}/protocol/openid-connect/token"),
chatApiClientId,
chatApiClientSecret)
.WaitFor(keycloak);
}
// 3.1 MCP Inspector
builder.AddContainer("mcp-inspector", "ghcr.io/modelcontextprotocol/inspector", "latest")
.WithContainerName("mcp-aspire-inspector")
.WithContainerRuntimeArgs(context =>
{
context.Args.Add("--user");
context.Args.Add("root");
})
.WithEnvironment("HOST", "0.0.0.0")
.WithEnvironment("MCP_AUTO_OPEN_ENABLED", "false")
.WithEnvironment("DANGEROUSLY_OMIT_AUTH", "true")
.WithBindMount(mcpInspectorCatalogPath, "/mcp-inspector/catalog.json")
.WithEntrypoint("/bin/sh")
.WithArgs("-c", """
cat > /usr/local/lib/node_modules/@modelcontextprotocol/inspector/node_modules/@napi-rs/keyring/index.js <<'EOF'
class AsyncEntry {
constructor() {}
async getPassword() { return null; }
async setPassword() {}
async deleteCredential() {}
}
class Entry {
constructor() {}
getPassword() { return null; }
setPassword() {}
deleteCredential() {}
}
exports.AsyncEntry = AsyncEntry;
exports.Entry = Entry;
exports.findCredentials = () => [];
exports.findCredentialsAsync = async () => [];
EOF
exec mcp-inspector --catalog /mcp-inspector/catalog.json --server mcp-aspire
""")
.WithReference(mcpInspectorProxy)
.WaitFor(mcpInspectorProxy)
.WithHttpEndpoint(port: 6274, targetPort: 6274, name: "http")
.WithHttpEndpoint(port: 6277, targetPort: 6277, name: "proxy")
.WithUrlForEndpoint("http", url =>
{
url.Url = "/";
url.DisplayText = "MCP Inspector";
})
.WithUrls(context =>
{
context.Urls.RemoveAll(url => string.Equals(url.Endpoint?.EndpointName, "proxy", StringComparison.OrdinalIgnoreCase));
});
// 3.2 MCP Scan
builder.AddContainer("mcp-scan", "ghcr.io/astral-sh/uv", "python3.13-bookworm")
.WithContainerName("mcp-aspire-scan")
.WaitFor(mcpInspectorProxy)
.WithEnvironment("MCP_PROXY_ENDPOINT", mcpInspectorProxy.GetEndpoint("http"))
.WithEnvironment("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt")
.WithEnvironment("UV_CACHE_DIR", "/root/.cache/uv")
.WithEnvironment("UV_LINK_MODE", "copy")
.WithVolume("mcp-scan-uv-cache", "/root/.cache/uv")
.WithExplicitStart()
.WithEntrypoint("/bin/sh")
.WithArgs("-c", """
set -eu
unset SSL_CERT_DIR
uvx --python 3.13 --from cisco-ai-mcp-scanner mcp-scanner \
--server-url "${MCP_PROXY_ENDPOINT}/mcp" \
--analyzers yara,prompt_defense \
--format detailed
""");
// 3.3 MCP Shield
builder.AddContainer("mcp-shield", "ghcr.io/astral-sh/uv", "python3.13-bookworm")
.WithContainerName("mcp-aspire-shield")
.WaitFor(mcpInspectorProxy)
.WithEnvironment("MCP_PROXY_ENDPOINT", mcpInspectorProxy.GetEndpoint("http"))
.WithEnvironment("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt")
.WithEnvironment("UV_CACHE_DIR", "/root/.cache/uv")
.WithEnvironment("UV_LINK_MODE", "copy")
.WithVolume("mcp-shield-uv-cache", "/root/.cache/uv")
.WithExplicitStart()
.WithEntrypoint("/bin/sh")
.WithArgs("-c", """
unset SSL_CERT_DIR
uvx --python 3.13 --from mcp-shield-cli --with mcp==1.29.0 mcp-shield test "${MCP_PROXY_ENDPOINT}/mcp" \
--suite all \
--format terminal \
--timeout 30
""");
builder.Build().Run();AppHostExtensions.cs
internal static class AppHostExtensions
{
public static IResourceBuilder<T> WithMcpResourceAuthentication<T>(
this IResourceBuilder<T> builder,
ReferenceExpression authority,
string audience,
string resourceMetadataUrl,
bool requireHttpsMetadata)
where T : IResourceWithEnvironment
{
return builder
.WithEnvironment("Authentication__Authority", authority)
.WithEnvironment("Authentication__Audience", audience)
.WithEnvironment("Authentication__ResourceMetadataUrl", resourceMetadataUrl)
.WithEnvironment("Authentication__RequireHttpsMetadata", requireHttpsMetadata.ToString())
.WithMcpScopes("Authentication__RequiredScopes");
}
public static IResourceBuilder<T> WithMcpClientCredentials<T>(
this IResourceBuilder<T> builder,
ReferenceExpression tokenEndpoint,
string clientId,
IResourceBuilder<ParameterResource> clientSecret)
where T : IResourceWithEnvironment
{
return builder
.WithEnvironment("McpAuth__TokenEndpoint", tokenEndpoint)
.WithEnvironment("McpAuth__ClientId", clientId)
.WithEnvironment("McpAuth__ClientSecret", clientSecret)
.WithMcpScopes("McpAuth__Scopes");
}
public static IResourceBuilder<T> WithMcpScopes<T>(this IResourceBuilder<T> builder, string configurationKey)
where T : IResourceWithEnvironment
{
return builder
.WithEnvironment($"{configurationKey}__0", "mcp:tools")
.WithEnvironment($"{configurationKey}__1", "mcp:prompts")
.WithEnvironment($"{configurationKey}__2", "mcp:resources");
}
}mcp-inspector-catalog.json
{
"mcpServers": {
"mcp-aspire": {
"type": "streamable-http",
"url": "http://host.docker.internal:5162/mcp"
}
}
}appsettings.json
{
"Auth": {
"Mode": "Development"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}Chat API
ChatApi is the application-facing agent service. It hosts the Microsoft Agent Framework chat agent, exposes POST /chat, maps OpenAI-compatible Responses and Conversations endpoints, and enables DevUI in development.
For every /chat request, ChatApi creates an MCP client using the requested transport. With SSE, it connects to MCP-SSE /mcp through streamable HTTP. In Keycloak mode, it obtains a client-credentials access token and attaches it to MCP requests. With STDIO, it launches the standalone MCP stdio server as a child process, which is allowed only in local Development mode.

Begin by adding the following packages to the ChatApi project.
dotnet add package Microsoft.Agents.AI --version 1.13.0
dotnet add package Microsoft.Agents.AI.DevUI --version 1.13.0-preview.260703.1
dotnet add package Microsoft.Agents.AI.Hosting --version 1.13.0-preview.260703.1
dotnet add package Microsoft.Agents.AI.Hosting.OpenAI --version 1.13.0-alpha.260703.1
dotnet add package Microsoft.AspNetCore.OpenApi --version 10.0.8
dotnet add package Microsoft.Extensions.AI --version 10.8.0
dotnet add package ModelContextProtocol --version 1.4.1
dotnet add package Microsoft.OpenApi --version 2.7.5
dotnet add package OllamaSharp --version 5.4.30Services/ChatAgentFactory.cs
using MCP_Aspire.Domain;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using OllamaSharp;
namespace MCP_Aspire.ChatApi.Services;
public sealed class ChatAgentFactory(
IConfiguration configuration,
IOptions<ChatSettings> chatSettings,
IMcpClientFactory mcpClientFactory,
ILoggerFactory loggerFactory,
IServiceProvider serviceProvider) : IChatAgentFactory
{
public async Task<ChatAgentLease> CreateAgentAsync(McpTransport transport, CancellationToken cancellationToken = default)
{
McpClient mcpClient = await mcpClientFactory.CreateClientAsync(transport, cancellationToken);
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
IList<AITool> tools = mcpTools.Cast<AITool>().ToList();
ChatSettings options = chatSettings.Value;
ChatModelOptions chatModel = ChatModelOptions.FromConnectionString(configuration, "chat");
var httpClient = new HttpClient
{
BaseAddress = new Uri(chatModel.Endpoint),
Timeout = TimeSpan.FromSeconds(options.ModelTimeoutSeconds)
};
var innerClient = new OllamaApiClient(httpClient, chatModel.ModelId, jsonSerializerContext: null);
IChatClient chatClient = new ChatClientBuilder(innerClient)
.UseOpenTelemetry(loggerFactory, configure: telemetry => telemetry.EnableSensitiveData = true)
.Build(serviceProvider);
var agent = new ChatClientAgent(
chatClient,
name: $"chat-{transport.ToString().ToLowerInvariant()}",
instructions: options.SystemPrompt,
tools: tools,
loggerFactory: loggerFactory);
return new ChatAgentLease(agent, mcpClient);
}
}Services/ChatAgentLease.cs
using Microsoft.Agents.AI;
using ModelContextProtocol.Client;
namespace MCP_Aspire.ChatApi.Services;
public sealed class ChatAgentLease(AIAgent agent, McpClient mcpClient) : IAsyncDisposable
{
public AIAgent Agent { get; } = agent;
public async ValueTask DisposeAsync()
{
await mcpClient.DisposeAsync();
}
}Services/ChatService.cs
using System.Diagnostics;
using MCP_Aspire.Domain;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol;
namespace MCP_Aspire.ChatApi.Services;
public class ChatService(
IConfiguration configuration,
ILoggerFactory loggerFactory,
IOptions<McpAuthOptions> mcpAuthOptions,
IChatAgentFactory agentFactory) : IChatService
{
private readonly ILogger<ChatService> _logger = loggerFactory.CreateLogger<ChatService>();
public async IAsyncEnumerable<string> ChatAsync(ChatRequest request)
{
AgentResponse response;
using (Activity? activity = ChatApiTelemetry.ActivitySource.StartActivity("chatapi.chat", ActivityKind.Internal))
{
McpAuthOptions auth = mcpAuthOptions.Value;
bool captureMessageContent = ChatApiTelemetry.CaptureMessageContent(configuration);
activity?.SetTag("gen_ai.operation.name", "chat");
activity?.SetTag("gen_ai.system", "agent-framework");
ChatModelOptions chatModel = ChatModelOptions.FromConnectionString(configuration, "chat");
activity?.SetTag("gen_ai.request.model", chatModel.ModelId);
activity?.SetTag("chatapi.model.id", chatModel.ModelId);
activity?.SetTag("mcp.transport", request.McpTransport.ToString().ToLowerInvariant());
activity?.SetTag("mcp.auth.mode", auth.Mode);
activity?.SetTag("mcp.auth.secured", auth.IsEnabled);
activity?.SetTag("mcp.stdio.enabled", auth.CanUseStdioTransport);
List<Microsoft.Extensions.AI.ChatMessage> messages = BuildChatMessages(request, captureMessageContent);
activity?.SetTag("chat.message_count", messages.Count);
await using ChatAgentLease agentLease = await agentFactory.CreateAgentAsync(request.McpTransport);
AIAgent agent = agentLease.Agent;
activity?.SetTag("chat.agent.name", agent.Name);
_logger.LogInformation(
"Running chat agent over {McpTransport}; MCP auth mode {McpAuthMode}; secured HTTP MCP flow {McpSecured}.",
request.McpTransport,
auth.Mode,
auth.IsEnabled);
if (captureMessageContent)
{
AddChatInputTelemetry(activity, request);
}
AgentSession session = await agent.CreateSessionAsync();
response = await agent.RunAsync(messages, session);
if (response.Usage is { } usage)
{
activity?.SetTag("gen_ai.usage.input_tokens", usage.InputTokenCount);
activity?.SetTag("gen_ai.usage.output_tokens", usage.OutputTokenCount);
activity?.SetTag("gen_ai.usage.total_tokens", usage.TotalTokenCount);
}
if (captureMessageContent && !string.IsNullOrWhiteSpace(response.Text))
{
ChatApiTelemetry.AddOutputMessage(activity, response.Text);
}
}
if (!string.IsNullOrWhiteSpace(response.Text))
{
_logger.LogTrace("Response: {Content}", response.Text);
yield return response.Text;
}
}
private List<Microsoft.Extensions.AI.ChatMessage> BuildChatMessages(ChatRequest request, bool captureMessageContent)
{
using Activity? activity = ChatApiTelemetry.ActivitySource.StartActivity("chatapi.messages.build", ActivityKind.Internal);
List<Microsoft.Extensions.AI.ChatMessage> messages = [];
int index = 1;
foreach (MCP_Aspire.Domain.ChatMessage entry in request.Messages)
{
_logger.LogTrace("{Role}: {Message}", entry.Role, entry.Message);
ChatRole role = entry.Role == Role.Assistant
? ChatRole.Assistant
: ChatRole.User;
messages.Add(new Microsoft.Extensions.AI.ChatMessage(role, entry.Message));
if (captureMessageContent)
{
ChatApiTelemetry.AddInputMessage(activity, index, entry.Role, entry.Message);
}
index++;
}
activity?.SetTag("chat.message_count", messages.Count);
return messages;
}
private static void AddChatInputTelemetry(Activity? activity, ChatRequest request)
{
int index = 1;
foreach (MCP_Aspire.Domain.ChatMessage entry in request.Messages)
{
ChatApiTelemetry.AddInputMessage(activity, index, entry.Role, entry.Message);
index++;
}
}
}Services/IChatAgentFactory.cs
using MCP_Aspire.Domain;
namespace MCP_Aspire.ChatApi.Services;
public interface IChatAgentFactory
{
Task<ChatAgentLease> CreateAgentAsync(McpTransport transport, CancellationToken cancellationToken = default);
}Services/IChatService.cs
using MCP_Aspire.Domain;
namespace MCP_Aspire.ChatApi.Services;
public interface IChatService
{
IAsyncEnumerable<string> ChatAsync(ChatRequest request);
}Services/IMcpAccessTokenProvider.cs
namespace MCP_Aspire.ChatApi.Services;
public interface IMcpAccessTokenProvider
{
ValueTask<string?> GetAccessTokenAsync(CancellationToken cancellationToken = default);
}Services/IMcpClientFactory.cs
using MCP_Aspire.Domain;
using ModelContextProtocol.Client;
namespace MCP_Aspire.ChatApi.Services;
public interface IMcpClientFactory
{
Task<McpClient> CreateClientAsync(McpTransport transport, CancellationToken cancellationToken = default);
}Services/KeycloakMcpAccessTokenProvider.cs
using System.Text.Json.Serialization;
using System.Diagnostics;
using MCP_Aspire.ChatApi;
using Microsoft.Extensions.Options;
namespace MCP_Aspire.ChatApi.Services;
public sealed class KeycloakMcpAccessTokenProvider(
IHttpClientFactory httpClientFactory,
IOptions<McpAuthOptions> options,
ILogger<KeycloakMcpAccessTokenProvider> logger) : IMcpAccessTokenProvider
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
private DateTimeOffset _expiresAt;
public async ValueTask<string?> GetAccessTokenAsync(CancellationToken cancellationToken = default)
{
McpAuthOptions auth = options.Value;
using Activity? activity = ChatApiTelemetry.ActivitySource.StartActivity("mcp.auth.client_credentials", ActivityKind.Internal);
activity?.SetTag("mcp.auth.mode", auth.Mode);
activity?.SetTag("mcp.auth.secured", auth.IsEnabled);
activity?.SetTag("mcp.auth.client_id", auth.ClientId);
activity?.SetTag("mcp.auth.scopes", string.Join(' ', auth.Scopes));
if (!auth.IsEnabled)
{
activity?.SetTag("mcp.auth.token_required", false);
return null;
}
if (HasValidToken())
{
activity?.SetTag("mcp.auth.token_required", true);
activity?.SetTag("mcp.auth.token_cache_hit", true);
return _accessToken;
}
await _refreshLock.WaitAsync(cancellationToken);
try
{
if (HasValidToken())
{
activity?.SetTag("mcp.auth.token_required", true);
activity?.SetTag("mcp.auth.token_cache_hit", true);
return _accessToken;
}
activity?.SetTag("mcp.auth.token_required", true);
activity?.SetTag("mcp.auth.token_cache_hit", false);
using var request = new HttpRequestMessage(HttpMethod.Post, auth.TokenEndpoint);
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = auth.ClientId!,
["client_secret"] = auth.ClientSecret!,
["scope"] = string.Join(' ', auth.Scopes)
});
HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Keycloak token request failed with status {StatusCode}.", response.StatusCode);
activity?.SetStatus(ActivityStatusCode.Error, $"Keycloak token request failed with status {response.StatusCode}.");
response.EnsureSuccessStatusCode();
}
McpTokenResponse token = await response.Content.ReadFromJsonAsync<McpTokenResponse>(cancellationToken)
?? throw new InvalidOperationException("Keycloak token response was empty.");
_accessToken = token.AccessToken;
_expiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(token.ExpiresIn - 30, 30));
activity?.SetTag("mcp.auth.token_expires_in_seconds", token.ExpiresIn);
return _accessToken;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
finally
{
_refreshLock.Release();
}
}
private bool HasValidToken() =>
!string.IsNullOrWhiteSpace(_accessToken) && DateTimeOffset.UtcNow < _expiresAt;
private sealed class McpTokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; init; } = "";
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
}
}Services/McpAuthOptions.cs
namespace MCP_Aspire.ChatApi.Services;
public sealed class McpAuthOptions
{
public string Mode { get; init; } = "Keycloak";
public bool StdioTransportEnabled { get; init; }
public string? TokenEndpoint { get; init; }
public string? ClientId { get; init; }
public string? ClientSecret { get; init; }
public string[] Scopes { get; init; } = ["mcp:tools", "mcp:prompts", "mcp:resources"];
public bool IsEnabled => string.Equals(Mode, "Keycloak", StringComparison.OrdinalIgnoreCase);
public bool CanUseStdioTransport =>
string.Equals(Mode, "Development", StringComparison.OrdinalIgnoreCase) &&
StdioTransportEnabled;
public void Validate(IHostEnvironment environment)
{
if (StdioTransportEnabled && !environment.IsDevelopment())
{
throw new InvalidOperationException("McpAuth:StdioTransportEnabled=true is allowed only in Development.");
}
if (string.Equals(Mode, "Development", StringComparison.OrdinalIgnoreCase))
{
if (!environment.IsDevelopment())
{
throw new InvalidOperationException("McpAuth:Mode=Development is allowed only in Development.");
}
return;
}
if (!IsEnabled)
{
throw new InvalidOperationException($"Unsupported McpAuth:Mode '{Mode}'. Use 'Keycloak' or 'Development'.");
}
if (StdioTransportEnabled)
{
throw new InvalidOperationException("McpAuth:StdioTransportEnabled=true is not allowed when McpAuth:Mode=Keycloak.");
}
if (string.IsNullOrWhiteSpace(TokenEndpoint) ||
string.IsNullOrWhiteSpace(ClientId) ||
string.IsNullOrWhiteSpace(ClientSecret))
{
throw new InvalidOperationException("McpAuth:Mode=Keycloak requires McpAuth:TokenEndpoint, McpAuth:ClientId, and McpAuth:ClientSecret.");
}
}
}Services/McpAuthorizationHandler.cs
using System.Diagnostics;
using System.Net.Http.Headers;
using MCP_Aspire.ChatApi;
using Microsoft.Extensions.Options;
namespace MCP_Aspire.ChatApi.Services;
public sealed class McpAuthorizationHandler(
IMcpAccessTokenProvider accessTokenProvider,
IOptions<McpAuthOptions> options) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
using Activity? activity = ChatApiTelemetry.ActivitySource.StartActivity("mcp.auth.attach_bearer_token", ActivityKind.Internal);
McpAuthOptions auth = options.Value;
activity?.SetTag("mcp.auth.mode", auth.Mode);
activity?.SetTag("mcp.auth.secured", auth.IsEnabled);
activity?.SetTag("mcp.auth.scopes", string.Join(' ', auth.Scopes));
activity?.SetTag("mcp.request.uri", request.RequestUri?.ToString());
try
{
string? accessToken = await accessTokenProvider.GetAccessTokenAsync(cancellationToken);
bool attachedToken = !string.IsNullOrWhiteSpace(accessToken);
if (attachedToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
activity?.AddEvent(new ActivityEvent("mcp.auth.bearer_token_attached"));
}
activity?.SetTag("mcp.auth.bearer_token_attached", attachedToken);
return await base.SendAsync(request, cancellationToken);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
}Services/McpClientFactory.cs
using System.Diagnostics;
using MCP_Aspire.ChatApi;
using MCP_Aspire.Domain;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
namespace MCP_Aspire.ChatApi.Services;
public sealed class McpClientFactory(
IHttpClientFactory httpClientFactory,
IHostEnvironment hostEnvironment,
IOptions<McpAuthOptions> mcpAuthOptions,
ILoggerFactory loggerFactory) : IMcpClientFactory
{
public async Task<McpClient> CreateClientAsync(McpTransport transport, CancellationToken cancellationToken = default)
{
using Activity? activity = ChatApiTelemetry.ActivitySource.StartActivity("mcp.transport.select", ActivityKind.Internal);
McpAuthOptions auth = mcpAuthOptions.Value;
activity?.SetTag("mcp.transport.requested", transport.ToString().ToLowerInvariant());
activity?.SetTag("mcp.auth.mode", auth.Mode);
activity?.SetTag("mcp.auth.secured", auth.IsEnabled);
activity?.SetTag("mcp.stdio.enabled", auth.CanUseStdioTransport);
try
{
IClientTransport clientTransport = transport switch
{
McpTransport.Stdio => CreateAllowedStdioTransport(activity),
_ => CreateHttpTransport(activity)
};
var clientOptions = new McpClientOptions
{
InitializationTimeout = TimeSpan.FromMinutes(2)
};
return await McpClient.CreateAsync(clientTransport, clientOptions, loggerFactory: loggerFactory, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private IClientTransport CreateAllowedStdioTransport(Activity? activity)
{
McpAuthOptions auth = mcpAuthOptions.Value;
if (!auth.CanUseStdioTransport)
{
throw new McpTransportNotAllowedException(
"STDIO MCP transport is disabled for ChatApi in Keycloak mode. Use SSE / HTTP, or run Auth:Mode=Development for local STDIO testing.");
}
activity?.SetTag("mcp.transport.effective", "stdio");
activity?.SetTag("mcp.transport.secured_http", false);
return CreateStdioTransport();
}
private IClientTransport CreateHttpTransport(Activity? activity)
{
HttpClient httpClient = httpClientFactory.CreateClient("mcp-sse");
activity?.SetTag("mcp.transport.effective", "streamable-http");
activity?.SetTag("mcp.transport.secured_http", mcpAuthOptions.Value.IsEnabled);
activity?.SetTag("mcp.endpoint", "http://mcp-sse/mcp");
return new HttpClientTransport(new HttpClientTransportOptions
{
Name = "MCP-Aspire MCP Server (SSE)",
Endpoint = new Uri("http://mcp-sse/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
ConnectionTimeout = TimeSpan.FromMinutes(2)
}, httpClient, loggerFactory);
}
private IClientTransport CreateStdioTransport()
{
string stdioProjectPath = Path.GetFullPath(
Path.Combine(
hostEnvironment.ContentRootPath,
"..",
"MCP-Aspire.MCP-STDIO",
"MCP-Aspire.MCP-STDIO.csproj"));
Dictionary<string, string?> environmentVariables = [];
Activity? traceActivity = Activity.Current;
if (traceActivity is { IdFormat: ActivityIdFormat.W3C, Id.Length: > 0 })
{
environmentVariables["MCP_ASPIRE_TRACEPARENT"] = traceActivity.Id;
if (!string.IsNullOrWhiteSpace(traceActivity.TraceStateString))
{
environmentVariables["MCP_ASPIRE_TRACESTATE"] = traceActivity.TraceStateString;
}
}
return new StdioClientTransport(new StdioClientTransportOptions
{
Name = "MCP-Aspire MCP Server (STDIO)",
Command = "dotnet",
Arguments = ["run", "--no-build", "--project", stdioProjectPath],
WorkingDirectory = hostEnvironment.ContentRootPath,
EnvironmentVariables = environmentVariables,
ShutdownTimeout = TimeSpan.FromSeconds(10)
}, loggerFactory);
}
}Services/McpTransportNotAllowedException.cs
namespace MCP_Aspire.ChatApi.Services;
public sealed class McpTransportNotAllowedException(string message) : Exception(message);appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"ModelContextProtocol": "Warning"
}
},
"Chat": {
"SystemPrompt": "You are a concise technical assistant for a Dapr-focused RAG sample. Use the available MCP tools to answer questions from the indexed Dapr corpus when relevant. Prefer grounded answers, mention source file names when available, and say when the corpus does not contain enough information. Keep responses brief and professional.",
"ModelTimeoutSeconds": 600
},
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": true,
"AllowedHosts": "*"
}ChatApiTelemetry.cs
using System.Diagnostics;
using ModelContextProtocol.Protocol;
namespace MCP_Aspire.ChatApi;
public static class ChatApiTelemetry
{
private const int MaxCapturedContentLength = 4_096;
public const string InstrumentationName = "MCP-Aspire.ChatApi";
public static readonly ActivitySource ActivitySource = new(InstrumentationName);
public static bool CaptureMessageContent(IConfiguration configuration) =>
configuration.GetValue("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", false);
public static string TruncateCapturedContent(string? value)
{
value ??= string.Empty;
return value.Length <= MaxCapturedContentLength
? value
: string.Concat(value.AsSpan(0, MaxCapturedContentLength), "... [truncated]");
}
public static void AddInputMessage(Activity? activity, int index, Role role, string? content)
{
if (activity is null)
{
return;
}
string roleName = ToGenAiRoleName(role);
string capturedContent = TruncateCapturedContent(content);
activity.SetTag($"gen_ai.prompt.{index}.role", roleName);
activity.SetTag($"gen_ai.prompt.{index}.content", capturedContent);
activity.AddEvent(new ActivityEvent(
"gen_ai.prompt",
tags: new ActivityTagsCollection
{
["gen_ai.prompt.index"] = index,
["gen_ai.prompt.role"] = roleName,
["gen_ai.prompt.content"] = capturedContent
}));
activity.AddEvent(new ActivityEvent(
$"gen_ai.{roleName}.message",
tags: new ActivityTagsCollection
{
["gen_ai.message.role"] = roleName,
["gen_ai.message.content"] = capturedContent,
["content"] = capturedContent
}));
}
public static void AddOutputMessage(Activity? activity, string? content)
{
if (activity is null)
{
return;
}
string capturedContent = TruncateCapturedContent(content);
activity.SetTag("gen_ai.completion.0.role", "assistant");
activity.SetTag("gen_ai.completion.0.content", capturedContent);
activity.AddEvent(new ActivityEvent(
"gen_ai.completion",
tags: new ActivityTagsCollection
{
["gen_ai.completion.0.role"] = "assistant",
["gen_ai.completion.0.content"] = capturedContent
}));
activity.AddEvent(new ActivityEvent(
"gen_ai.assistant.message",
tags: new ActivityTagsCollection
{
["gen_ai.message.role"] = "assistant",
["gen_ai.message.content"] = capturedContent,
["content"] = capturedContent
}));
}
private static string ToGenAiRoleName(Role role) =>
role == Role.Assistant ? "assistant" : "user";
}ChatModelOptions.cs
using System.Data.Common;
namespace MCP_Aspire.ChatApi;
public sealed record ChatModelOptions(string Endpoint, string ModelId)
{
public static ChatModelOptions FromConnectionString(IConfiguration configuration, string name)
{
string connectionString = configuration.GetConnectionString(name)
?? throw new InvalidOperationException($"ConnectionStrings:{name} is required.");
var builder = new DbConnectionStringBuilder
{
ConnectionString = connectionString
};
return new ChatModelOptions(
GetRequiredValue(builder, name, "Endpoint", "Uri", "Url"),
GetRequiredValue(builder, name, "Model", "ModelName"));
}
private static string GetRequiredValue(DbConnectionStringBuilder builder, string connectionName, params string[] names)
{
foreach (string name in names)
{
if (builder.TryGetValue(name, out object? value)
&& value?.ToString() is { Length: > 0 } stringValue)
{
return stringValue;
}
}
throw new InvalidOperationException(
$"ConnectionStrings:{connectionName} is missing required value. Expected one of: {string.Join(", ", names)}.");
}
}ChatSettings.cs
namespace MCP_Aspire.ChatApi;
public class ChatSettings
{
public required string SystemPrompt { get; init; }
public int ModelTimeoutSeconds { get; init; } = 600;
}Program.cs
using System.Text.Json.Serialization;
using MCP_Aspire.ChatApi;
using MCP_Aspire.ChatApi.Services;
using MCP_Aspire.Domain;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using OllamaSharp;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.Configure<ChatSettings>(builder.Configuration.GetSection("Chat"));
builder.Services.Configure<McpAuthOptions>(builder.Configuration.GetSection("McpAuth"));
McpAuthOptions mcpAuth = builder.Configuration.GetSection("McpAuth").Get<McpAuthOptions>() ?? new();
mcpAuth.Validate(builder.Environment);
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddOpenApi();
builder.Services.AddScoped<IChatService, ChatService>();
builder.Services.AddScoped<IChatAgentFactory, ChatAgentFactory>();
builder.Services.AddScoped<IMcpClientFactory, McpClientFactory>();
builder.Services.AddSingleton<IMcpAccessTokenProvider, KeycloakMcpAccessTokenProvider>();
builder.Services.AddTransient<McpAuthorizationHandler>();
builder.Services.AddHttpClient("mcp-sse", client =>
{
client.Timeout = TimeSpan.FromMinutes(10);
})
.AddHttpMessageHandler<McpAuthorizationHandler>()
.RemoveAllResilienceHandlers();
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
builder.AddDevUI();
builder.AddAIAgent("chat", CreateChatAgent);
builder.Services.AddSingleton<AIAgent>(sp => sp.GetRequiredKeyedService<AIAgent>("chat"));
builder.Services.AddSingleton<IList<AITool>>(sp =>
{
McpClient client = sp.GetRequiredService<McpClient>();
IList<McpClientTool> tools = client.ListToolsAsync().GetAwaiter().GetResult();
return tools.Cast<AITool>().ToList();
});
builder.Services.AddSingleton<McpClient>(sp =>
{
IClientTransport clientTransport = sp.GetRequiredService<IClientTransport>();
ILoggerFactory loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var clientOptions = new McpClientOptions
{
InitializationTimeout = TimeSpan.FromMinutes(2)
};
return McpClient.CreateAsync(clientTransport, clientOptions, loggerFactory: loggerFactory).GetAwaiter().GetResult();
});
builder.Services.AddSingleton<IClientTransport>(sp =>
{
ILoggerFactory loggerFactory = sp.GetRequiredService<ILoggerFactory>();
HttpClient httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("mcp-sse");
return new HttpClientTransport(new HttpClientTransportOptions
{
Name = "MCP-Aspire MCP Server",
Endpoint = new Uri("http://mcp-sse/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
ConnectionTimeout = TimeSpan.FromMinutes(2)
}, httpClient, loggerFactory);
});
var app = builder.Build();
app.Logger.LogInformation(
"ChatApi MCP auth mode {McpAuthMode}; secured HTTP MCP flow {McpSecured}; STDIO transport enabled {McpStdioEnabled}; scopes {McpScopes}.",
mcpAuth.Mode,
mcpAuth.IsEnabled,
mcpAuth.CanUseStdioTransport,
string.Join(' ', mcpAuth.Scopes));
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
IExceptionHandlerFeature? exception = context.Features.Get<IExceptionHandlerFeature>();
if (exception?.Error is BadHttpRequestException badRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await Results.Problem(
title: "Invalid request body.",
detail: badRequest.Message,
statusCode: StatusCodes.Status400BadRequest)
.ExecuteAsync(context);
return;
}
if (exception?.Error is McpTransportNotAllowedException transportNotAllowed)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await Results.Problem(
title: "MCP transport is not allowed.",
detail: transportNotAllowed.Message,
statusCode: StatusCodes.Status400BadRequest)
.ExecuteAsync(context);
return;
}
await Results.Problem(statusCode: StatusCodes.Status500InternalServerError)
.ExecuteAsync(context);
});
});
app.MapDefaultEndpoints();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapDevUI();
}
app.MapOpenAIResponses();
app.MapOpenAIConversations();
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.MapGet("/", () => "Chat API service is running. POST /chat to send chat messages.");
app.MapPost("/chat", async (ChatRequest request, IChatService chat) =>
{
var replies = new List<string>();
await foreach (string reply in chat.ChatAsync(request))
{
replies.Add(reply);
}
return Results.Ok(replies);
});
app.Run();
static AIAgent CreateChatAgent(IServiceProvider sp, string name)
{
ChatSettings options = sp.GetRequiredService<IOptions<ChatSettings>>().Value;
IConfiguration configuration = sp.GetRequiredService<IConfiguration>();
ChatModelOptions chatModel = ChatModelOptions.FromConnectionString(configuration, "chat");
ILoggerFactory loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var httpClient = new HttpClient
{
BaseAddress = new Uri(chatModel.Endpoint),
Timeout = TimeSpan.FromSeconds(options.ModelTimeoutSeconds)
};
var innerClient = new OllamaApiClient(httpClient, chatModel.ModelId, jsonSerializerContext: null);
IChatClient chatClient = new ChatClientBuilder(innerClient)
.UseOpenTelemetry(loggerFactory, configure: telemetry => telemetry.EnableSensitiveData = true)
.Build(sp);
IList<AITool> tools = sp.GetRequiredService<IList<AITool>>();
return new ChatClientAgent(
chatClient,
name: name,
instructions: options.SystemPrompt,
tools: tools,
loggerFactory: loggerFactory);
}Domain
To avoid duplicating request shapes across projects, the Domain project keeps a small, independent set of shared request contracts. The Web app, ChatApi, RAGApi, and MCP servers all build on these shared contracts rather than defining their own.
Central to this is ChatRequest, which carries the conversation messages and the selected MCP transport. This design lets the Web UI switch between SSE and STDIO transports without requiring any changes to the ChatApi endpoint itself.

dotnet add package ModelContextProtocol --version 1.4.1ChatMessage.cs
using ModelContextProtocol.Protocol;
namespace MCP_Aspire.Domain;
public record ChatMessage(Role Role, string Message);ChatRequest.cs
namespace MCP_Aspire.Domain;
public record ChatRequest
{
public required IEnumerable<ChatMessage> Messages { get; init; }
public McpTransport McpTransport { get; init; } = McpTransport.Sse;
}McpTransport
namespace MCP_Aspire.Domain;
public enum McpTransport
{
Sse,
Stdio
}SearchRequest
namespace MCP_Aspire.Domain;
public record SearchRequest
{
public required string Query { get; init; }
}MCP-InspectorProxy
MCP-InspectorProxy is a local developer helper that sits between MCP Inspector or scanner tools and the protected MCP-SSE endpoint. This keeps the real MCP server behavior intact while making local inspection easier.
In Keycloak mode, the proxy obtains the same style of client-credentials token used by ChatApi and forwards MCP traffic with an Authorization: Bearer header. In Development mode, it forwards without a token because the MCP-SSE endpoint is intentionally unauthenticated for the local inner loop.

Program.cs
using System.Net.Http.Headers;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.Configure<McpAuthOptions>(builder.Configuration.GetSection("McpAuth"));
builder.Services.AddSingleton<McpAccessTokenProvider>();
builder.Services.AddHttpClient("mcp-sse-proxy", client =>
{
client.BaseAddress = new Uri("http://mcp-sse");
client.Timeout = Timeout.InfiniteTimeSpan;
})
.RemoveAllResilienceHandlers();
var app = builder.Build();
McpAuthOptions authOptions = app.Services.GetRequiredService<IOptions<McpAuthOptions>>().Value;
authOptions.Validate(app.Environment);
app.MapGet("/", () => Results.Content("""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MCP Inspector Auth Proxy</title>
<style>
body {
color: #1f2937;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
margin: 2rem;
max-width: 46rem;
}
code {
background: #f3f4f6;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
}
</style>
</head>
<body>
<h1>MCP Inspector Auth Proxy</h1>
<p>This service is running. It is a backend MCP endpoint for tools such as MCP Inspector, not the Inspector UI.</p>
<p>Use <code>/mcp</code> as the Streamable HTTP server URL from MCP Inspector.</p>
</body>
</html>
""", "text/html"));
app.MapMethods("/mcp", ["GET", "POST", "DELETE"], ProxyMcpRequestAsync);
app.MapDefaultEndpoints();
app.Run();
static async Task ProxyMcpRequestAsync(
HttpContext context,
IHttpClientFactory httpClientFactory,
McpAccessTokenProvider tokenProvider,
CancellationToken cancellationToken)
{
string targetPath = "/mcp" + context.Request.QueryString;
using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetPath);
CopyRequestHeaders(context.Request, request);
if (HttpMethods.IsPost(context.Request.Method) ||
HttpMethods.IsPut(context.Request.Method) ||
HttpMethods.IsPatch(context.Request.Method))
{
request.Content = new StreamContent(context.Request.Body);
CopyContentHeaders(context.Request, request.Content.Headers);
}
string? accessToken = await tokenProvider.GetAccessTokenAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(accessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
HttpClient httpClient = httpClientFactory.CreateClient("mcp-sse-proxy");
using HttpResponseMessage response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
context.Response.StatusCode = (int)response.StatusCode;
CopyResponseHeaders(response, context.Response);
await response.Content.CopyToAsync(context.Response.Body, cancellationToken);
}
static void CopyRequestHeaders(HttpRequest source, HttpRequestMessage target)
{
foreach (KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues> header in source.Headers)
{
if (ShouldSkipRequestHeader(header.Key))
{
continue;
}
target.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
}
}
static void CopyContentHeaders(HttpRequest source, HttpContentHeaders target)
{
foreach (KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues> header in source.Headers)
{
if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase))
{
target.TryAddWithoutValidation(header.Key, header.Value.ToArray());
}
}
}
static void CopyResponseHeaders(HttpResponseMessage source, HttpResponse target)
{
foreach (KeyValuePair<string, IEnumerable<string>> header in source.Headers)
{
if (!ShouldSkipResponseHeader(header.Key))
{
target.Headers[header.Key] = header.Value.ToArray();
}
}
foreach (KeyValuePair<string, IEnumerable<string>> header in source.Content.Headers)
{
if (!ShouldSkipResponseHeader(header.Key))
{
target.Headers[header.Key] = header.Value.ToArray();
}
}
}
static bool ShouldSkipRequestHeader(string headerName) =>
headerName.Equals("Host", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Connection", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Keep-Alive", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Proxy-Authenticate", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Proxy-Authorization", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("TE", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Trailer", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Upgrade", StringComparison.OrdinalIgnoreCase);
static bool ShouldSkipResponseHeader(string headerName) =>
headerName.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) ||
headerName.Equals("Connection", StringComparison.OrdinalIgnoreCase);
public sealed class McpAuthOptions
{
public string Mode { get; init; } = "Keycloak";
public string? TokenEndpoint { get; init; }
public string? ClientId { get; init; }
public string? ClientSecret { get; init; }
public string[] Scopes { get; init; } = ["mcp:tools", "mcp:prompts", "mcp:resources"];
public bool IsEnabled => string.Equals(Mode, "Keycloak", StringComparison.OrdinalIgnoreCase);
public void Validate(IHostEnvironment environment)
{
if (string.Equals(Mode, "Development", StringComparison.OrdinalIgnoreCase))
{
if (!environment.IsDevelopment())
{
throw new InvalidOperationException("McpAuth:Mode=Development is allowed only in Development.");
}
return;
}
if (!IsEnabled)
{
throw new InvalidOperationException($"Unsupported McpAuth:Mode '{Mode}'. Use 'Keycloak' or 'Development'.");
}
if (string.IsNullOrWhiteSpace(TokenEndpoint) ||
string.IsNullOrWhiteSpace(ClientId) ||
string.IsNullOrWhiteSpace(ClientSecret))
{
throw new InvalidOperationException("McpAuth:Mode=Keycloak requires McpAuth:TokenEndpoint, McpAuth:ClientId, and McpAuth:ClientSecret.");
}
}
}
public sealed class McpAccessTokenProvider(
IHttpClientFactory httpClientFactory,
IOptions<McpAuthOptions> options,
ILogger<McpAccessTokenProvider> logger)
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
private DateTimeOffset _expiresAt;
public async ValueTask<string?> GetAccessTokenAsync(CancellationToken cancellationToken = default)
{
McpAuthOptions auth = options.Value;
if (!auth.IsEnabled)
{
return null;
}
if (HasValidToken())
{
return _accessToken;
}
await _refreshLock.WaitAsync(cancellationToken);
try
{
if (HasValidToken())
{
return _accessToken;
}
using var request = new HttpRequestMessage(HttpMethod.Post, auth.TokenEndpoint);
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = auth.ClientId!,
["client_secret"] = auth.ClientSecret!,
["scope"] = string.Join(' ', auth.Scopes)
});
HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Keycloak token request failed with status {StatusCode}.", response.StatusCode);
response.EnsureSuccessStatusCode();
}
McpTokenResponse token = await response.Content.ReadFromJsonAsync<McpTokenResponse>(cancellationToken)
?? throw new InvalidOperationException("Keycloak token response was empty.");
_accessToken = token.AccessToken;
_expiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(token.ExpiresIn - 30, 30));
return _accessToken;
}
finally
{
_refreshLock.Release();
}
}
private bool HasValidToken() =>
!string.IsNullOrWhiteSpace(_accessToken) && DateTimeOffset.UtcNow < _expiresAt;
private sealed class McpTokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; init; } = "";
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
}
}MCP-SSE
MCP-SSE is the main MCP server for the secured HTTP flow. It exposes the Dapr RAG tools, prompts, and resources over the streamable HTTP MCP endpoint at /mcp.
This project is where the OAuth resource-server behavior is enforced. In Keycloak mode, MCP-SSE validates bearer tokens for issuer, signature, audience, expiry, and required MCP scopes. It also publishes OAuth protected-resource metadata so MCP clients can discover how to authenticate before retrying with a token.

Add the following packages to the MCP-SSE project.
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 10.0.8
dotnet add package Microsoft.AspNetCore.OpenApi --version 10.0.8
dotnet add package Microsoft.OpenApi --version 2.7.5
dotnet add package ModelContextProtocol.AspNetCore --version 1.4.1Prompts/DaprRAGPrompts.cs
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MCP_Aspire.MCP_SSE.Prompts;
[McpServerPromptType]
public static class DaprRAGPrompts
{
[McpServerPrompt(Name = "explain_dapr_concept"), Description("Builds a prompt for explaining a Dapr concept using the indexed RAG corpus.")]
public static string ExplainDaprConcept(
[Description("The Dapr concept to explain, such as workflow, pub/sub, state management, service invocation, or conversation APIs.")]
string concept) =>
$"""
Use the MCP-Aspire Dapr RAG tools to explain "{concept}".
Start with AnswerDaprQuestion, then call FindDaprSnippets if source snippets are needed.
Keep the explanation grounded in the indexed Dapr corpus and mention source file names when available.
""";
[McpServerPrompt(Name = "compare_dapr_building_blocks"), Description("Builds a prompt for comparing two Dapr building blocks with retrieval support.")]
public static string CompareDaprBuildingBlocks(
[Description("The first Dapr building block, such as workflow, pub/sub, state management, service invocation, actors, bindings, or secrets.")]
string firstBuildingBlock,
[Description("The second Dapr building block, such as workflow, pub/sub, state management, service invocation, actors, bindings, or secrets.")]
string secondBuildingBlock) =>
$"""
Compare Dapr {firstBuildingBlock} and Dapr {secondBuildingBlock} using the MCP-Aspire Dapr RAG tools.
Cover the problem each block solves, when to choose each one, and how they can work together.
Ground the comparison in retrieved snippets from FindDaprSnippets when the answer needs evidence.
""";
[McpServerPrompt(Name = "design_dapr_rag_answer"), Description("Builds a prompt for producing a concise, source-grounded Dapr architecture answer.")]
public static string DesignDaprRagAnswer(
[Description("The architecture question to answer from the Dapr corpus.")]
string question) =>
$"""
Answer this Dapr architecture question with the MCP-Aspire Dapr RAG tools: {question}
Prefer AnswerDaprQuestion for the draft answer and FindDaprSnippets for citations or verification.
Include practical tradeoffs and call out when the indexed corpus does not contain enough detail.
""";
}Resources/RAGResources.cs
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MCP_Aspire.MCP_SSE.Resources;
[McpServerResourceType]
public static class RAGResources
{
[McpServerResource(UriTemplate = "mcp-aspire://rag/corpus", Name = "Dapr RAG Corpus", MimeType = "application/json")]
[Description("Lists the local markdown documents indexed by the MCP-Aspire RAG API.")]
public static string CorpusResource() =>
"""
{
"description": "Local markdown corpus indexed in memory by MCP-Aspire.RAGApi on startup.",
"documents": [
{
"file": "dapr-overview.md",
"topics": ["Distributed Application Runtime", "portable APIs", "security", "resiliency", "observability"]
},
{
"file": "dapr-building-blocks.md",
"topics": ["building blocks", "workflow", "service invocation", "pub/sub", "state", "bindings", "actors", "secrets", "configuration"]
},
{
"file": "dapr-service-invocation.md",
"topics": ["service discovery", "sidecar invocation", "mTLS", "retries", "tracing", "access control"]
},
{
"file": "dapr-state-pubsub.md",
"topics": ["state stores", "consistency", "TTL", "queries", "publish subscribe", "CloudEvents", "dead letter topics"]
},
{
"file": "dapr-workflow-conversation.md",
"topics": ["durable workflow", "activities", "child workflows", "multi-application workflows", "conversation API", "LLM integration"]
}
]
}
""";
[McpServerResource(UriTemplate = "mcp-aspire://rag/api", Name = "RAG API Contract", MimeType = "application/json")]
[Description("Describes the RAG API endpoints used by this MCP server.")]
public static string ApiContractResource() =>
"""
{
"serviceName": "ragapi",
"baseAddress": "http://ragapi/",
"endpoints": [
{
"method": "POST",
"path": "/search",
"requestBody": { "query": "string" },
"response": "Plain text snippets with source file names and relevance scores.",
"usedByTool": "FindDaprSnippets"
},
{
"method": "POST",
"path": "/ask",
"requestBody": { "query": "string" },
"response": "Plain text generated answer based on the top retrieved chunks.",
"usedByTool": "AnswerDaprQuestion"
}
]
}
""";
[McpServerResource(UriTemplate = "mcp-aspire://rag/behavior", Name = "RAG Behavior And Limits", MimeType = "text/plain")]
[Description("Summarizes the current behavior and limits of the MCP-Aspire RAG implementation.")]
public static string BehaviorResource() =>
"""
MCP-Aspire.RAGApi builds an in-memory vector index from Data/*.md at startup. Each query is embedded with the configured Ollama embedding model, compared with indexed chunks by cosine similarity, filtered at minimum relevance 0.1, and limited to the top 5 chunks.
/search returns retrieved snippets directly. /ask sends the retrieved snippets as context to the configured Ollama chat model and returns the generated answer.
Current limits: the corpus is static until process restart, the vector store is in memory, retrieval scans all chunks linearly, and generated answers are only as reliable as the retrieved Dapr snippets.
""";
}Tools/RAGCorpusTools.cs
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using ModelContextProtocol.Server;
namespace MCP_Aspire.MCP_SSE.Tools;
[McpServerToolType]
public static class RAGCorpusTools
{
[McpServerTool(Name = "DescribeDaprCorpus", Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true)]
[Description("Describes the local Dapr topics currently indexed by the MCP-Aspire RAG API.")]
public static string DescribeDaprCorpus(
[Range(1, 60)]
[Description("Timeout in seconds for this read-only metadata tool. Allowed range is 1 to 60.")]
int timeoutSeconds = 1,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return """
The MCP-Aspire RAG API indexes local markdown documents about these Dapr topics:
- Dapr overview and runtime goals
- Dapr building blocks
- Dapr service invocation
- Dapr state management and pub/sub
- Dapr workflow and conversation APIs
Use FindDaprSnippets for raw retrieved context and AnswerDaprQuestion for a generated answer grounded in those documents.
""";
}
}Tools/RAGSearch.cs
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using MCP_Aspire.Domain;
using ModelContextProtocol.Server;
namespace MCP_Aspire.MCP_SSE.Tools;
[McpServerToolType]
public sealed class RAGSearchTool(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
private const string RagApiClientName = "ragapi";
private const int MaxQueryLength = 2_000;
private const int MaxAttempts = 2;
private const int DefaultToolTimeoutSeconds = 180;
[McpServerTool(Name = "AnswerDaprQuestion", Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true)]
[Description("Answers a Dapr documentation question using the local MCP-Aspire RAG API corpus.")]
public Task<string> AnswerDaprQuestion(
[MaxLength(MaxQueryLength)]
[RegularExpression(@"^[\p{L}\p{N}\p{P}\p{Zs}\r\n\t]{1,2000}$")]
[Description("A Dapr documentation question. Maximum length is 2,000 characters.")]
string question,
[Range(1, DefaultToolTimeoutSeconds)]
[Description("Timeout in seconds for this RAG API tool call. Allowed range is 1 to 180; default is 180.")]
int timeoutSeconds = DefaultToolTimeoutSeconds,
CancellationToken cancellationToken = default)
=> PostRagQueryAsync("ask", question, timeoutSeconds, "ragapi.ask", cancellationToken);
[McpServerTool(Name = "FindDaprSnippets", Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true)]
[Description("Retrieves relevant Dapr documentation snippets and source file names from the local MCP-Aspire RAG API corpus.")]
public Task<string> FindDaprSnippets(
[MaxLength(MaxQueryLength)]
[RegularExpression(@"^[\p{L}\p{N}\p{P}\p{Zs}\r\n\t]{1,2000}$")]
[Description("Search text for the Dapr corpus. Maximum length is 2,000 characters.")]
string searchText,
[Range(1, DefaultToolTimeoutSeconds)]
[Description("Timeout in seconds for this RAG API tool call. Allowed range is 1 to 180; default is 180.")]
int timeoutSeconds = DefaultToolTimeoutSeconds,
CancellationToken cancellationToken = default)
=> PostRagQueryAsync("search", searchText, timeoutSeconds, "ragapi.search", cancellationToken);
private async Task<string> PostRagQueryAsync(
string endpoint,
string query,
int timeoutSeconds,
string activityName,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(query))
{
return "Please provide a non-empty Dapr question or search query.";
}
if (query.Length > MaxQueryLength)
{
return $"Query is too long. Limit is {MaxQueryLength} characters.";
}
if (timeoutSeconds is < 1 or > DefaultToolTimeoutSeconds)
{
return $"Timeout must be between 1 and {DefaultToolTimeoutSeconds} seconds.";
}
bool captureMessageContent = McpSseTelemetry.CaptureMessageContent(configuration);
using Activity? activity = McpSseTelemetry.ActivitySource.StartActivity(activityName, ActivityKind.Client);
activity?.SetTag("gen_ai.operation.name", "execute_tool");
activity?.SetTag("gen_ai.system", "model-context-protocol");
activity?.SetTag("mcp.transport", "streamable_http");
activity?.SetTag("mcp.tool.name", endpoint == "ask" ? "AnswerDaprQuestion" : "FindDaprSnippets");
activity?.SetTag("ragapi.endpoint", endpoint);
activity?.SetTag("ragapi.query.length", query.Length);
activity?.SetTag("ragapi.timeout.seconds", timeoutSeconds);
if (captureMessageContent)
{
activity?.SetTag("mcp.tool.input.query", McpSseTelemetry.TruncateCapturedContent(query));
activity?.AddEvent(new ActivityEvent(
"mcp.tool.input",
tags: new ActivityTagsCollection
{
["mcp.tool.input.query"] = McpSseTelemetry.TruncateCapturedContent(query)
}));
}
HttpClient client = httpClientFactory.CreateClient(RagApiClientName);
using CancellationTokenSource timeoutCts =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
using JsonContent content = JsonContent.Create(new SearchRequest
{
Query = query
});
try
{
using HttpResponseMessage result = await client.PostAsync(endpoint, content, timeoutCts.Token);
result.EnsureSuccessStatusCode();
string response = await result.Content.ReadAsStringAsync(timeoutCts.Token);
activity?.SetTag("ragapi.response.length", response.Length);
activity?.SetTag("ragapi.attempts", attempt);
activity?.SetTag("http.response.status_code", (int)result.StatusCode);
if (captureMessageContent)
{
activity?.SetTag("mcp.tool.output.content", McpSseTelemetry.TruncateCapturedContent(response));
activity?.AddEvent(new ActivityEvent(
"mcp.tool.output",
tags: new ActivityTagsCollection
{
["mcp.tool.output.content"] = McpSseTelemetry.TruncateCapturedContent(response)
}));
}
return response;
}
catch (HttpRequestException ex) when (attempt < MaxAttempts)
{
activity?.AddEvent(new ActivityEvent("ragapi.retry"));
activity?.AddException(ex);
await Task.Delay(TimeSpan.FromMilliseconds(250), timeoutCts.Token);
}
catch (HttpRequestException ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
throw;
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
activity?.SetStatus(ActivityStatusCode.Error, "RAG API request timed out.");
activity?.AddException(ex);
return $"The RAG API request timed out after {timeoutSeconds} seconds.";
}
}
return "The RAG API request failed after retry.";
}
}appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": true,
"AllowedHosts": "*"
}McpSseTelemetry.cs
using System.Diagnostics;
namespace MCP_Aspire.MCP_SSE;
public static class McpSseTelemetry
{
private const int MaxCapturedContentLength = 4_096;
public const string InstrumentationName = "MCP-Aspire.MCP-SSE";
public static readonly ActivitySource ActivitySource = new(InstrumentationName);
public static bool CaptureMessageContent(IConfiguration configuration) =>
configuration.GetValue("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", false);
public static string TruncateCapturedContent(string value) =>
value.Length <= MaxCapturedContentLength
? value
: string.Concat(value.AsSpan(0, MaxCapturedContentLength), "... [truncated]");
}Program.cs
using ModelContextProtocol.Protocol;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using System.Diagnostics;
using System.Security.Claims;
using System.Reflection;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.Configure<McpAuthenticationOptions>(builder.Configuration.GetSection("Authentication"));
builder.Services.AddHttpClient("ragapi", client =>
{
client.BaseAddress = new Uri("http://ragapi/");
client.Timeout = TimeSpan.FromMinutes(10);
})
.RemoveAllResilienceHandlers();
builder.Services.AddOpenApi();
McpAuthenticationOptions authentication = builder.Configuration
.GetSection("Authentication")
.Get<McpAuthenticationOptions>() ?? new();
authentication.Validate(builder.Environment);
if (authentication.IsEnabled)
{
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = authentication.Authority;
options.Audience = authentication.Audience;
options.RequireHttpsMetadata = authentication.RequireHttpsMetadata;
options.Events = new JwtBearerEvents
{
OnChallenge = context =>
{
context.HandleResponse();
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.WWWAuthenticate = BuildAuthenticateHeader(authentication);
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("McpAccess", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAllScopes(context.User, authentication.EffectiveScopes));
});
}
Assembly assembly = Assembly.GetExecutingAssembly();
// Add Model Context Protocol server implementation
builder.Services
.AddMcpServer(o =>
{
FileVersionInfo fvi = FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion ?? "v0.0.1";
o.ServerInfo = new Implementation
{
Name = "MCP-Aspire Dapr RAG Server",
Version = version
};
o.ServerInstructions = "Use this server to answer questions about Dapr concepts from the indexed MCP-Aspire RAG corpus. Prefer the RAG tools for grounded answers and cite source file names when snippets include them.";
})
.WithHttpTransport(options =>
{
options.Stateless = false;
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.MaxIdleSessionCount = 1_000;
})
.WithResourcesFromAssembly(assembly)
.WithPromptsFromAssembly(assembly)
.WithToolsFromAssembly(assembly);
var app = builder.Build();
app.Logger.LogInformation(
"MCP-SSE auth mode {McpAuthMode}; /mcp requires bearer token {McpRequiresBearer}; audience {McpAudience}; required scopes {McpScopes}.",
authentication.Mode,
authentication.IsEnabled,
authentication.Audience,
string.Join(' ', authentication.EffectiveScopes));
app.MapDefaultEndpoints();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
if (authentication.IsEnabled)
{
app.UseAuthentication();
app.UseAuthorization();
}
app.MapGet("/.well-known/oauth-protected-resource", () => Results.Json(CreateProtectedResourceMetadata(authentication)));
app.MapGet("/.well-known/oauth-protected-resource/mcp", () => Results.Json(CreateProtectedResourceMetadata(authentication)));
var mcpEndpoint = app.MapMcp("/mcp");
if (authentication.IsEnabled)
{
mcpEndpoint.RequireAuthorization("McpAccess");
}
app.Run();
static object CreateProtectedResourceMetadata(McpAuthenticationOptions authentication)
{
string resource = authentication.Audience ?? "http://localhost:5161/mcp";
string authorizationServer = authentication.Authority ?? "http://localhost:8080/realms/mcp-aspire";
return new
{
resource,
authorization_servers = new[] { authorizationServer },
scopes_supported = authentication.EffectiveScopes,
resource_documentation = "https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-11-25/basic/authorization.md"
};
}
static string BuildAuthenticateHeader(McpAuthenticationOptions authentication)
{
string metadataUrl = authentication.ResourceMetadataUrl ?? "http://localhost:5161/.well-known/oauth-protected-resource/mcp";
string scopes = string.Join(' ', authentication.EffectiveScopes);
return $"Bearer resource_metadata=\"{metadataUrl}\", scope=\"{scopes}\"";
}
static bool HasAllScopes(ClaimsPrincipal user, IReadOnlyCollection<string> requiredScopes)
{
HashSet<string> grantedScopes = user.FindAll("scope")
.Concat(user.FindAll("scp"))
.SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToHashSet(StringComparer.Ordinal);
return requiredScopes.All(grantedScopes.Contains);
}
internal sealed class McpAuthenticationOptions
{
public string Mode { get; init; } = "Keycloak";
public string? Authority { get; init; }
public string? Audience { get; init; }
public string? ResourceMetadataUrl { get; init; }
public string[] RequiredScopes { get; init; } = ["mcp:tools", "mcp:prompts", "mcp:resources"];
public string[] EffectiveScopes => RequiredScopes
.Where(scope => !string.IsNullOrWhiteSpace(scope))
.Distinct(StringComparer.Ordinal)
.ToArray();
public bool RequireHttpsMetadata { get; init; } = true;
public bool IsEnabled => string.Equals(Mode, "Keycloak", StringComparison.OrdinalIgnoreCase);
public void Validate(IHostEnvironment environment)
{
if (string.Equals(Mode, "Development", StringComparison.OrdinalIgnoreCase))
{
if (!environment.IsDevelopment())
{
throw new InvalidOperationException("Authentication:Mode=Development is allowed only in Development.");
}
return;
}
if (!IsEnabled)
{
throw new InvalidOperationException($"Unsupported Authentication:Mode '{Mode}'. Use 'Keycloak' or 'Development'.");
}
if (string.IsNullOrWhiteSpace(Authority) || string.IsNullOrWhiteSpace(Audience))
{
throw new InvalidOperationException("Authentication:Mode=Keycloak requires Authentication:Authority and Authentication:Audience.");
}
}
}MCP-STDIO
MCP-STDIO is the standalone stdio MCP server variant. It exposes the same Dapr RAG tools, prompts, and resources as MCP-SSE, but communicates over process stdin/stdout instead of HTTP.

Add the following packages to the MCP-STDIO project.
dotnet add package Microsoft.Extensions.Hosting --version 10.0.8
dotnet add package ModelContextProtocol.AspNetCore --version 1.4.1NOTE: Copy DaprRAGPrompts, RAGResources, RAGCorpusTools and RAGSearch files from MCP-SSE project. This could be managed through a shared project, but I wanted to keep things simple, so I duplicated the files instead.
Prompts/DaprRAGPrompts.cs
Resources/RAGResources.cs
Tools/RAGCorpusTools.cs
Tools/RAGSearch.cs
McpStdioTelemetry.cs
using System.Diagnostics;
using Microsoft.Extensions.Configuration;
namespace MCP_Aspire.MCP_STDIO;
public static class McpStdioTelemetry
{
private const int MaxCapturedContentLength = 4_096;
private const string TraceParentEnvironmentVariable = "MCP_ASPIRE_TRACEPARENT";
private const string TraceStateEnvironmentVariable = "MCP_ASPIRE_TRACESTATE";
public const string InstrumentationName = "MCP-Aspire.MCP-STDIO";
public static readonly ActivitySource ActivitySource = new(InstrumentationName);
public static bool CaptureMessageContent(IConfiguration configuration) =>
configuration.GetValue("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", false);
public static Activity? StartToolActivity(string activityName)
{
string? traceParent = Environment.GetEnvironmentVariable(TraceParentEnvironmentVariable);
string? traceState = Environment.GetEnvironmentVariable(TraceStateEnvironmentVariable);
if (ActivityContext.TryParse(traceParent, traceState, isRemote: true, out ActivityContext parentContext))
{
return ActivitySource.StartActivity(activityName, ActivityKind.Client, parentContext);
}
return ActivitySource.StartActivity(activityName, ActivityKind.Client);
}
public static string TruncateCapturedContent(string value) =>
value.Length <= MaxCapturedContentLength
? value
: string.Concat(value.AsSpan(0, MaxCapturedContentLength), "... [truncated]");
}Program.cs
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddHttpClient("ragapi", client =>
{
client.BaseAddress = new Uri("http://ragapi/");
client.Timeout = TimeSpan.FromMinutes(10);
})
.RemoveAllResilienceHandlers();
builder.Logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Warning;
});
builder.Logging.SetMinimumLevel(LogLevel.Warning);
Assembly assembly = Assembly.GetExecutingAssembly();
builder.Services
.AddMcpServer(o =>
{
FileVersionInfo fvi = FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion ?? "v0.0.1";
o.ServerInfo = new Implementation
{
Name = "MCP-Aspire Dapr RAG Server (Stdio)",
Version = version
};
o.ServerInstructions = "Use this server to answer questions about Dapr concepts from the indexed MCP-Aspire RAG corpus. Prefer the RAG tools for grounded answers and cite source file names when snippets include them.";
})
.WithStdioServerTransport()
.WithResourcesFromAssembly(assembly)
.WithPromptsFromAssembly(assembly)
.WithToolsFromAssembly(assembly);
await builder.Build().RunAsync();RAG API
RAGApi owns the retrieval pipeline. It loads local Dapr markdown documents at startup, splits them into chunks, creates embeddings through the Ollama embedding model, and stores the vectors in memory for semantic search.

Add the following packages to the RAG API project.
dotnet add package Scalar.AspNetCore --version 2.16.16
dotnet add package System.Numerics.Tensors --version 10.0.10
dotnet add package Microsoft.Extensions.AI --version 10.8.0
dotnet add package Microsoft.AspNetCore.OpenApi --version 10.0.8
dotnet add package Microsoft.OpenApi --version 2.7.5
dotnet add package OllamaSharp --version 5.4.30Data/dapr-building-blocks.md
# Dapr building blocks
Source: https://docs.dapr.io/concepts/building-blocks-concept/
A Dapr building block is an HTTP or gRPC API that application code calls to use a distributed application capability. Building blocks usually rely on one or more Dapr components to connect the API to real infrastructure.
The building block model lets developers use common patterns without embedding infrastructure-specific client code everywhere. Each building block addresses a recurring distributed-systems concern and codifies a production-oriented pattern.
Dapr workflow is exposed under `/v1.0/workflow`. It provides durable execution for long-running processes that span services, and it can be combined with other APIs such as service invocation, state, secrets, and pub/sub.
Dapr service invocation is exposed under `/v1.0/invoke`. It gives applications a standard way to call each other over HTTP or gRPC with service discovery, tracing, error handling, and secure communication.
Dapr publish and subscribe is exposed under `/v1.0/publish` and `/v1.0/subscribe`. It lets publishers send messages to topics and lets subscribers receive messages without directly knowing about each other.
Dapr state management is exposed under `/v1.0/state`. It provides key/value storage and query APIs backed by pluggable state stores.
Dapr bindings are exposed under `/v1.0/bindings`. Bindings connect applications to external services and systems, either by invoking an external resource or by triggering application code from events.
Dapr actors are exposed under `/v1.0/actors`. Actors model independent units of compute and state with single-threaded execution, using a virtual actor pattern.
Dapr secrets are exposed under `/v1.0/secrets`. Applications can retrieve secrets from configured secret stores without hard-coding provider-specific access logic.
Dapr configuration is exposed under `/v1.0/configuration`. Applications can retrieve and subscribe to configuration changes from supported configuration stores.
Dapr distributed lock, cryptography, jobs, and conversation APIs add coordination, secure cryptographic operations, job scheduling, and large language model interaction capabilities.Data/dapr-overview.md
# Dapr overview
Source: https://dapr.io/
Dapr is the Distributed Application Runtime. It gives developers a set of APIs for building secure and reliable distributed applications, including microservices and agentic AI systems.
Dapr focuses on application-level APIs for communication, state, workflow, and interaction with large language models. Application code calls Dapr through standard HTTP or gRPC interfaces while Dapr handles infrastructure details through sidecars and pluggable components.
The main design goal is portability. A service can depend on Dapr APIs instead of taking a direct dependency on a particular broker, database, secrets store, configuration provider, or cloud service. Operations teams can swap infrastructure by changing component configuration rather than rewriting business logic.
Dapr is intended to improve developer productivity by packaging distributed-system best practices into reusable building blocks. Its homepage highlights workflow, agentic AI, pub/sub, state management, secret stores, external configuration, bindings, actors, jobs, distributed locks, and cryptography.
Dapr applications can run on Kubernetes or in self-hosted environments such as virtual machines, physical machines, local development machines, and edge deployments.
Dapr can also be introduced incrementally. Existing applications can call non-Dapr endpoints while using Dapr for resilience, observability, security scoping, and other cross-cutting concerns.
Security, reliability, and observability are central Dapr concerns. Dapr supports encrypted in-transit communication with mTLS, policy-based access controls, resiliency policies, retries, backoff, circuit breakers, timeouts, metrics, and distributed tracing.Data/dapr-service-invocation.md
# Dapr service invocation
Source: https://docs.dapr.io/developing-applications/building-blocks/service-invocation/service-invocation-overview/
Dapr service invocation helps applications communicate reliably and securely using HTTP or gRPC. It is designed for microservice systems where services need discovery, standardized calls, encryption, access control, retries, tracing, and metrics.
With service invocation, an application sends a request to its local Dapr sidecar. The local sidecar discovers the destination service, forwards the call to the destination sidecar, and the destination sidecar invokes the target endpoint on the destination application.
Dapr service invocation acts like a reverse proxy with built-in service discovery and distributed tracing. It can call Dapr-enabled applications and can also call non-Dapr HTTP endpoints when an architecture is only partially using Dapr.
For HTTP service invocation, an application can add the `dapr-app-id` header to route a request through Dapr. Applications can also call the service invocation API directly through the local Dapr HTTP port.
For gRPC service invocation, applications can keep their existing proto services and use Dapr to invoke them without forcing every service to use a Dapr SDK.
Dapr supports secure service-to-service communication through mutual TLS on hosted platforms. The Sentry service issues identities and supports automatic certificate rollover.
Service invocation includes resiliency support. Dapr can apply retry policies, backoff, timeouts, and circuit breakers to calls. Streaming HTTP requests are forwarded without buffering and retry policies are bypassed because a consumed stream cannot be replayed.
Observability is included by default. Dapr gathers tracing and metrics for calls between applications, which helps teams understand production call graphs and diagnose failures.
Access control policies can restrict which applications may call a service and which operations they are allowed to perform. Namespace scoping and pluggable name resolution support different hosting models such as Kubernetes, local development, self-hosted machines, and Consul-backed environments.Data/dapr-state-pubsub.md
# Dapr state management and pub/sub
Sources:
- https://docs.dapr.io/developing-applications/building-blocks/state-management/state-management-overview/
- https://docs.dapr.io/developing-applications/building-blocks/pubsub/pubsub-overview/
Dapr state management lets an application save, read, and query key/value data through a state store component. Common examples include shopping carts, game sessions, workflow state, and other stateful application data.
State stores are pluggable Dapr components. An application can use the same Dapr state API while the configured backend changes to a different database or storage service.
The state API supports concurrency and consistency options. Dapr defaults to eventual consistency and last-write-wins, but callers can request stronger behavior when the underlying store supports it.
Dapr state operations can include metadata such as content type. State stores may use content type to decide how data should be interpreted, stored, or manipulated.
Dapr supports querying state through the state management query API. The API can filter, sort, and paginate key/value data without tying application code to a specific database query language.
Dapr can also support state time-to-live. When TTL is set on a state value, the value expires and cannot be retrieved after the configured duration.
Dapr publish and subscribe supports event-driven communication between services. A publisher sends a message to a topic and a subscriber receives messages from that topic without the two services knowing about each other directly.
The pub/sub building block uses a pluggable broker component. This lets an application keep the same publish and subscribe API while the runtime configuration chooses a broker such as Redis, Kafka, RabbitMQ, or another supported system.
Dapr pub/sub offers at-least-once message delivery. If delivery fails or an application crashes, Dapr attempts redelivery until the message is successfully delivered to each subscriber.
Pub/sub features include CloudEvents support, message routing, dead letter topics, namespace-aware consumer groups, per-message TTL, and bulk publish or subscribe operations for higher throughput.Data/dapr-workflow-conversation.md
# Dapr workflow and conversation APIs
Sources:
- https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/
- https://docs.dapr.io/developing-applications/building-blocks/conversation/conversation-overview/
Dapr workflow lets developers write business processes and integrations as durable code. Workflows are stateful, fault tolerant, and useful for long-running orchestration across microservices.
Dapr workflow works with other building blocks such as service invocation, pub/sub, state management, and bindings. A workflow can call services, publish events, save state, and interact with external systems while Dapr manages durable execution.
Workflow activities are the basic unit of work inside a workflow. Activities can call Dapr services, interact with state stores, use pub/sub brokers, or call third-party services.
Dapr supports child workflows. A parent workflow can schedule another workflow instance with its own identity, status, and history. Child workflows can have automatic retry policies.
Dapr also supports multi-application workflows. Complex business processes can span multiple applications while preserving Dapr security, reliability, and durability guarantees.
Workflow management is available through HTTP, gRPC, and CLI operations. Individual workflow instances can be started, queried, paused, resumed, sent external events, terminated, and purged.
Dapr workflow supports authoring SDKs for Python, JavaScript, .NET, Java, and Go. The workflow logic stays in application code and is orchestrated by the Dapr workflow engine through the sidecar.
Dapr conversation is an alpha building block for interacting with large language models through one consistent API. It is useful when teams want to avoid binding application code directly to each LLM provider SDK.
The conversation API can work with capabilities such as prompt caching, response caching, response formatting with JSON Schema, usage metrics, personally identifiable information obfuscation, and tool calling.
Conversation requests can also use Dapr cross-cutting features such as resiliency policies, circuit breakers, timeouts, retries, observability with OpenTelemetry or Zipkin, and middleware-based authentication.Services/DocumentChunk.cs
namespace MCP_Aspire.RAGApi.Services;
public sealed record DocumentChunk
{
public string Id { get; init; } = Guid.NewGuid().ToString();
public string Text { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public ReadOnlyMemory<float> Embedding { get; init; }
}Services/DocumentIndexing.cs
using Microsoft.Extensions.AI;
using System.Diagnostics;
using MCP_Aspire.RAGApi.DocumentsApi;
namespace MCP_Aspire.RAGApi.Services;
public class DocumentIndexing(
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
List<DocumentChunk> chunks,
RAGApiTelemetry telemetry,
IConfiguration configuration,
ILogger<DocumentIndexing> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
using Activity? indexingActivity = RAGApiTelemetry.ActivitySource.StartActivity("ragapi.index.documents");
SearchOptions embeddingModel = SearchOptions.FromConnectionString(configuration, "embedding");
indexingActivity?.SetTag("ragapi.embedding.model.id", embeddingModel.ModelId);
DirectoryInfo dataDir = new(Path.Combine(Environment.CurrentDirectory, "Data"));
logger.LogInformation("Looking for data files in {path}", dataDir.FullName);
if (!dataDir.Exists)
throw new DirectoryNotFoundException($"{dataDir.FullName} does not exist");
foreach (var file in dataDir.GetFiles())
{
using Activity? documentActivity = RAGApiTelemetry.ActivitySource.StartActivity("ragapi.index.document");
documentActivity?.SetTag("ragapi.document.source", file.Name);
logger.LogInformation("Indexing {document}", file.FullName);
var paragraphs = ChunkFile(file.FullName);
foreach (var paragraph in paragraphs)
{
using Activity? embeddingActivity = RAGApiTelemetry.ActivitySource.StartActivity("ragapi.model.embedding.index");
embeddingActivity?.SetTag("ragapi.document.source", paragraph.SourceName);
embeddingActivity?.SetTag("gen_ai.operation.name", "embeddings");
embeddingActivity?.SetTag("gen_ai.system", "ollama");
embeddingActivity?.SetTag("gen_ai.request.model", embeddingModel.ModelId);
embeddingActivity?.SetTag("ragapi.embedding.model.id", embeddingModel.ModelId);
embeddingActivity?.SetTag("ragapi.model.input.length", paragraph.Text.Length);
logger.LogInformation(
"Embedding model input for indexing from {SourceName}: {EmbeddingInput}",
paragraph.SourceName,
paragraph.Text);
long startedAt = Stopwatch.GetTimestamp();
Embedding<float> embedding;
try
{
embedding = await embeddingGenerator.GenerateAsync(paragraph.Text, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
TimeSpan failedDuration = Stopwatch.GetElapsedTime(startedAt);
embeddingActivity?.SetStatus(ActivityStatusCode.Error, ex.Message);
telemetry.RecordModelCall("embedding.index", "error", paragraph.Text.Length, 0, failedDuration);
logger.LogError(
ex,
"Embedding model failed while indexing {SourceName} after {DurationMs} ms. Input: {EmbeddingInput}",
paragraph.SourceName,
failedDuration.TotalMilliseconds,
paragraph.Text);
throw;
}
TimeSpan duration = Stopwatch.GetElapsedTime(startedAt);
embeddingActivity?.SetTag("ragapi.model.response.vector_dimensions", embedding.Vector.Length);
telemetry.RecordModelCall(
"embedding.index",
"ok",
paragraph.Text.Length,
embedding.Vector.Length,
duration);
logger.LogInformation(
"Embedding model response for indexing from {SourceName}: vector_dimensions={VectorDimensions}, duration_ms={DurationMs}",
paragraph.SourceName,
embedding.Vector.Length,
duration.TotalMilliseconds);
chunks.Add(new DocumentChunk
{
Text = paragraph.Text,
SourceName = paragraph.SourceName,
Embedding = embedding.Vector
});
}
logger.LogInformation("Indexed {document}", file.Name);
}
indexingActivity?.SetTag("ragapi.index.chunk_count", chunks.Count);
logger.LogInformation("Finished importing {count} chunks from data files", chunks.Count);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static IEnumerable<(string Text, string SourceName)> ChunkFile(string filePath)
{
string text = File.ReadAllText(filePath);
string sourceName = Path.GetFileName(filePath);
string[] paragraphs = text.Split(["\r\n\r\n", "\n\n"], StringSplitOptions.RemoveEmptyEntries);
foreach (string para in paragraphs)
{
string trimmed = para.Trim();
if (trimmed.Length > 20)
{
yield return (trimmed, sourceName);
}
}
}
}Services/DocumentMemory.cs
using System.Numerics.Tensors;
using System.Diagnostics;
using MCP_Aspire.RAGApi.DocumentsApi;
using Microsoft.Extensions.AI;
namespace MCP_Aspire.RAGApi.Services;
public sealed class DocumentMemory(
List<DocumentChunk> chunks,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
RAGApiTelemetry telemetry,
IConfiguration configuration,
ILogger<DocumentMemory> logger)
{
public async Task<IReadOnlyList<(string Text, string SourceName, float Score)>> SearchAsync(
string query,
int limit = 5,
float minRelevance = 0.1f,
CancellationToken cancellationToken = default)
{
using Activity? embeddingActivity = RAGApiTelemetry.ActivitySource.StartActivity("ragapi.model.embedding.query");
SearchOptions embeddingModel = SearchOptions.FromConnectionString(configuration, "embedding");
embeddingActivity?.SetTag("gen_ai.operation.name", "embeddings");
embeddingActivity?.SetTag("gen_ai.system", "ollama");
embeddingActivity?.SetTag("gen_ai.request.model", embeddingModel.ModelId);
embeddingActivity?.SetTag("ragapi.embedding.model.id", embeddingModel.ModelId);
embeddingActivity?.SetTag("ragapi.model.input.length", query.Length);
logger.LogInformation("Embedding model input for search query: {EmbeddingInput}", query);
long embeddingStartedAt = Stopwatch.GetTimestamp();
Embedding<float> queryEmbedding;
try
{
queryEmbedding = await embeddingGenerator.GenerateAsync(query, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
TimeSpan failedDuration = Stopwatch.GetElapsedTime(embeddingStartedAt);
embeddingActivity?.SetStatus(ActivityStatusCode.Error, ex.Message);
telemetry.RecordModelCall("embedding.query", "error", query.Length, 0, failedDuration);
logger.LogError(
ex,
"Embedding model failed for search query after {DurationMs} ms. Input: {EmbeddingInput}",
failedDuration.TotalMilliseconds,
query);
throw;
}
TimeSpan embeddingDuration = Stopwatch.GetElapsedTime(embeddingStartedAt);
embeddingActivity?.SetTag("ragapi.model.response.vector_dimensions", queryEmbedding.Vector.Length);
telemetry.RecordModelCall(
"embedding.query",
"ok",
query.Length,
queryEmbedding.Vector.Length,
embeddingDuration);
logger.LogInformation(
"Embedding model response for search query: vector_dimensions={VectorDimensions}, duration_ms={DurationMs}",
queryEmbedding.Vector.Length,
embeddingDuration.TotalMilliseconds);
using Activity? searchActivity = RAGApiTelemetry.ActivitySource.StartActivity("ragapi.vector.search");
searchActivity?.SetTag("ragapi.search.limit", limit);
searchActivity?.SetTag("ragapi.search.min_relevance", minRelevance);
long searchStartedAt = Stopwatch.GetTimestamp();
var scored = new List<(DocumentChunk Chunk, float Score)>();
foreach (var chunk in chunks)
{
cancellationToken.ThrowIfCancellationRequested();
float score = TensorPrimitives.CosineSimilarity(
queryEmbedding.Vector.Span, chunk.Embedding.Span);
if (score >= minRelevance)
scored.Add((chunk, score));
}
var results = scored
.OrderByDescending(pair => pair.Score)
.Take(limit)
.Select(pair => (pair.Chunk.Text, pair.Chunk.SourceName, pair.Score))
.ToList();
TimeSpan searchDuration = Stopwatch.GetElapsedTime(searchStartedAt);
searchActivity?.SetTag("ragapi.search.candidate_count", scored.Count);
searchActivity?.SetTag("ragapi.search.result_count", results.Count);
telemetry.RecordSearch("vector.search", "ok", results.Count, searchDuration);
logger.LogInformation(
"Vector search returned {ResultCount} results from {CandidateCount} candidates in {DurationMs} ms",
results.Count,
scored.Count,
searchDuration.TotalMilliseconds);
foreach (var (text, sourceName, score) in results)
{
logger.LogInformation(
"Vector search result from {SourceName} with score {Score}: {ResultText}",
sourceName,
score,
text);
}
return results;
}
}Services/ISearch.cs
namespace MCP_Aspire.RAGApi.Services;
public interface ISearch
{
Task<string> Search(string query, CancellationToken cancellationToken);
Task<string> Ask(string query, CancellationToken cancellationToken);
}Services/RAGApiTelemetry.cs
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace MCP_Aspire.RAGApi.Services;
public sealed class RAGApiTelemetry(IMeterFactory meterFactory)
{
private const int MaxCapturedContentLength = 4_096;
public const string InstrumentationName = "MCP-Aspire.RAGApi";
public static readonly ActivitySource ActivitySource = new(InstrumentationName);
public static string TruncateCapturedContent(string value) =>
value.Length <= MaxCapturedContentLength
? value
: string.Concat(value.AsSpan(0, MaxCapturedContentLength), "... [truncated]");
private readonly Counter<long> _modelCalls =
meterFactory.Create(InstrumentationName).CreateCounter<long>(
"ragapi.model.calls",
description: "Number of model calls made by RAGApi.");
private readonly Histogram<double> _modelCallDuration =
meterFactory.Create(InstrumentationName).CreateHistogram<double>(
"ragapi.model.call.duration",
unit: "ms",
description: "Duration of model calls made by RAGApi.");
private readonly Histogram<long> _modelInputLength =
meterFactory.Create(InstrumentationName).CreateHistogram<long>(
"ragapi.model.input.length",
unit: "characters",
description: "Character length of model inputs.");
private readonly Histogram<long> _modelResponseLength =
meterFactory.Create(InstrumentationName).CreateHistogram<long>(
"ragapi.model.response.length",
unit: "characters",
description: "Character length of model responses.");
private readonly Histogram<double> _searchDuration =
meterFactory.Create(InstrumentationName).CreateHistogram<double>(
"ragapi.search.duration",
unit: "ms",
description: "Duration of vector search operations.");
private readonly Histogram<long> _searchResultCount =
meterFactory.Create(InstrumentationName).CreateHistogram<long>(
"ragapi.search.results",
description: "Number of vector search results returned.");
public void RecordModelCall(string operation, string status, int inputLength, int responseLength, TimeSpan duration)
{
var tags = new TagList
{
{ "ragapi.model.operation", operation },
{ "ragapi.status", status }
};
_modelCalls.Add(1, tags);
_modelCallDuration.Record(duration.TotalMilliseconds, tags);
_modelInputLength.Record(inputLength, tags);
_modelResponseLength.Record(responseLength, tags);
}
public void RecordSearch(string operation, string status, int resultCount, TimeSpan duration)
{
var tags = new TagList
{
{ "ragapi.operation", operation },
{ "ragapi.status", status }
};
_searchDuration.Record(duration.TotalMilliseconds, tags);
_searchResultCount.Record(resultCount, tags);
}
}Services/Search.cs
using System.Text;
using System.Diagnostics;
using MCP_Aspire.RAGApi.DocumentsApi;
using Microsoft.Extensions.AI;
namespace MCP_Aspire.RAGApi.Services;
public class SearchService(
DocumentMemory memory,
IChatClient chatClient,
RAGApiTelemetry telemetry,
IConfiguration configuration,
ILogger<SearchService> logger) : ISearch
{
public async Task<string> Search(string query, CancellationToken cancellationToken)
{
using Activity? activity = RAGApiTelemetry.ActivitySource.StartActivity("ragapi.search");
activity?.SetTag("ragapi.query.length", query.Length);
bool captureMessageContent = configuration.GetValue("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", false);
if (captureMessageContent)
{
activity?.SetTag("ragapi.query.content", RAGApiTelemetry.TruncateCapturedContent(query));
}
logger.LogInformation("RAG search request input: {Query}", query);
long startedAt = Stopwatch.GetTimestamp();
var results = await memory.SearchAsync(query, limit: 5, cancellationToken: cancellationToken);
TimeSpan duration = Stopwatch.GetElapsedTime(startedAt);
activity?.SetTag("ragapi.search.result_count", results.Count);
telemetry.RecordSearch("search.endpoint", "ok", results.Count, duration);
if (results.Count == 0) return "There were no relevant search results";
StringBuilder sb = new();
foreach (var (text, sourceName, score) in results)
{
sb.AppendLine($"Snippet found in {sourceName} (relevance: {score:F2}):");
sb.AppendLine(text);
}
string response = sb.ToString();
if (captureMessageContent)
{
activity?.SetTag("ragapi.response.content", RAGApiTelemetry.TruncateCapturedContent(response));
}
logger.LogInformation("RAG search response: {Response}", response);
return response;
}
public async Task<string> Ask(string query, CancellationToken cancellationToken)
{
using Activity? activity = RAGApiTelemetry.ActivitySource.StartActivity("ragapi.ask");
activity?.SetTag("ragapi.query.length", query.Length);
SearchOptions chatModel = SearchOptions.FromConnectionString(configuration, "chat");
activity?.SetTag("ragapi.chat.model.id", chatModel.ModelId);
bool captureMessageContent = configuration.GetValue("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", false);
if (captureMessageContent)
{
activity?.SetTag("ragapi.query.content", RAGApiTelemetry.TruncateCapturedContent(query));
}
logger.LogInformation("RAG ask request input: {Query}", query);
long startedAt = Stopwatch.GetTimestamp();
var results = await memory.SearchAsync(query, limit: 5, cancellationToken: cancellationToken);
TimeSpan searchDuration = Stopwatch.GetElapsedTime(startedAt);
activity?.SetTag("ragapi.search.result_count", results.Count);
activity?.SetTag("ragapi.search.duration_ms", searchDuration.TotalMilliseconds);
if (results.Count == 0) return "I could not find any relevant information to answer that question.";
StringBuilder context = new();
foreach (var (text, sourceName, _) in results)
{
context.AppendLine($"From {sourceName}:");
context.AppendLine(text);
context.AppendLine();
}
string augmentedPrompt = $"""
Use the following context to answer the question. If the context does not contain relevant information, say so.
Context:
{context}
Question: {query}
""";
using Activity? chatActivity = RAGApiTelemetry.ActivitySource.StartActivity("ragapi.model.chat");
chatActivity?.SetTag("gen_ai.operation.name", "chat");
chatActivity?.SetTag("gen_ai.system", "ollama");
chatActivity?.SetTag("gen_ai.request.model", chatModel.ModelId);
chatActivity?.SetTag("ragapi.chat.model.id", chatModel.ModelId);
chatActivity?.SetTag("ragapi.model.input.length", augmentedPrompt.Length);
chatActivity?.SetTag("ragapi.search.result_count", results.Count);
if (captureMessageContent)
{
chatActivity?.SetTag("gen_ai.prompt.0.content", RAGApiTelemetry.TruncateCapturedContent(augmentedPrompt));
chatActivity?.AddEvent(new ActivityEvent(
"gen_ai.prompt",
tags: new ActivityTagsCollection
{
["gen_ai.prompt.content"] = RAGApiTelemetry.TruncateCapturedContent(augmentedPrompt)
}));
}
logger.LogInformation("Chat model input prompt: {Prompt}", augmentedPrompt);
long chatStartedAt = Stopwatch.GetTimestamp();
ChatResponse response;
try
{
response = await chatClient.GetResponseAsync(augmentedPrompt, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
TimeSpan failedDuration = Stopwatch.GetElapsedTime(chatStartedAt);
chatActivity?.SetStatus(ActivityStatusCode.Error, ex.Message);
telemetry.RecordModelCall("chat", "error", augmentedPrompt.Length, 0, failedDuration);
logger.LogError(
ex,
"Chat model failed after {DurationMs} ms. Input prompt: {Prompt}",
failedDuration.TotalMilliseconds,
augmentedPrompt);
throw;
}
TimeSpan chatDuration = Stopwatch.GetElapsedTime(chatStartedAt);
string responseText = response.Text ?? "No response generated.";
chatActivity?.SetTag("ragapi.model.response.length", responseText.Length);
if (captureMessageContent)
{
activity?.SetTag("ragapi.response.content", RAGApiTelemetry.TruncateCapturedContent(responseText));
chatActivity?.SetTag("gen_ai.completion.0.content", RAGApiTelemetry.TruncateCapturedContent(responseText));
chatActivity?.AddEvent(new ActivityEvent(
"gen_ai.completion",
tags: new ActivityTagsCollection
{
["gen_ai.completion.0.content"] = RAGApiTelemetry.TruncateCapturedContent(responseText)
}));
}
telemetry.RecordModelCall(
"chat",
"ok",
augmentedPrompt.Length,
responseText.Length,
chatDuration);
logger.LogInformation(
"Chat model response in {DurationMs} ms: {Response}",
chatDuration.TotalMilliseconds,
responseText);
return responseText;
}
}Services/Program.cs
using Microsoft.Extensions.AI;
using MCP_Aspire.Domain;
using MCP_Aspire.RAGApi.DocumentsApi;
using MCP_Aspire.RAGApi.Services;
using OllamaSharp;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
SearchOptions options = SearchOptions.FromConnectionString(configuration, "embedding");
return CreateOllamaClient(configuration, options);
});
builder.Services.AddSingleton<IChatClient>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
SearchOptions options = SearchOptions.FromConnectionString(configuration, "chat");
var ollamaClient = CreateOllamaClient(configuration, options);
return new ChatClientBuilder(ollamaClient)
.UseOpenTelemetry(
sp.GetRequiredService<ILoggerFactory>(),
RAGApiTelemetry.InstrumentationName,
telemetry => telemetry.EnableSensitiveData = true)
.Build(sp);
});
builder.Services.AddSingleton<List<DocumentChunk>>();
builder.Services.AddSingleton<RAGApiTelemetry>();
builder.Services.AddSingleton<DocumentMemory>();
builder.Services.AddHostedService<DocumentIndexing>();
builder.Services.AddScoped<ISearch, SearchService>();
var app = builder.Build();
// Map endpoints
app.MapPost("/search", async (SearchRequest req, ISearch search, CancellationToken cancellationToken) =>
{
string response = await search.Search(req.Query, cancellationToken);
return Results.Ok(response);
});
app.MapPost("/ask", async (SearchRequest req, ISearch search, CancellationToken cancellationToken) =>
{
string response = await search.Ask(req.Query, cancellationToken);
return Results.Ok(response);
});
app.MapDefaultEndpoints();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.Run();
static OllamaApiClient CreateOllamaClient(IConfiguration configuration, SearchOptions options)
{
int timeoutSeconds = configuration.GetValue("RAGApi:ModelTimeoutSeconds", 600);
var httpClient = new HttpClient
{
BaseAddress = new Uri(options.Endpoint),
Timeout = TimeSpan.FromSeconds(timeoutSeconds)
};
return new OllamaApiClient(httpClient, options.ModelId, jsonSerializerContext: null);
}Services/SearchOptions.cs
using System.Data.Common;
namespace MCP_Aspire.RAGApi.DocumentsApi;
public sealed record SearchOptions(string Endpoint, string ModelId)
{
public static SearchOptions FromConnectionString(IConfiguration configuration, string name)
{
string connectionString = configuration.GetConnectionString(name)
?? throw new InvalidOperationException($"ConnectionStrings:{name} is required.");
var builder = new DbConnectionStringBuilder
{
ConnectionString = connectionString
};
return new SearchOptions(
GetRequiredValue(builder, name, "Endpoint", "Uri", "Url"),
GetRequiredValue(builder, name, "Model", "ModelName"));
}
private static string GetRequiredValue(DbConnectionStringBuilder builder, string connectionName, params string[] names)
{
foreach (string name in names)
{
if (builder.TryGetValue(name, out object? value)
&& value?.ToString() is { Length: > 0 } stringValue)
{
return stringValue;
}
}
throw new InvalidOperationException(
$"ConnectionStrings:{connectionName} is missing required value. Expected one of: {string.Join(", ", names)}.");
}
}Service Defaults
ServiceDefaults centralizes the cross-cutting runtime behavior shared by the application projects. Each service calls AddServiceDefaults() to get service discovery, HTTP resilience, health endpoints, logging, metrics, tracing, and OTLP exporter configuration in a consistent way.
There has been no change in this project; it remains in the default state that comes with the starter template.

Let's run the app
Start with aspire run to launch the Aspire Dashboard.
# To run in Development mode
aspire run

# To run in Keycloak mode
Auth__Mode=Keycloak aspire run

From the Aspire dashboard, you can launch the Web App and start sending prompts to observe the end-to-end functionality.
Clicking on other URLs from the Aspire dashboard displays the respective UI or current status.





NOTE: MCP-Scan and MCP-Shield are configured for explicit start, meaning the user has to start them manually to view the findings. Currently, they display findings in the Aspire console interface, but this can be enhanced to write to files instead.
Authentication flow
Keycloak mode is what enables the secure Web-to-MCP flow. It's worth noting that the Web chat request itself doesn't yet follow a user-delegated OAuth flow. Instead, ChatApi receives the application request and authenticates as the confidential mcp-aspire-chatapi client before calling the protected MCP resource.

When Codex CLI uses this project as an MCP server, Codex acts as the MCP client, connecting directly to the streamable HTTP MCP endpoint exposed by MCP-Aspire.MCP-SSE, bypassing MCP-Aspire.Web and MCP-Aspire.ChatApi entirely.

Web
Web is the Blazor Web App used to exercise ChatApi from a browser. It sends ChatRequest messages to POST /chat and allows the user to select the MCP transport for each request.
For standard application testing, the UI should use MCP-SSE. The MCP-STDIO option is available only in Development mode and is useful for validating that the standalone stdio MCP server exposes the same RAG behaviour as the HTTP MCP server.
Given that this post is already quite long, the full source code isn't included here to keep things concise. If you'd like access to it or have any questions, feel free to reach out via email.
Conclusion
This demo shows one clear way to build and run a local agent application that uses real tools, grounded retrieval, and an MCP endpoint that can run unauthenticated for local development or protected by Keycloak for the secure flow. Aspire owns the distributed application graph, Microsoft Agent Framework hosts the agent in ChatApi, MCP-SSE exposes the HTTP MCP tool surface, MCP-STDIO provides a local development transport, RAGApi owns retrieval and answer generation, Ollama runs the local models, and Keycloak protects the MCP-SSE path when Keycloak mode is enabled.
The main idea is to keep each responsibility separate. The agent does not perform retrieval directly. It calls MCP tools. The MCP servers do not generate answers on their own. They forward tool requests to RAGApi. RAGApi handles indexing, semantic search, and grounded responses. AppHost brings these services together, supplies configuration, and makes the running system observable through the Aspire dashboard.
The two modes serve different purposes. Development mode keeps the inner loop fast by allowing MCP-SSE without Keycloak and by supporting MCP-STDIO parity testing. Keycloak mode validates the secured flow, where ChatApi obtains a client-credentials token and uses it to call the protected MCP-SSE endpoint. Both modes exercise the same RAG capability, so the local workflow and the secured workflow stay aligned.