Hermes Agent では Alpaca / OANDA との通信を MCP(Model Context Protocol)Server として独立させています。Claude API の Tool Use とは少し異なるアーキテクチャで、なぜこうしたのか、どう実装したかを解説します。
MCP とは
MCP(Model Context Protocol)は Anthropic が提唱するオープンな標準で、LLM アプリケーションと外部ツール・データソースを疎結合でつなぐプロトコルです。
Claude API ←→ MCP Client ←→ MCP Server ←→ 外部サービス
(Alpaca, OANDA, etc.)
@modelcontextprotocol/sdk で Client / Server の両方を TypeScript で実装できます。
なぜ Tool Use ではなく MCP にしたのか
Claude API には Tool Use 機能があり、関数定義を渡してモデルに呼ばせることができます。当初はこれを使っていましたが、MCP に移行した理由があります。
| Claude Tool Use | MCP Server | |
|---|---|---|
| 通信 | API コール内でインライン | 独立プロセス(stdio) |
| ライフサイクル | リクエスト単位 | 常駐(WebSocket接続を維持) |
| WebSocket接続 | 都度接続 | 起動時に1回接続して維持 |
| 再利用性 | Claude 専用 | どのクライアントからでも使える |
Alpaca の WebSocket 接続(リアルタイム価格キャッシュ)は常駐が必要なため、MCP Server として独立させました。
実装構成
src/mcp/
├── index.ts # MCP Server エントリーポイント
├── providers/
│ ├── alpaca.ts # Alpaca REST + WebSocket
│ └── oanda.ts # OANDA REST + SSE streaming
├── tools/
│ ├── price-tools.ts # get_realtime_price, get_historical_bars
│ ├── order-tools.ts # place_order
│ ├── account-tools.ts # get_account_info
│ └── calendar-tools.ts # get_economic_calendar
└── ws-manager.ts # シンボルルーティング
MCP Server の実装
// src/mcp/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "hermes-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
// ツール一覧を返す
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
...priceToolDefs,
...orderToolDefs,
...accountToolDefs,
...calendarToolDefs,
],
}));
// ツール呼び出しを処理
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args } = req.params;
if (name.startsWith("get_") && name.includes("price") || name.includes("bars")) {
return handlePriceTool(name, args);
}
if (name === "place_order") {
return handleOrderTool(name, args);
}
if (name === "get_account_info") {
return handleAccountTool(name);
}
if (name === "get_economic_calendar") {
return handleCalendarTool(name);
}
throw new Error(`Unknown tool: ${name}`);
});
// stdio で通信(サブプロセスとして起動)
const transport = new StdioServerTransport();
await server.connect(transport);
MCP Client(エージェント側)の実装
// src/agent/hermes.ts(抜粋)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
async connect() {
// MCP Server をサブプロセスとして起動
const transport = new StdioClientTransport({
command: "node",
args: ["--import", "tsx/esm", this.cfg.mcpServerPath],
env: { ...process.env },
});
this.mcp = new Client(
{ name: "hermes-agent", version: "0.11.0" },
{ capabilities: {} },
);
await this.mcp.connect(transport);
// 利用可能なツール一覧を取得
const { tools } = await this.mcp.listTools();
this.mcpTools = tools.map(t => ({
name: t.name,
description: t.description ?? "",
input_schema: t.inputSchema,
}));
}
エージェントが起動するとき、MCP Server が サブプロセス として立ち上がります。親子プロセスは stdio で JSON-RPC 通信します。
ツールの実装例:place_order
// src/mcp/tools/order-tools.ts(抜粋)
export async function handleOrderTool(
name: string,
args: Record<string, unknown>,
): Promise<ToolResponse> {
const { symbol, direction, quantity, stop_loss, take_profit } = args;
const mode = process.env.TRADING_MODE ?? "analyze";
// ドライランモード
if (mode === "analyze") {
return ok({ dry_run: true, status: "simulated", message: "Dry run — no order sent" });
}
// シンボルの種類でブローカーを振り分け
if ((symbol as string).includes("_")) {
// FX → OANDA
return placeOandaOrder(symbol, direction, quantity, stop_loss, take_profit);
} else {
// 株・暗号資産 → Alpaca
return placeAlpacaOrder(symbol, direction, quantity, stop_loss, take_profit);
}
}
symbol の形式(_ 含む → FX、/ 含む → 暗号資産、それ以外 → 株)でブローカーを自動判定しています。
WebSocket 接続の維持
MCP Server が常駐することで、Alpaca の WebSocket 接続を起動時に1回だけ張って維持できます。
// src/mcp/ws-manager.ts(概略)
export class WsManager {
private readonly alpaca: AlpacaProvider;
constructor() {
this.alpaca = new AlpacaProvider(apiKey, apiSecret, isPaper);
// 株式・暗号資産の WebSocket を起動時に接続
this.alpaca.connect(stockSymbols);
this.alpaca.connectCrypto(cryptoSymbols);
}
// get_realtime_price はキャッシュから返す(接続オーバーヘッドなし)
getPrice(symbol: string): PriceTick | null {
return this.alpaca.getCached(symbol);
}
}
毎回 REST API を叩くより WebSocket キャッシュから返す方が圧倒的に高速です。
ツールのタイムアウト制御
MCP ツール呼び出しに 30 秒のタイムアウトを設けています。Alpaca や Finnhub のレスポンスが遅い場合でもサイクル全体がハングするのを防ぐためです。
private async callToolSafe(name: string, args: Record<string, unknown>, timeoutMs = 30_000) {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`MCP '${name}' timed out after ${timeoutMs / 1000}s`)), timeoutMs)
);
return Promise.race([this.mcp.callTool({ name, arguments: args }), timeout]);
}
まとめ
| ポイント | 内容 |
|---|---|
| 常駐プロセスとして分離 | WebSocket 接続をサイクルをまたいで維持できる |
| stdio 通信 | シンプル・デバッグしやすい |
| シンボル形式でブローカーを自動判定 |
_→OANDA、/→Alpaca crypto、それ以外→Alpaca stocks |
| ツールタイムアウト | 外部API 遅延でサイクル全体がハングするのを防止 |
MCP は「LLM ↔ 外部ツール」の接続方法として標準化が進んでいます。自動売買以外のユースケースでも、常駐型のツールサーバーが必要な場合に有効です。