📝

Postman AI Agent Builder を使ってNew York Times のMCPサーバを構築する

に公開
4

PostmanがPostmanネットワーク上で公開されているAPIから自動でMCP Server用jsを作成してくれる機能がリリースされました。
中の人、草薙さんがいかに使い方をまとめてくれています。
https://qiita.com/nagix/items/9011274a51cdb0a3d7de
この記事ではGoogle Mapを使っていますが、同じではさみしいのでNew York Timesで世界のニュースをサマリーしてくれるMCPを作成してみました。

上でご紹介した API 以外でも色々試してみましたが、期待通りにうまく動くものもあれば、動かないものもあります。動かない場合の原因を探っていくと、API のドキュメントが不十分(もしくは API カタログ上から外部リンクをたどらないと見つからない場所にある等)で、AI に API の振る舞いの意図が伝わっていないことが多いように見受けられました。API のドキュメント・・・やはり AI 時代でも重要ですね

とあるように、生成されたものはかなり惜しいところまでできていましたが、いくつか修正が必要でした。

さっそくやってみる

0. 事前環境準備

まずはこちらを終わらせておきます。
https://zenn.dev/kameoncloud/articles/7b663daf3c4fad

1. New York Times 用APIキーの発行とAppの登録

https://developer.nytimes.com/
にアクセスを行い画面右上のSign inをクリックします。クレカ不要で登録できます。
その後Appをクリックします。

Top Stories APIをクリックしてSAVEをクリックします。

次の画面でAPI Keyが発行されますので、コピーして手元にメモをしておきます。

2. Postmanからのテスト

まずはAPI Keyをテストしましょう。
Postmanの画面上部検索窓からNew York Timesを検索します。いくつかありますが、以下画面で指定しているものを選択します。

フォークを作成をクリックして自分のワークスペースにコピーします。

コレクションをフォークをクリックします。

Top Storiesを実行するためapi keyを登録します。


APIを呼び出して200と合わせて以下の様なjsonが戻ってくれば成功です。

{
    "status": "OK",
    "copyright": "Copyright (c) 2025 The New York Times Company. All Rights Reserved.",
    "section": "Arts",
    "last_updated": "2025-04-30T13:53:50-04:00",
    "num_results": 39,
    "results": [
        {
            "section": "arts",
            "subsection": "music",
            "title": "Sean Combs’s Path From Harlem to Stardom, and Now Federal Court",
            "abstract": "As Puffy, Diddy or Love, the mogul found success and trouble. After years of accusations with few consequences, he’ll stand trial next month.",
            "url": "https://www.nytimes.com/2025/04/30/arts/music/sean-combs-diddy-trial.html",
            "uri": "nyt://article/aae23943-d7fd-5d59-bedc-d15a2ed641b7",

3. MCP Server のひな形作成

Postmanの画面上部からAPI ネットワークを作成し、Build AI Toolsをクリックします。

NY Timesを検索してTop Stories用APIを選択し、Add Requests to Agentをクリックします。

次にBuild as MCP Serverを選択しBuildをクリックします。

しばらく待つとZIPファイルがダウンロード可能になります。

4. MCP Server の Claude Desktopへの登録

https://zenn.dev/kameoncloud/articles/7b663daf3c4fad
の手順ですでにMCPが2つClaude Desktopへ登録されています。
ここにあらたにいま作成されたMCPを追加します。
まずはnytimesmcpというフォルダに先ほどDLしたZIPの中身を展開します。
npm installで必要なモジュールをインストールします。
次に.envファイルを修正します。

# Workspace API Keys
THE_NEW_YORK_TIMES_API_KEY=

先ほどのAPI Keyをペーストして保存します。

前の手順で生成されたClaudeの設定ファイルを修正します。

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": [
        "C:\\Users\\h.kameda\\firstmcp\\build\\index.js"
      ]
    },
    "new-york-times-mcp": {
      "command": "node",
      "args": [
        "C:\\Users\\h.kameda\\nytimesmcp\\mcpServer.js"
      ],
      "env": {
        "THE_NEW_YORK_TIMES_API_KEY": "<apikey>"
      }
    }
  }
}

<C:\\Users\\h.kameda\\nytimesmcp\\mcpServer.js>は皆さんの環境に合わせてください。mcpServer.jsへのフルパスが指定されていればOKです。`<apikey>も同じく置き換えておきます。

5. 起動とテスト

Claude Desktopを起動して、トンカチアイコンをクリックすると無事起動しています。

2025年5月1日のNY Timesのニュースを教えて下さい
Web検索ではなく、fetch_top_storiesというMCPを使ってください

というプロンプトで検索します。
ちなみに無償版だと2行目はなくてもよいのですが、Pro版だと勝手にWeb検索を優先させるケースがあるため指定しています。
ちなみにMCPはいつ呼び出すかの制御は生成AIが行うため結構あいまいだったりしますが、このあたりはそのうち解決していく気がします。

はい!エラーとなりました😿
Postmanは毎回異なるスクリプトを生成するのですがほとんどの場合エラーとなりました。
草薙さんもおっしゃっている通り、Postmanで公開されているオリジナルのAPIドキュメントの記述が足りていない(特にAPIに投げ込むパラメータやAPIキーの取り扱い)とそうなるようです。
人によっては動くケースもあると思います。

6. 修正版への置換

とはいえ、これだけひな形を作ってくれているのであれば修正はそこまで大変ではありませんでした。詳細は置いておいて以下にファイルを置換していきます。

mcpServer.js
#!/usr/bin/env node
import dotenv from "dotenv";
import express from "express";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { discoverTools } from './lib/tools.js';

// 起動時のデバッグ情報
console.log('Starting MCP Server...');
console.log('Current directory:', process.cwd());

// dotenvの設定(他のインポートの前に)
dotenv.config();

// 環境変数の確認
console.log('Environment check:');
console.log('NYT_API_KEY exists:', Boolean(process.env.NYT_API_KEY));
console.log('Env variables available:', Object.keys(process.env).length);

// .envファイルの存在確認
try {
  const envPath = path.join(process.cwd(), '.env');
  if (fs.existsSync(envPath)) {
    const envContent = fs.readFileSync(envPath, 'utf8');
    console.log('.env file exists and content length:', envContent.length);
  } else {
    console.log('.env file does not exist at path:', envPath);
  }
} catch (err) {
  console.error('Error checking .env file:', err.message);
}

// __dirname を ESM 用に定義
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// ログファイルパス(絶対パス)
const LOG_FILE = path.join(__dirname, 'logs', 'error.log');

// ログディレクトリ作成
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });

// ログ追記関数
function appendErrorLog(content) {
  const timestamp = new Date().toISOString();
  const fullLog = `[${timestamp}] ${content}\n\n`;
  fs.appendFileSync(LOG_FILE, fullLog, 'utf8');
}

const SERVER_NAME = "generated-mcp-server";

async function transformTools(tools) {
  return tools
    .map((tool) => {
      const fn = tool?.definition?.function;
      if (!fn) return null;
      return {
        name: fn.name,
        description: fn.description,
        inputSchema: fn.parameters,
      };
    })
    .filter((tool) => tool != null);
}

async function run() {
  const args = process.argv.slice(2);
  const isSSE = args.includes('--sse');

  const server = new Server(
    {
      name: SERVER_NAME,
      version: '0.1.0',
    },
    {
      capabilities: {
        tools: {},
      },
    }
  );

  server.onerror = (error) => {
    console.error('\x1b[31m[Server Error]\x1b[0m', error.message);
    if (error.stack) console.error(error.stack);
    appendErrorLog(`[Server Error] ${error.message}\n${error.stack || ''}`);
  };

  process.on('SIGINT', async () => {
    await server.close();
    process.exit(0);
  });

  const tools = await discoverTools();
  console.log('Discovered tools:', tools.map(t => t?.definition?.function?.name).filter(Boolean));

  server.setRequestHandler(ListToolsRequestSchema, async () => {
    return { tools: await transformTools(tools) };
  });

  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const toolName = request.params.name;
    const tool = tools.find((t) => t?.definition?.function?.name === toolName);
    const args = request.params.arguments || {};

    console.log(`Executing tool: ${toolName} with args:`, args);

    if (!tool) {
      throw new Error(`❌ Unknown tool: ${toolName}`);
    }

    const requiredParams = tool.definition?.function?.parameters?.required || [];
    for (const param of requiredParams) {
      if (!(param in args)) {
        throw new Error(`❌ Missing required parameter: ${param}`);
      }
    }

    if (typeof tool.function !== 'function') {
      throw new Error(`❌ Tool function is not defined for ${toolName}`);
    }

    try {
      const result = await tool.function(args);
      console.log(`Tool execution successful for: ${toolName}`);
      return {
        content: [{
          type: 'text',
          text: JSON.stringify(result, null, 2),
        }],
      };
    } catch (error) {
      const fullMessage = [
        `❌ [TOOL EXECUTION ERROR]`,
        `Tool Name: ${toolName}`,
        `Arguments: ${JSON.stringify(args, null, 2)}`,
        `Error Name: ${error.name}`,
        `Error Message: ${error.message}`,
        error.stack ? `Stack Trace:\n${error.stack}` : '',
      ].join('\n\n');

      console.error('\x1b[31m[Execution Error]\x1b[0m', fullMessage);
      appendErrorLog(fullMessage);

      // Claude には簡潔なメッセージのみ
      throw new Error("❌ Failed to execute tool. Check logs/error.log for details.");
    }
  });

  if (isSSE) {
    const app = express();
    app.use(express.json());

    const transports = {};

    app.get("/sse", async (_req, res) => {
      const transport = new SSEServerTransport('/messages', res);
      transports[transport.sessionId] = transport;

      res.on("close", () => {
        delete transports[transport.sessionId];
      });

      await server.connect(transport);
    });

    app.post("/messages", async (req, res) => {
      const sessionId = req.query.sessionId;
      const transport = transports[sessionId];

      if (transport) {
        await transport.handlePostMessage(req, res);
      } else {
        res.status(400).send("No transport found for sessionId");
      }
    });

    const port = process.env.PORT || 3001;
    app.listen(port, () => {
      console.log(`[SSE Server] running on port ${port}`);
    });
  } else {
    const transport = new StdioServerTransport();
    await server.connect(transport);
  }
}

run().catch((err) => {
  console.error('\x1b[31m[Fatal Error]\x1b[0m', err.message);
  if (err.stack) console.error(err.stack);
  appendErrorLog(`[Fatal Error] ${err.message}\n${err.stack || ''}`);
});

これでログが出力されるようになります。(logsフォルダは手で作成しておきます)

topstories.js
/**
 * Function to fetch top stories from the New York Times API.
 *
 * @param {Object} args - Arguments for the top stories request.
 * @param {string} args.section - The section the story appears in.
 * @param {string} args.format - The format of the response (either 'json' or 'jsonp').
 * @param {string} args.callback - The name of the function for JSONP callback.
 * @returns {Promise<Object>} - The result of the top stories request.
 */
// Node.js v17以前の場合は、こちらを有効化してください
// import fetch from 'node-fetch';

const executeFunction = async ({ section, format, callback }) => {
  console.log('Starting fetch_top_stories with params:', { section, format, callback });
  const baseUrl = 'https://api.nytimes.com/svc/topstories/v2';
  
  // 環境変数またはハードコードされたAPIキーを使用
  // 注:本番環境では環境変数を使用するべきですが、デバッグのために直接指定
  const apiKey = process.env.THE_NEW_YORK_TIMES_API_KEY;
  
  console.log('API Key exists:', Boolean(apiKey));
  
  if (!apiKey) {
    throw new Error("NYT API key is not configured");
  }
  
  try {
    // API keyをURLに追加
    const url = `${baseUrl}/${section}.${format}?api-key=${apiKey}${format === 'jsonp' ? `&callback=${callback}` : ''}`;
    console.log('Request URL (without API key):', `${baseUrl}/${section}.${format}?api-key=[HIDDEN]${format === 'jsonp' ? `&callback=${callback}` : ''}`);

    // Set up headers for the request
    const headers = {
      'Accept': 'application/json'
    };

    // Perform the fetch request
    console.log('Attempting to fetch data...');
    const response = await fetch(url, {
      method: 'GET',
      headers
    });

    // Check if the response was successful
    if (!response.ok) {
      const errorText = await response.text();
      console.error('API error response:', errorText);
      throw new Error(`NYT API responded with status ${response.status}: ${errorText}`);
    }

    // レスポンス処理
    if (format === 'jsonp') {
      // JSONPの場合はテキストでレスポンスを取得
      const text = await response.text();
      console.log(`JSONP response received, length: ${text.length}`);
      return { jsonp: text };
    } else {
      // JSONの場合は通常通り処理
      const data = await response.json();
      console.log('JSON response received successfully');
      return data;
    }
  } catch (error) {
    console.error('Error fetching top stories:', error);
    // MCPサーバーはエラーをスローする必要がある
    throw new Error(`Failed to fetch top stories: ${error.message}`);
  }
};

/**
 * Tool configuration for fetching top stories from the New York Times API.
 * @type {Object}
 */
const apiTool = {
  function: executeFunction,
  definition: {
    type: 'function',
    function: {
      name: 'fetch_top_stories',
      description: 'Fetch top stories from the New York Times API.',
      parameters: {
        type: 'object',
        properties: {
          section: {
            type: 'string',
            description: 'The section the story appears in.'
          },
          format: {
            type: 'string',
            enum: ['json', 'jsonp'],
            description: 'The format of the response.'
          },
          callback: {
            type: 'string',
            description: 'The name of the function for JSONP callback.'
          }
        },
        required: ['section', 'format', 'callback']
      }
    }
  }
};

export { apiTool };

無事動作しました!

const apiKey = process.env.THE_NEW_YORK_TIMES_API_KEY';ですがどうも.envを読むのでhはなく、claude_desktop_config.jsonの値をとっているようです。

Discussion

草薙 昭彦草薙 昭彦

環境変数の件、Claudeの設定ファイルにenvプロパティを書いておくと動くのではないでしょうか。

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": [
        "C:\\Users\\h.kameda\\firstmcp\\build\\index.js"
      ]
    },
    "new-york-times-mcp": {
      "command": "node",
      "args": [
        "C:\\Users\\h.kameda\\nytimesmcp\\mcpServer.js"
      ],
      "env": {
        "THE_NEW_YORK_TIMES_API_KEY": "<api key>"
      }
    }
  }
}
kameoncloudkameoncloud

やってみたけどダメだったんですよね、、、でも色々やってぐちゃぐちゃになってた気もするので明日もう一度やってみます!

kameoncloudkameoncloud

やってみたら無事どうさしました!やっぱり私が途中でいろいろやっていてテスト漏れのようです。ありがとうございました。