0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPでDual-eraクライアントでLegacyサーバに接続するときのリクエストとレスポンスを観測する

0
Posted at

概要

2026-07-28 にMCPがアップデートされた。
Legacyサーバを使い続ける場合、Dual-eraクライアントで接続できることは確認しておきたい。

しかし、この公式ドキュメントにDual-eraクライアントの正確なフローが載ってない。
Modernのリクエストに対して、400 Bad Requestのボディを点検してからLegacyを試すように書かれているだけ。
image.png

SDKの実装ではどう処理しているか見るため、MCPクライアントが行うリクエストとレスポンスをテキストファイルに書き込むコードを作成した。

Dual-eraクライアント

McpUrlでMCPサーバを指定して、fetch_log.txtにログを保存するコード。
fetchを使ってリクエストとレスポンスを取得している。

ソースコード

import { Client, StreamableHTTPClientTransport, SdkError } from '@modelcontextprotocol/client';
import * as fs from 'fs';

let originalFetch: typeof globalThis.fetch | undefined;

interface CustomLogger {
    log: (...args: any[]) => void;
    warn: (...args: any[]) => void;
    error: (...args: any[]) => void;
    close: () => void;
}

let customLogger: CustomLogger = {
    log: console.log,
    warn: console.warn,
    error: console.error,
    close: () => {}
};

/**
 * ログ出力用のロガーインスタンスを生成します。
 * ファイルパスが指定された場合、そのファイルにログを書き込み、コンソールにも出力します。
 * 指定されない場合は、コンソールのみに出力します。
 * @param filePath ログを書き込むファイルのパス。
 */
function createLogger(filePath?: string): CustomLogger {
    let _logStream: fs.WriteStream | undefined; // ロガーインスタンス内部で管理されるログストリーム

    if (filePath) {
        _logStream = fs.createWriteStream(filePath, { flags: 'w', encoding: 'utf8' });
        _logStream.on('error', (err) => {
            console.error(`🚨 Log file stream error: ${err.message}`);
        });
        console.log(`ログ出力先ファイル: ${filePath} (実行ごとに上書きされます)`);

        const logToConsoleAndFile = (level: 'log' | 'warn' | 'error', ...args: any[]) => {
            if (level === 'log') console.log(...args);
            else if (level === 'warn') console.warn(...args);
            else console.error(...args);

            const message = args.map(arg => {
                if (typeof arg === 'object' && arg !== null) {
                    try {
                        const cache = new Set();
                        return JSON.stringify(arg, (key, value) => {
                            if (typeof value === 'object' && value !== null) {
                                if (cache.has(value)) {
                                    // Circular reference found, discard key
                                    return;
                                }
                                // Store value in our collection
                                cache.add(value);
                            }
                            return value;
                        }, 2);
                    } catch (e) {
                        return String(arg);
                    }
                }
                return String(arg);
            }).join(' ');

            const logLine = `${message}\n`;

            if (_logStream) { // 内部の_logStreamを使用
                _logStream.write(logLine);
            }
        };

        return {
            log: (...args: any[]) => logToConsoleAndFile('log', ...args),
            warn: (...args: any[]) => logToConsoleAndFile('warn', ...args),
            error: (...args: any[]) => logToConsoleAndFile('error', ...args),
            close: () => {
                if (_logStream) {
                    _logStream.end();
                    _logStream = undefined; // 閉じた後はクリア
                    console.log(`ログファイル ${filePath} を閉じました。`);
                }
            }
        };
    } else {
        return {
            log: console.log,
            warn: console.warn,
            error: console.error,
            close: () => {}
        };
    }
}

/**
 * fetch関数をラップして、HTTP通信の内容をコンソールと指定されたファイルに記録するヘルパー関数。
 * この関数は、プログラムの開始時に一度だけ呼び出す必要があります。
 *
 * @param options ロギングのオプション設定
 * @param options.logBody リクエスト/レスポンスのボディをログに出力するかどうか (デフォルト: true)
 * @param options.jsonIndent JSONボディを整形して出力する際のインデント数 (デフォルト: 2)
 * @param options.logFilePath ログを保存するファイルのパス。指定しない場合はファイルに保存されず、コンソールのみに出力されます。
 */
function wrapFetchWithLogging(options?: { logBody?: boolean; jsonIndent?: number; logFilePath?: string }) {
    const logBody = options?.logBody ?? true; // デフォルトはボディをログに出力
    const jsonIndent = options?.jsonIndent ?? 2; // デフォルトはインデント2
    const logFilePath = options?.logFilePath;

    // ロガーを初期化 (logFilePathが指定されていなければコンソール出力のみ)
    // ここでcustomLoggerを初期化する
    customLogger = createLogger(logFilePath);

    if (originalFetch) {
        // ロガーが存在すればロガー経由で警告、なければ通常のconsole.warn
        customLogger.warn("⚠️ fetchは既にラップされています。多重ラップは行いません。");
        return;
    }

    originalFetch = globalThis.fetch;

    globalThis.fetch = async function (input: string | URL | Request, init?: RequestInit): Promise<Response> {
        // リクエスト情報を抽出
        const requestUrl = input instanceof Request ? input.url : input.toString();
        const requestMethod = init?.method || (input instanceof Request ? input.method : 'GET');
        const requestHeaders = init?.headers || (input instanceof Request ? input.headers : undefined);
        // ヘッダーをHeadersオブジェクトに変換し、ContentTypeを簡単に取得できるようにする
        const requestHeadersObj = new Headers(requestHeaders);
        const requestContentType = requestHeadersObj.get('content-type');

        customLogger.log(`\n--- HTTP Request (START) ---`);
        customLogger.log(`[REQUEST] URL: ${requestUrl}`);
        customLogger.log(`[REQUEST] Method: ${requestMethod}`);
        customLogger.log(`[REQUEST] Headers:`, Object.fromEntries(requestHeadersObj.entries()));

        if (logBody) {
            let requestBodyContent: string | object | undefined;
            const clonedRequest = input instanceof Request ? input.clone() : undefined;

            // Requestオブジェクトからボディを読み取る場合
            if (clonedRequest && clonedRequest.bodyUsed === false && clonedRequest.body !== null) {
                try {
                    if (requestContentType?.includes('application/json')) {
                        requestBodyContent = await clonedRequest.json() as object;
                    } else if (requestContentType?.includes('text/') || requestContentType?.includes('application/x-www-form-urlencoded')) {
                        requestBodyContent = await clonedRequest.text();
                    } else {
                        customLogger.log(`[REQUEST] Body: (Content skipped for non-text/json or binary)`);
                    }
                } catch (e) {
                    customLogger.warn(`[REQUEST] Failed to parse request body from Request object:`, e);
                }
            }
            // initオプションからボディを読み取る場合
            else if (init?.body) {
                if (typeof init.body === 'string') {
                    // Content-Typeがapplication/jsonの場合のみJSONパースを試みる
                    if (requestContentType?.includes('application/json')) {
                        try {
                            requestBodyContent = JSON.parse(init.body) as object;
                        } catch (e) {
                            customLogger.warn(`[REQUEST] Failed to parse string body as JSON (despite Content-Type: application/json):`, e);
                            requestBodyContent = init.body; // パース失敗時は元の文字列をログ
                        }
                    } else {
                        requestBodyContent = init.body;
                    }
                } else if (init.body instanceof URLSearchParams) {
                    requestBodyContent = init.body.toString();
                } else {
                    customLogger.log(`[REQUEST] Body: (Content skipped, type: ${init.body.constructor.name})`);
                }
            }

            if (requestBodyContent !== undefined) {
                if (typeof requestBodyContent === 'string') {
                    customLogger.log(`[REQUEST] Body: ${requestBodyContent.substring(0, 1000)}${requestBodyContent.length > 1000 ? '...' : ''}`);
                } else if (typeof requestBodyContent === 'object') {
                    customLogger.log(`[REQUEST] Body:\n${JSON.stringify(requestBodyContent, null, jsonIndent)}`);
                }
            }
        } else {
             customLogger.log(`[REQUEST] Body: (Logging disabled by options)`);
        }
        customLogger.log(`------------------------------------`);

        try {
            const response = await (originalFetch as typeof globalThis.fetch)(input, init);

            customLogger.log(`\n--- HTTP Response (END) ---`);
            customLogger.log(`[RESPONSE] URL: ${requestUrl}`);
            customLogger.log(`[RESPONSE] Status: ${response.status} ${response.statusText}`);
            customLogger.log(`[RESPONSE] Headers:`, Object.fromEntries(response.headers.entries()));

            if (logBody) {
                try {
                    const clonedResponse = response.clone();
                    const contentType = clonedResponse.headers.get('content-type');
                    
                    if (contentType?.includes('application/json')) {
                        const json = await clonedResponse.json();
                        customLogger.log(`[RESPONSE] Body:\n${JSON.stringify(json, null, jsonIndent)}`);
                    } else if (contentType?.includes('text/')) {
                        const text = await clonedResponse.text();
                        customLogger.log(`[RESPONSE] Body: ${text.substring(0, 1000)}${text.length > 1000 ? '...' : ''}`);
                    } else if (contentType?.includes('application/octet-stream')) {
                        customLogger.log(`[RESPONSE] Body: (Binary data, content skipped)`);
                    } else {
                        customLogger.log(`[RESPONSE] Body: (Content skipped for non-text/json or unknown type)`);
                    }
                } catch (bodyError) {
                    if (bodyError instanceof DOMException && bodyError.name === 'AbortError') {
                        // クライアントが閉じられたことによるAbortErrorの場合、警告ではなく情報として扱う
                        customLogger.log(`[RESPONSE] Body: (Logging aborted due to client close)`);
                    } else {
                        customLogger.warn(`[RESPONSE] Failed to log response body:`, bodyError);
                    }
                }
            } else {
                customLogger.log(`[RESPONSE] Body: (Logging disabled by options)`);
            }

            customLogger.log(`-----------------------------------`);
            return response;
        } catch (error) {
            customLogger.error(`\n--- HTTP Error (END) ---`);
            customLogger.error(`[ERROR] URL: ${requestUrl}`);
            customLogger.error(`[ERROR] Error:`, error);
            customLogger.error(`---------------------------------`);
            throw error;
        }
    };

    customLogger.log("✅ fetch関数をラップしました。MCPクライアントからのHTTP通信が記録されます。");
}

/**
 * MCPサーバーに接続し、プロトコルEraとツール一覧を表示する関数。
 * @param serverUrlString 接続するMCPサーバーのURL文字列。
 */
async function connectAndListTools(serverUrlString: string) {
    customLogger.log('\n--- MCP接続Eraとツール一覧の表示 ---');

    const clientForListing = new Client(
        { name: 'my-tool-lister', version: '1.0.0' },
        { versionNegotiation: { mode: 'auto' } }
    );

    try {
        const serverUrl = new URL(serverUrlString);
        customLogger.log(`サーバー ${serverUrl.toString()} に接続を試行中...`);

        await clientForListing.connect(new StreamableHTTPClientTransport(serverUrl));
        customLogger.log('接続成功!');

        const era = clientForListing.getProtocolEra();
        customLogger.log(`確立されたプロトコル Era: ${era}`);

        const tools = await clientForListing.listTools();
        customLogger.log(`取得されたツール一覧:`, tools.tools);

    } catch (error) {
        if (error instanceof SdkError) {
            customLogger.error(`❌ MCP SDKエラー (${error.code}): ${error.message}`);
        } else if (error instanceof Error) {
            customLogger.error(`❌ MCPサーバーへの接続またはツール取得中に予期せぬエラーが発生しました: ${error.message}`);
        } else {
            customLogger.error('❌ MCPサーバーへの接続またはツール取得中に予期せぬエラーが発生しました:', error);
        }
    } finally {
        await clientForListing.close();
        customLogger.log('クライアント接続を閉じました。');
    }
    customLogger.log('--- 処理終了 ---');
}

// クライアントロジックを実行
const McpUrl = 'http://127.0.0.1:3000/mcp';
const LOG_FILE_PATH = 'fetch_log.txt';

wrapFetchWithLogging({ logBody: true, jsonIndent: 2, logFilePath: LOG_FILE_PATH });

(async () => {
    await connectAndListTools(McpUrl);
    if (customLogger) {
        customLogger.close();
    }
})();

Legacyサーバ

サンプルサーバの立ち上げ方は以下の記事参照。

今回はtoolsの代わりにstandalone-getを立ち上げる

pnpm --filter @mcp-examples/standalone-get server -- --http --port 3000

実行結果

Modernのserver/discoverのリクエスト
image.png

Modernのserver/discoverのリクエストに対する400 Bad Requestのレスポンス
image.png

その後、Legacyのinitializeのリクエスト
image.png

Legacyのinitializeのリクエストに対するレスポンス
image.png

Modernリクエストで400エラー後に、Legacy接続が行われていることを確認できた。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?