Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ OLLAMA_API_KEY=ollama
# OpenAI Configuration (default if API_HOST not set)
OPENAI_MODEL=gpt-4o-mini
OPENAI_API_KEY=your_openai_api_key_here

# OpenTelemetry Configuration (for Aspire Dashboard)
# Uncomment to enable tracing, metrics, and logs export
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A demonstration project showcasing Model Context Protocol (MCP) implementations
- [Use with GitHub Copilot](#use-with-github-copilot)
- [Debug with VS Code](#debug-with-vs-code)
- [Inspect with MCP inspector](#inspect-with-mcp-inspector)
- [View traces with Aspire Dashboard](#view-traces-with-aspire-dashboard)
- [Run local Agents <-> MCP](#run-local-agents---mcp)
- [Deploy to Azure](#deploy-to-azure)
- [Deploy to Azure with private networking](#deploy-to-azure-with-private-networking)
Expand Down Expand Up @@ -156,6 +157,42 @@ The inspector provides a web interface to:
- Inspect server responses and errors
- Debug server communication

### View traces with Aspire Dashboard

You can use the [.NET Aspire Dashboard](https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone) to view OpenTelemetry traces, metrics, and logs from the MCP server.

> **Note:** Aspire Dashboard integration is only configured for the HTTP server (`basic_mcp_http.py`).

1. Start the Aspire Dashboard:

```bash
docker run --rm -d -p 18888:18888 -p 4317:18889 --name aspire-dashboard \
mcr.microsoft.com/dotnet/aspire-dashboard:latest
```

> The Aspire Dashboard exposes its OTLP endpoint on container port 18889. The mapping `-p 4317:18889` makes it available on the host's standard OTLP port 4317.

Get the dashboard URL and login token from the container logs:

```bash
docker logs aspire-dashboard 2>&1 | grep "Login to the dashboard"
```

2. Enable OpenTelemetry by adding this to your `.env` file:

```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
```

3. Start the HTTP server:

```bash
uv run servers/basic_mcp_http.py
```


4. View the dashboard at: http://localhost:18888

---

## Run local Agents <-> MCP
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"azure-cosmos>=4.9.0",
"azure-monitor-opentelemetry>=1.6.4",
"opentelemetry-instrumentation-starlette>=0.49b0",
"opentelemetry-exporter-otlp-proto-grpc>=1.28.0",
"logfire>=4.15.1",
"azure-core-tracing-opentelemetry>=1.0.0b12"
]
Expand All @@ -33,3 +34,4 @@ line-length = 120
target-version = "py310"
lint.select = ["E", "F", "I", "UP"]
lint.ignore = ["D203"]
lint.isort.known-first-party = ["opentelemetry_middleware"]
15 changes: 14 additions & 1 deletion servers/basic_mcp_http.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
import csv
import logging
import os
from datetime import date
from enum import Enum
from pathlib import Path
from typing import Annotated

from dotenv import load_dotenv
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware

from opentelemetry_middleware import OpenTelemetryMiddleware, configure_aspire_dashboard

load_dotenv(override=True)

logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")
logger = logging.getLogger("ExpensesMCP")

middleware: list[Middleware] = []
if os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"):
logger.info("Setting up Aspire Dashboard instrumentation (OTLP)")
configure_aspire_dashboard(service_name="expenses-mcp")
middleware = [OpenTelemetryMiddleware(tracer_name="expenses.mcp")]


SCRIPT_DIR = Path(__file__).parent
EXPENSES_FILE = SCRIPT_DIR / "expenses.csv"


mcp = FastMCP("Expenses Tracker")
mcp = FastMCP("Expenses Tracker", middleware=middleware)


class PaymentMethod(Enum):
Expand Down
55 changes: 54 additions & 1 deletion servers/opentelemetry_middleware.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,61 @@
import logging
import os

from fastmcp.server.middleware import Middleware, MiddlewareContext
from opentelemetry import trace
from opentelemetry import metrics, trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler # _logs is "experimental", not "private"
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import Status, StatusCode


def configure_aspire_dashboard(service_name: str = "expenses-mcp"):
"""Configure OpenTelemetry to send telemetry to the Aspire standalone dashboard.

Requires the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to be set.
"""
otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if not otlp_endpoint:
raise ValueError("OTEL_EXPORTER_OTLP_ENDPOINT environment variable must be set to configure telemetry export.")

# Create resource with service name
resource = Resource.create({"service.name": service_name})

# Configure Tracing
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint)))
trace.set_tracer_provider(tracer_provider)

# Configure Metrics
metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter(endpoint=otlp_endpoint))
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)

# Configure Logging
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter(endpoint=otlp_endpoint)))
set_logger_provider(logger_provider)

# Add logging handler to send Python logs to OTLP
root_logger = logging.getLogger()
handler_exists = any(
isinstance(existing, LoggingHandler) and getattr(existing, "logger_provider", None) is logger_provider
for existing in root_logger.handlers
)

if not handler_exists:
handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
root_logger.addHandler(handler)


class OpenTelemetryMiddleware(Middleware):
"""Middleware that creates OpenTelemetry spans for MCP operations."""

Expand Down
Loading