🔭

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]をそのまま使います。

コード
mcp-server.ts
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]をほとんどそのまま使います。

コード
http-server.ts
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 サーバにプロキシするだけのサーバを用意します。

proxy-server.ts
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]をほとんどそのまま使います。

コード
instrumentation.ts
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();
};
http-server.ts
+import { sdkStart } from "./instrumentation";
+
+sdkStart({
+  serviceName: "mcp-server",
+});
proxy-server.ts
+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 を叩くと分散トレースが取得できました。

以上です。
コードは以下のリポジトリにあります。
https://github.com/YunosukeY/otel-and-mcp-sample

脚注
  1. https://github.com/modelcontextprotocol/python-sdk/issues/443 ↩︎

  2. https://modelcontextprotocol.io/quickstart/server#node ↩︎

  3. https://github.com/modelcontextprotocol/typescript-sdk#without-session-management-stateless ↩︎

  4. https://opentelemetry.io/docs/languages/js/getting-started/nodejs/ ↩︎

Discussion