MCPサーバをOpenTelemetryで計装する
MCP におけるオブザーバビリティの必要性
MCP の利用方法として現時点では以下がよくあると思います。
- MCP サーバをローカルで動かしている
- サードパーティーのリモートサーバを使っている
- クライアントがローカルアプリ
上記の場合にはオブザーバビリティは比較的重要ではありません。
一方で、以下のような場合にはMCP においてもオブザーバビリティが重要です。
- Web アプリケーションが MCP クライアント(例えば生成 AI アプリ)
- MCP サーバを自作している
このような状況では MCP クライアントと MCP サーバは、マイクロサービスで構成されたアプリケーションとして見ることができ、通常のマイクロサービスと同様にオブザーバビリティが重要となります。
小さな生成 AI アプリであればわざわざ MCP でクライアントとサーバに分ける必要はありませんが、生成 AI アプリが巨大化すれば従来の Web アプリケーションの潮流と同様に分割される方向で進化するでしょう。
MCP クライアントとサーバの準備
以下のようなアーキテクチャを作っていきます。
MCP サーバ
まずは MCP サーバを用意します。
生成 AI アプリは Python か TypeScript で実装されることが多いのでどちらかの SDK を使いたいです。
Python SDK では Streamable HTTP transport は絶賛実装中[1]なので今回は TypeScript の SDK を使います。
MCP サーバは動くものがあれば十分なので、公式の Quickstart[2]をそのまま使います。
コード
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const NWS_API_BASE = "https://api.weather.gov";
const USER_AGENT = "weather-app/1.0";
// Create server instance
export const server = new McpServer({
name: "weather",
version: "1.0.0",
capabilities: {
resources: {},
tools: {},
},
});
// Helper function for making NWS API requests
async function makeNWSRequest<T>(url: string): Promise<T | null> {
const headers = {
"User-Agent": USER_AGENT,
Accept: "application/geo+json",
};
try {
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return (await response.json()) as T;
} catch (error) {
console.error("Error making NWS request:", error);
return null;
}
}
interface AlertFeature {
properties: {
event?: string;
areaDesc?: string;
severity?: string;
status?: string;
headline?: string;
};
}
// Format alert data
function formatAlert(feature: AlertFeature): string {
const props = feature.properties;
return [
`Event: ${props.event || "Unknown"}`,
`Area: ${props.areaDesc || "Unknown"}`,
`Severity: ${props.severity || "Unknown"}`,
`Status: ${props.status || "Unknown"}`,
`Headline: ${props.headline || "No headline"}`,
"---",
].join("\n");
}
interface ForecastPeriod {
name?: string;
temperature?: number;
temperatureUnit?: string;
windSpeed?: string;
windDirection?: string;
shortForecast?: string;
}
interface AlertsResponse {
features: AlertFeature[];
}
interface PointsResponse {
properties: {
forecast?: string;
};
}
interface ForecastResponse {
properties: {
periods: ForecastPeriod[];
};
}
// Register weather tools
server.tool(
"get-alerts",
"Get weather alerts for a state",
{
state: z.string().length(2).describe("Two-letter state code (e.g. CA, NY)"),
},
async ({ state }) => {
const stateCode = state.toUpperCase();
const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`;
const alertsData = await makeNWSRequest<AlertsResponse>(alertsUrl);
if (!alertsData) {
return {
content: [
{
type: "text",
text: "Failed to retrieve alerts data",
},
],
};
}
const features = alertsData.features || [];
if (features.length === 0) {
return {
content: [
{
type: "text",
text: `No active alerts for ${stateCode}`,
},
],
};
}
const formattedAlerts = features.map(formatAlert);
const alertsText = `Active alerts for ${stateCode}:\n\n${formattedAlerts.join(
"\n"
)}`;
return {
content: [
{
type: "text",
text: alertsText,
},
],
};
}
);
server.tool(
"get-forecast",
"Get weather forecast for a location",
{
latitude: z.number().min(-90).max(90).describe("Latitude of the location"),
longitude: z
.number()
.min(-180)
.max(180)
.describe("Longitude of the location"),
},
async ({ latitude, longitude }) => {
// Get grid point data
const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(
4
)},${longitude.toFixed(4)}`;
const pointsData = await makeNWSRequest<PointsResponse>(pointsUrl);
if (!pointsData) {
return {
content: [
{
type: "text",
text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`,
},
],
};
}
const forecastUrl = pointsData.properties?.forecast;
if (!forecastUrl) {
return {
content: [
{
type: "text",
text: "Failed to get forecast URL from grid point data",
},
],
};
}
// Get forecast data
const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl);
if (!forecastData) {
return {
content: [
{
type: "text",
text: "Failed to retrieve forecast data",
},
],
};
}
const periods = forecastData.properties?.periods || [];
if (periods.length === 0) {
return {
content: [
{
type: "text",
text: "No forecast periods available",
},
],
};
}
// Format forecast periods
const formattedForecast = periods.map((period: ForecastPeriod) =>
[
`${period.name || "Unknown"}:`,
`Temperature: ${period.temperature || "Unknown"}°${
period.temperatureUnit || "F"
}`,
`Wind: ${period.windSpeed || "Unknown"} ${period.windDirection || ""}`,
`${period.shortForecast || "No forecast available"}`,
"---",
].join("\n")
);
const forecastText = `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join(
"\n"
)}`;
return {
content: [
{
type: "text",
text: forecastText,
},
],
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
HTTP サーバ
次に MCP サーバをサーブする HTTP サーバを用意します。
こちらも動くものがあれば十分なので、公式の README[3]をほとんどそのまま使います。
コード
import express, { type Request, type Response } from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { server } from "./mcp-server";
const app = express();
app.use(express.json());
app.post("/mcp", async (req: Request, res: Response) => {
// In stateless mode, create a new instance of transport and server for each request
// to ensure complete isolation. A single instance would cause request ID collisions
// when multiple clients connect concurrently.
try {
const transport: StreamableHTTPServerTransport =
new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on("close", () => {
console.log("Request closed");
transport.close();
server.close();
});
} catch (error) {
console.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: null,
});
}
}
});
app.get("/mcp", async (req: Request, res: Response) => {
console.log("Received GET MCP request");
res.writeHead(405).end(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Method not allowed.",
},
id: null,
})
);
});
app.delete("/mcp", async (req: Request, res: Response) => {
console.log("Received DELETE MCP request");
res.writeHead(405).end(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Method not allowed.",
},
id: null,
})
);
});
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`);
});
MCP クライアント
今回のシナリオでは MCP クライアントも HTTP サーバです。
実際には生成 AI アプリだったりしますが、今回は HTTP リクエストをそのまま MCP サーバにプロキシするだけのサーバを用意します。
import express, { type Request, type Response } from "express";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req: Request, res: Response) => {
const url = new URL("http://localhost:3000/mcp");
const client = new Client({
name: "streamable-http-client",
version: "1.0.0",
});
const transport = new StreamableHTTPClientTransport(url);
await client.connect(transport);
const result = await client.callTool({
name: "get-forecast",
arguments: {
latitude: 38.6272,
longitude: -90.1978,
},
});
console.log(result);
res.json(result);
});
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Proxy Server listening on port ${PORT}`);
});
動作確認
プロキシサーバの API を curl で叩くと、一番背後にいる MCP サーバの結果が取得できます。
curl -X POST http://localhost:3001/mcp
{"content":[{"type":"text","text":"Forecast for 38.6272, -90.1978:\n\nOvernight:\nTemperature: 49°F\nWind: 3 mph NW\nMostly Cloudy\n---\nMonday:\nTemperature: 63°F\nWind: 3 to 7 mph N\nMostly Cloudy\n---\nMonday Night:\nTemperature: 49°F\nWind: 1 to 5 mph NW\nPartly Cloudy\n---\nTuesday:\nTemperature: 74°F\nWind: 3 mph S\nMostly Sunny\n---\nTuesday Night:\nTemperature: 56°F\nWind: 2 mph E\nMostly Cloudy then Slight Chance Rain Showers\n---\nWednesday:\nTemperature: 71°F\nWind: 2 to 7 mph E\nChance Rain Showers\n---\nWednesday Night:\nTemperature: 56°F\nWind: 5 mph E\nChance Rain Showers\n---\nThursday:\nTemperature: 74°F\nWind: 8 mph NE\nChance Rain Showers\n---\nThursday Night:\nTemperature: 52°F\nWind: 7 mph NE\nSlight Chance Showers And Thunderstorms\n---\nFriday:\nTemperature: 72°F\nWind: 9 mph NE\nSlight Chance Rain Showers\n---\nFriday Night:\nTemperature: 51°F\nWind: 7 mph NE\nSlight Chance Rain Showers then Partly Cloudy\n---\nSaturday:\nTemperature: 76°F\nWind: 7 mph N\nSlight Chance Rain Showers\n---\nSaturday Night:\nTemperature: 54°F\nWind: 5 mph NE\nSlight Chance Rain Showers then Mostly Clear\n---\nSunday:\nTemperature: 79°F\nWind: 7 mph NE\nSunny then Slight Chance Rain Showers\n---"}]}
OpenTelemetry による計装
実装
OpenTelemetry の Getting Started[4]をほとんどそのまま使います。
コード
import { NodeSDK, type NodeSDKConfiguration } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import {
PeriodicExportingMetricReader,
ConsoleMetricExporter,
} from "@opentelemetry/sdk-metrics";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
export const sdkStart = (config: Partial<NodeSDKConfiguration>) => {
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter(),
metricReader: new PeriodicExportingMetricReader({
exporter: new ConsoleMetricExporter(),
}),
instrumentations: [getNodeAutoInstrumentations()],
...config,
});
sdk.start();
};
+import { sdkStart } from "./instrumentation";
+
+sdkStart({
+ serviceName: "mcp-server",
+});
+import { sdkStart } from "./instrumentation";
+
+sdkStart({
+ serviceName: "proxy-server",
+});
動作確認
トレースを確認するため、Jaeger を起動しておきます。
docker run -d --name jaeger \
-p 16686:16686 \
-p 4317:4317 \
-p 4318:4318 \
-p 5778:5778 \
-p 9411:9411 \
jaegertracing/jaeger:2.5.0
再度プロキシサーバの API を叩くと分散トレースが取得できました。
以上です。
コードは以下のリポジトリにあります。
Discussion