概要
2026-07-28 にMCPがアップデートされた。
Legacyサーバを使い続ける場合、Dual-eraクライアントで接続できることは確認しておきたい。
しかし、この公式ドキュメントにDual-eraクライアントの正確なフローが載ってない。
Modernのリクエストに対して、400 Bad Requestのボディを点検してからLegacyを試すように書かれているだけ。

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のリクエストに対する400 Bad Requestのレスポンス

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

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

