2025年4月時点 Node-RED の HTTP request ノードでローカルのステートレス MCP サーバーと連携するメモ

2025年4月時点 Node-RED の HTTP request ノードでローカルのステートレス MCP サーバーと連携するメモ

2025年4月時点 Node-RED の HTTP request ノードでローカルのステートレス MCP サーバーと連携するメモです。

背景

あくまで2025年4月時点の仕様での試してみたお話です。ここから仕様が変わったりより良いアプローチが見つかるかもしれません。

MCP サーバーも stdio 方式が慣れてきたので HTTP ストリーミング方式にも足を延ばしつつあります。いろいろのたうち回りながらですが、ひとまず、StreamableHTTPServerTransport クラスを TypeScrip で使いつつも、まずはシンプルにステートレスな HTTP サーバーで Node-RED の HTTP request ノードとやりとりできたました。

セッションがあり会話を記録できる本来のステートフルの実装については Node-RED の HTTP request ノードではストリーム的なものが標準ではできないので text/event-stream とやりとり可能なライブラリが必要っぽいので、これはステートレスが落ち着いたら深く調べてみる予定です。

ChatGPT と並走していましたが、やはり最新めの技術にいくら文献読み込みを保持しても、一緒にのたうち回る感じになってしまいましたが、考えをすっきりさせるときに azukiazusa さんの MCP サーバーの Streamable HTTP transport を試してみる にとても助けになりました。ありがとうございます。

MCP ステートレスサーバーをつくる

こちらは、あえて Node-RED でつくりません。TypeScript SDK があるので、作れなくはなさそうですけど、今回はあくまで別のところにある MCP サーバーへアクセスするかという観点で進めます。

モジュールのインストール

modelcontextprotocol/typescript-sdk: The official Typescript SDK for Model Context Protocol servers and clients

こちらをもとに、

npm install @modelcontextprotocol/sdk zod express

npm install -D typescript ts-node @types/node @types/express

を実行してモジュールの準備をします。

package.json に "type": "module" 加える

{
  "name": "red-mcp-test",
  "version": "1.0.0",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.11.0",
    "express": "^5.1.0",
    "zod": "^3.24.3"
  },
  "devDependencies": {
    "@types/express": "^5.0.1",
    "@types/node": "^22.15.3",
    "ts-node": "^10.9.2",
    "typescript": "^5.8.3"
  }
}

package.json はこんな構成です。

"type": "module" を加えます。

server.ts 用意

こちらの記事の Without Session Management (Stateless) のソースを元に、足し算のツールを加えたものを server.ts で用意します。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import express from "express";
 
const app = express();
app.use(express.json());

const server = new McpServer({
  name: "example-server",
  version: "1.0.0"
});

// 足し算ツール
server.tool(
  "add",
  "足し算ツール",
  { a: z.number(), b: z.number() },
  async ({ a, b }) => ({
    content: [{ type: "text", text: String(a + b) }]
  })
);

app.post('/mcp', async (req, res) => {
  // 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,
    });
    res.on('close', () => {
      console.log('Request closed');
      transport.close();
      server.close();
    });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  } 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, res) => {
  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, res) => {
  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}`);
});

tsconfig.json

tsconfig.json も準備して、この後、TypeScript を動かす設定もしておきます。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "types": ["node"],
    "strict": true
  },
  "include": ["**/*.ts"]
}

server.ts 実行

こちらを

node --loader ts-node/esm server.ts

で TypeScript 実行します。

MCP Stateless Streamable HTTP Server listening on port 3000

と出て、 localhost:3000/mcp のエンドポイントからアクセスできます。

REST Client

curl っぽくアクセスできる VSCode の便利ツール REST Client でアクセスしてみます。

### ツール一覧
POST http://localhost:3000/mcp
Content-Type: application/json
Accept: application/json,text/event-stream

{"jsonrpc":"2.0","method":"tools/list","params":{},"id":2}

でツール一覧が取得でき、

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Date: Sat, 03 May 2025 06:13:59 GMT
Transfer-Encoding: chunked

event: message
data: {"result":{"tools":[{"name":"add","description":"足し算ツール","inputSchema":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}]},"jsonrpc":"2.0","id":2}

というレスポンスが返ってきます。

### 足し算ツール add を 5+6 で実行
POST http://localhost:3000/mcp
Content-Type: application/json
Accept: application/json,text/event-stream

{"jsonrpc":"2.0","method":"tools/call","params":{"name":"add","arguments":{"a":5,"b":6}},"id":1}

で足し算ツール add を 5+6 で実行でき、

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Date: Sat, 03 May 2025 06:14:46 GMT
Transfer-Encoding: chunked

event: message
data: {"result":{"content":[{"type":"text","text":"11"}]},"jsonrpc":"2.0","id":1}

というレスポンスが返ってきます。JSON ではなく event: と data: という独自?フォーマットの中にデータがあるのが、ちょっと癖ですがなんとかなりそうです。

Node-RED からアクセスしてみる

これをもとにアクセスしてみます。

こんなフローです。

データの元となる change ノードの中身は、

たとえばツール一覧はこのようになっています。

http request ノードについては、

Accept ヘッダーを application/json,text/event-stream にしないと、

"{"jsonrpc":"2.0","error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}"

といったエラーが出るので対策してます。

「data: 抽出」を行う function ノードは、

となっていて、

// msg.payload に "event: message\ndata: {…}\n\n" が入っている想定
const result = msg.payload.toString();
// 改行で分割して "data: " から始まる行を見つける
let line = result.split(/\r?\n/);
line = line.find(l => l.startsWith("data: "));

if (line) {
  try {
    // "data: " を除いた部分を JSON.parse
    msg.payload = JSON.parse(line.slice(6));
    return msg;
  } catch (err) {
    node.error("JSON parse error", err);
  }
}

// data 行がなければ何も送らない
return null;

data: から JSON が取り出せるようにしています。

以下がフロー JSON です。

[{"id":"475f91cc6f4dd5d4","type":"http request","z":"27acd4cd6bc83a79","name":"http://localhost:3000/mcp","method":"POST","ret":"txt","paytoqs":"ignore","url":"http://localhost:3000/mcp","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[{"keyType":"other","keyValue":"Accept","valueType":"other","valueValue":"application/json,text/event-stream"}],"x":690,"y":380,"wires":[["19d1748aed0d8da8","db2755b7cd28527a"]]},{"id":"66278aa997210515","type":"debug","z":"27acd4cd6bc83a79","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1130,"y":380,"wires":[]},{"id":"693815d25407d174","type":"inject","z":"27acd4cd6bc83a79","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":220,"y":420,"wires":[["4c6da7a30bbfdf68"]]},{"id":"4c6da7a30bbfdf68","type":"change","z":"27acd4cd6bc83a79","name":"listTools","rules":[{"t":"set","p":"payload","pt":"msg","to":"{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"params\":{},\"id\":2}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":400,"y":420,"wires":[["475f91cc6f4dd5d4"]]},{"id":"dcf7c4e31058b4f2","type":"change","z":"27acd4cd6bc83a79","name":"tool add","rules":[{"t":"set","p":"payload","pt":"msg","to":"{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"a\":5,\"b\":6}},\"id\":1}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":400,"y":340,"wires":[["475f91cc6f4dd5d4"]]},{"id":"423b27511e49858c","type":"inject","z":"27acd4cd6bc83a79","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":220,"y":340,"wires":[["dcf7c4e31058b4f2"]]},{"id":"19d1748aed0d8da8","type":"function","z":"27acd4cd6bc83a79","name":"data: 抽出","func":"// msg.payload に \"event: message\\ndata: {…}\\n\\n\" が入っている想定\nconst result = msg.payload.toString();\n// 改行で分割して \"data: \" から始まる行を見つける\nlet line = result.split(/\\r?\\n/);\nline = line.find(l => l.startsWith(\"data: \"));\n\nif (line) {\n  try {\n    // \"data: \" を除いた部分を JSON.parse\n    msg.payload = JSON.parse(line.slice(6));\n    return msg;\n  } catch (err) {\n    node.error(\"JSON parse error\", err);\n  }\n}\n\n// data 行がなければ何も送らない\nreturn null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":920,"y":380,"wires":[["66278aa997210515"]]},{"id":"db2755b7cd28527a","type":"debug","z":"27acd4cd6bc83a79","name":"debug 3","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":920,"y":320,"wires":[]}]

動かしてみる

こちらの inject ノードをクリックして、足し算ツールを 5+6 で実行してみます。

このように MCP サーバーから結果が返ってきます!

ステートレスで一旦疎通できると MCP サーバーと MCP クライアントの挙動があっていることが分かり、あとは HTTP ストリーミング+セッションが絡むステートフルをつなげればよくなります。

ちょうどよい踊り場としての検証になりました。