本記事は、Model Context Protocol(MCP) サーバーをDockerイメージで作成して「チャットで学べるXRPチャート学習環境」を構築する手順と使い方をまとめたものです。
データは bitbank の公開APIから取得し、SMA20/50、RSI、MACD、ボリンジャーバンドを計算。
結果をSVGチャートとして自動保存します。
Dockerで起動するMCPクライアントを利用し、チャットに自然文などで「してほしい分析」と入力して学習を進めます。
分からない用語を、チャットで調べながらスムーズに学習を進められます。
⚠️ 免責: 本リポジトリは教育目的です。
特定の投資行動を勧めるものではありません。暗号資産は価格変動が大きく、元本割れのリスクがあります。
MCP サーバー 作成
- 作業ディレクトリを作成し移動する。
- ファイル(Dockerfile、package.json、server.js、mcp.json)を作成する。
- Dockerfile を使ってMCP サーバーイメージをビルドする
- GHCR(GitHub Container Registry)にpushする。
- LLMにmcp.jsonを登録し、LLMとDockerイメージを連携する。
- 今回の作業では、LM Studioにインストールしたopenai/gpt-oss-20bを使用。
(LM Studioとopenai/gpt-oss-20bのインストールは、インストールと準備を参照してください。) - Docker Desktop を起動する。
- LM Studio (gpt-oss 20b)へMCP サーバーを登録する。
ディレクトリ構成
bitbank_mcp # 作業ディレクトリ
├─ package.json
├─ Dockerfile
├─ mcp.json # LM Studio 連携
├─server.js # MCPサーバー本体
└─out # MCPサーバーがSVGファイル保存用に自動生成するディレクトリ。
package.json
Node.js設定ファイル
{
“name”: “mcp-xrp-analyzer”,
“version”: “0.1.0”,
“type”: “module”,
“dependencies”: {
“@modelcontextprotocol/sdk”: “^1.2.0”,
“node-fetch”: “^3.3.2”,
“technicalindicators”: “^3.1.0”
}
}
server.js
MCPサーバー本体
// server.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import fetch from "node-fetch";
import { z } from "zod";
import { RSI, SMA, MACD, BollingerBands } from "technicalindicators";
import fs from "node:fs";
import path from "node:path";
/** =========================
* ユーティリティ
* =========================*/
const BB_CANDLE_MAP = {
"1m": "1min",
"5m": "5min",
"15m": "15min",
"1h": "1hour",
"4h": "4hour",
"1d": "1day",
};
// JST (UTC+9) で yyyymmdd を作成(bitbankのcandlestickは日付単位)
function formatJstYmd(d) {
const tz = d.getTime() + 9 * 60 * 60 * 1000; // +9h
const j = new Date(tz);
const y = j.getUTCFullYear();
const m = String(j.getUTCMonth() + 1).padStart(2, "0");
const day = String(j.getUTCDate()).padStart(2, "0");
return `${y}${m}${day}`;
}
/** =========================
* bitbank: OHLCV取得
* /{pair}/candlestick/{candle}/{yyyymmdd}
* =========================*/
async function fetchBitbankCandlesDay({ pair = "xrp_jpy", candle = "1hour", yyyymmdd }) {
const url = `https://public.bitbank.cc/${pair}/candlestick/${candle}/${yyyymmdd}`;
const res = await fetch(url);
// 404 は「その日付のデータが無い」→ 空配列で返す
if (res.status === 404) return [];
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`bitbank fetch failed: ${res.status} ${body.slice(0, 120)}`);
}
const json = await res.json();
// success フラグが 1 でない場合も「データなし」とみなす
if (json?.success !== 1) return [];
const ohlcv = json?.data?.candlestick?.[0]?.ohlcv ?? [];
// 形式: ["O","H","L","C","V","UnixTime(ms)"]
return ohlcv.map(([o, h, l, c, v, t]) => ({
t: Number(t),
o: Number(o),
h: Number(h),
l: Number(l),
c: Number(c),
v: Number(v),
}));
}
// 直近limit本を過去日に遡って収集→重複排除→時系列整列
async function fetchKlinesBitbankRecent({ pair = "xrp_jpy", interval = "1h", limit = 200 }) {
const candle = BB_CANDLE_MAP[interval] || "1hour";
const out = [];
const seen = new Set();
let d = new Date();
// 最大400日分さかのぼって収集(十分なバッファ)
for (let guard = 0; guard < 400 && out.length < limit; guard++) {
const ymd = formatJstYmd(d);
let rows = [];
try {
rows = await fetchBitbankCandlesDay({ pair, candle, yyyymmdd: ymd });
} catch (e) {
// 一時的な障害等 → 100〜300ms で軽く再試行(最大2回)
for (let retry = 0; retry < 2; retry++) {
await new Promise((r) => setTimeout(r, 150 + retry * 100));
try {
rows = await fetchBitbankCandlesDay({ pair, candle, yyyymmdd: ymd });
if (rows.length) break;
} catch {
// noop
}
}
// まだダメでも致命ではない(この日をスキップ)
}
for (const r of rows) {
if (!seen.has(r.t)) {
seen.add(r.t);
out.push(r);
}
}
d.setDate(d.getDate() - 1);
}
out.sort((a, b) => a.t - b.t);
return out.slice(-limit);
}
/** =========================
* テクニカル指標
* =========================*/
function computeIndicators(closes) {
const rsi = RSI.calculate({ values: closes, period: 14 });
const sma20 = SMA.calculate({ values: closes, period: 20 });
const sma50 = SMA.calculate({ values: closes, period: 50 });
const macd = MACD.calculate({
values: closes,
fastPeriod: 12,
slowPeriod: 26,
signalPeriod: 9,
SimpleMAOscillator: false,
SimpleMASignal: false,
});
const bb = BollingerBands.calculate({ period: 20, values: closes, stdDev: 2 });
return { rsi, sma20, sma50, macd, bb };
}
function generateAdvice({ closes, indicators }) {
const latestClose = closes.at(-1);
const lastRSI = indicators.rsi.at(-1);
const lastMACD = indicators.macd.at(-1);
const lastBB = indicators.bb.at(-1);
const sma20 = indicators.sma20.at(-1);
const sma50 = indicators.sma50.at(-1);
const notes = [];
if (sma20 && sma50) {
notes.push(
sma20 > sma50
? "短期SMA(20)が長期SMA(50)を上回り、短期上昇バイアス。"
: "短期SMA(20)が長期SMA(50)を下回り、短期下落バイアス。"
);
}
if (lastMACD) {
notes.push(
lastMACD.MACD > lastMACD.signal
? "MACDがシグナルを上回り、モメンタムは強め。"
: "MACDがシグナルを下回り、モメンタムは弱め。"
);
}
if (lastRSI !== undefined) {
if (lastRSI >= 70) notes.push(`RSI=${lastRSI.toFixed(1)}(買われ過ぎ圏)。押し目待ち注意。`);
else if (lastRSI <= 30) notes.push(`RSI=${lastRSI.toFixed(1)}(売られ過ぎ圏)。反発余地に注目。`);
else notes.push(`RSI=${lastRSI.toFixed(1)}(中立)。`);
}
if (lastBB && (latestClose >= lastBB.upper || latestClose <= lastBB.lower)) {
notes.push("終値がボリンジャー±2σに接触。短期反転/伸びのボラ拡大リスク。");
}
const hints = [];
if (sma20 && sma50 && lastMACD) {
if (sma20 > sma50 && lastMACD.MACD > lastMACD.signal && (lastRSI ?? 50) < 70) {
hints.push("上昇トレンド継続シナリオ:押し目(SMA20近辺)での分割エントリを検討。");
}
if (sma20 < sma50 && lastMACD.MACD < lastMACD.signal && (lastRSI ?? 50) > 30) {
hints.push("下降トレンド継続シナリオ:戻り売り(SMA20近辺)やリスク縮小を検討。");
}
}
const disclaimer =
"※本出力は教育目的の市場分析であり、特定銘柄の推奨や投資助言ではありません。暗号資産は価格変動が大きく、元本割れのリスクがあります。";
return [
"対象: XRP/JPY (bitbank)",
`最新終値: ${latestClose}`,
...notes.map((n) => `・${n}`),
...(hints.length ? ["— 戦略ヒント —", ...hints.map((h) => `・${h}`)] : []),
"",
disclaimer,
].join("\n");
}
/** =========================
* SVG チャート描画
* =========================*/
function makeScale(min, max, outMin, outMax) {
const d = max - min || 1;
return (v) => outMin + (outMax - outMin) * (1 - (v - min) / d); // 高い値ほど上へ
}
function pathFromSeries(rows, series, offset, x0, x1, yScale) {
const N = rows.length;
const width = x1 - x0;
const step = N > 1 ? width / (N - 1) : 0;
let d = "";
for (let i = offset; i < N; i++) {
const v = series[i - offset];
if (v == null || Number.isNaN(v)) continue;
const x = x0 + step * i;
const y = yScale(v);
d += (d ? " L " : "M ") + x.toFixed(1) + " " + y.toFixed(1);
}
return d;
}
function renderCandlesSVG(rows, opts = {}) {
const {
width = 1200,
height = 600,
margin = { top: 16, right: 60, bottom: 28, left: 60 },
showSMA20 = true,
showSMA50 = true,
showBB = true,
theme = "dark", // 既定ダーク
} = opts;
const W = width,
H = height;
const plotX0 = margin.left,
plotX1 = W - margin.right;
const plotY0 = margin.top,
plotY1 = H - margin.bottom;
const plotW = plotX1 - plotX0,
plotH = plotY1 - plotY0;
if (!rows.length) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><rect width="${W}" height="${H}" fill="${
theme === "dark" ? "#0b0f14" : "#ffffff"
}"/><text x="${W / 2}" y="${H / 2}" text-anchor="middle" fill="${
theme === "dark" ? "#d6dee6" : "#111827"
}" font-family="ui-sans-serif,system-ui" font-size="16">No data</text></svg>`;
}
// データ範囲
const highs = rows.map((r) => r.h);
const lows = rows.map((r) => r.l);
const maxH = Math.max(...highs);
const minL = Math.min(...lows);
const pad = (maxH - minL || 1) * 0.05;
const yScale = makeScale(minL - pad, maxH + pad, plotY0, plotY1);
const N = rows.length;
const step = N > 1 ? plotW / (N - 1) : plotW;
const bodyW = Math.max(1, Math.min(18, step * 0.6));
// テーマ色
const C =
theme === "dark"
? {
bg: "#0b0f14",
grid: "#243447",
axis: "#9aa7b2",
bull: "#24c861",
bear: "#f05454",
sma20: "#2e86de",
sma50: "#f4c542",
bb: "#9aa7b2",
text: "#d6dee6",
}
: {
bg: "#ffffff",
grid: "#e9eef3",
axis: "#4b5563",
bull: "#16a34a",
bear: "#dc2626",
sma20: "#2563eb",
sma50: "#d97706",
bb: "#6b7280",
text: "#111827",
};
// 背景とグリッド
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<rect x="0" y="0" width="${W}" height="${H}" fill="${C.bg}"/>
<g stroke="${C.grid}" stroke-width="1" opacity="0.6">`;
const gridHCount = 5;
for (let i = 0; i <= gridHCount; i++) {
const y = plotY0 + (plotH * i) / gridHCount;
svg += `<line x1="${plotX0}" y1="${y.toFixed(1)}" x2="${plotX1}" y2="${y.toFixed(1)}"/>`;
}
svg += `</g>`;
// 価格軸(左)
svg += `<g font-family="ui-sans-serif,system-ui" font-size="12" fill="${C.axis}">`;
for (let i = 0; i <= gridHCount; i++) {
const y = plotY0 + (plotH * i) / gridHCount;
const val = (maxH + pad) - ((maxH - minL + 2 * pad) * i) / gridHCount;
svg += `<text x="${(plotX0 - 8).toFixed(1)}" y="${(y + 4).toFixed(1)}" text-anchor="end">${val.toFixed(4)}</text>`;
}
svg += `</g>`;
// 時刻ラベル(最初と最後のみ)
const t0 = new Date(rows[0]?.t || Date.now());
const t1 = new Date(rows[rows.length - 1]?.t || Date.now());
const fmt = (d) => {
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
const hh = d.getHours().toString().padStart(2, "0");
const mm = d.getMinutes().toString().padStart(2, "0");
return `${y}-${m}-${day} ${hh}:${mm}`;
};
svg += `<g font-family="ui-sans-serif,system-ui" font-size="12" fill="${C.axis}">
<text x="${plotX0}" y="${(plotY1 + 18).toFixed(1)}" text-anchor="start">${fmt(t0)}</text>
<text x="${plotX1}" y="${(plotY1 + 18).toFixed(1)}" text-anchor="end">${fmt(t1)}</text>
</g>`;
// ローソク
svg += `<g stroke-width="1">`;
for (let i = 0; i < N; i++) {
const r = rows[i];
const x = plotX0 + step * i;
const yH = yScale(r.h),
yL = yScale(r.l);
const yO = yScale(r.o),
yC = yScale(r.c);
const up = r.c >= r.o;
const color = up ? C.bull : C.bear;
// wick
svg += `<line x1="${x.toFixed(1)}" y1="${yH.toFixed(1)}" x2="${x.toFixed(1)}" y2="${yL.toFixed(1)}" stroke="${color}"/>`;
// body
const yTop = Math.min(yO, yC),
yBot = Math.max(yO, yC);
const hBody = Math.max(1, yBot - yTop);
svg += `<rect x="${(x - bodyW / 2).toFixed(1)}" y="${yTop.toFixed(1)}" width="${bodyW.toFixed(
1
)}" height="${hBody.toFixed(1)}" fill="${color}" opacity="0.8"/>`;
}
svg += `</g>`;
// 指標(SMA/BB)
const closes = rows.map((r) => r.c);
// SMA20/50
if (showSMA20 || showSMA50) {
const sma20 = showSMA20 ? SMA.calculate({ values: closes, period: 20 }) : null;
const sma50 = showSMA50 ? SMA.calculate({ values: closes, period: 50 }) : null;
if (sma20?.length) {
const path20 = pathFromSeries(rows, sma20, 20 - 1, plotX0, plotX1, yScale);
svg += `<path d="${path20}" fill="none" stroke="${C.sma20}" stroke-width="1.6"/>`;
}
if (sma50?.length) {
const path50 = pathFromSeries(rows, sma50, 50 - 1, plotX0, plotX1, yScale);
svg += `<path d="${path50}" fill="none" stroke="${C.sma50}" stroke-width="1.6"/>`;
}
}
// ボリンジャーバンド
if (showBB) {
const bb = BollingerBands.calculate({ period: 20, values: closes, stdDev: 2 }) || [];
const upp = bb.map((x) => x?.upper ?? null);
const mid = bb.map((x) => x?.middle ?? null);
const low = bb.map((x) => x?.lower ?? null);
if (upp.length) {
const dU = pathFromSeries(rows, upp, 20 - 1, plotX0, plotX1, yScale);
const dM = pathFromSeries(rows, mid, 20 - 1, plotX0, plotX1, yScale);
const dL = pathFromSeries(rows, low, 20 - 1, plotX0, plotX1, yScale);
svg += `<path d="${dU}" fill="none" stroke="${C.bb}" stroke-width="1" opacity="0.9"/>`;
svg += `<path d="${dM}" fill="none" stroke="${C.bb}" stroke-width="1" opacity="0.5" stroke-dasharray="4 4"/>`;
svg += `<path d="${dL}" fill="none" stroke="${C.bb}" stroke-width="1" opacity="0.9"/>`;
}
}
// 凡例
svg += `<g font-family="ui-sans-serif,system-ui" font-size="12" fill="${C.text}">
<rect x="${plotX0}" y="${plotY0 - 12}" width="8" height="8" fill="${C.bull}"/><text x="${
plotX0 + 12
}" y="${plotY0 - 4}">Bull</text>
<rect x="${plotX0 + 60}" y="${plotY0 - 12}" width="8" height="8" fill="${C.bear}"/><text x="${
plotX0 + 72
}" y="${plotY0 - 4}">Bear</text>
${
showSMA20
? `<rect x="${plotX0 + 120}" y="${plotY0 - 12}" width="8" height="8" fill="${C.sma20}"/><text x="${
plotX0 + 132
}" y="${plotY0 - 4}">SMA20</text>`
: ""
}
${
showSMA50
? `<rect x="${plotX0 + 190}" y="${plotY0 - 12}" width="8" height="8" fill="${C.sma50}"/><text x="${
plotX0 + 202
}" y="${plotY0 - 4}">SMA50</text>`
: ""
}
${
showBB
? `<rect x="${plotX0 + 260}" y="${plotY0 - 12}" width="8" height="8" fill="${C.bb}"/><text x="${
plotX0 + 272
}" y="${plotY0 - 4}">Bollinger ±2σ</text>`
: ""
}
</g>`;
svg += `</svg>`;
return svg;
}
/** =========================
* MCP Server(bitbank専用)
* =========================*/
const server = new McpServer({ name: "xrp-bitbank-mcp", version: "1.3.1" });
const intervalSchema = z.enum(["1m", "5m", "15m", "1h", "4h", "1d"]).default("1h");
const limitSchema = z.number().int().min(1).max(5000).default(200);
server.tool(
"xrp_fetch_ohlcv",
{ interval: intervalSchema, limit: limitSchema },
async ({ interval, limit }) => {
const rows = await fetchKlinesBitbankRecent({ pair: "xrp_jpy", interval, limit });
return {
content: [{ type: "text", text: JSON.stringify(rows) }],
structuredContent: rows,
};
}
);
server.tool(
"xrp_indicators",
{ interval: intervalSchema, limit: limitSchema },
async ({ interval, limit }) => {
const rows = await fetchKlinesBitbankRecent({ pair: "xrp_jpy", interval, limit });
const closes = rows.map((r) => r.c);
const ind = computeIndicators(closes);
const result = { closes, ...ind };
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
};
}
);
server.tool(
"xrp_advise",
{ interval: intervalSchema, limit: limitSchema },
async ({ interval, limit }) => {
const rows = await fetchKlinesBitbankRecent({ pair: "xrp_jpy", interval, limit });
const closes = rows.map((r) => r.c);
if (!closes.length) {
return { content: [{ type: "text", text: "データが取得できませんでした(対象期間にローソクが存在しない可能性)。" }] };
}
const indicators = computeIndicators(closes);
const text = generateAdvice({ closes, indicators });
return {
content: [{ type: "text", text }],
};
}
);
/** =========================
* SVGチャート返却(必ずファイル保存 → 短文のみ返す)
* =========================*/
server.tool(
"xrp_chart_svg",
{
interval: intervalSchema,
limit: z.number().int().min(50).max(1500).default(300),
width: z.number().int().min(400).max(2000).default(1200),
height: z.number().int().min(300).max(1200).default(600),
theme: z.enum(["light", "dark"]).default("dark"),
showSMA20: z.boolean().default(true),
showSMA50: z.boolean().default(true),
showBB: z.boolean().default(true),
},
async ({ interval, limit, width, height, theme, showSMA20, showSMA50, showBB }) => {
const rows = await fetchKlinesBitbankRecent({ pair: "xrp_jpy", interval, limit });
if (!rows.length) {
return {
content: [
{ type: "text", text: `SVG chart not created: データが取得できませんでした(interval=${interval}, n=${limit})。` },
],
structuredContent: { savedPath: null },
};
}
const svg = renderCandlesSVG(rows, { width, height, showSMA20, showSMA50, showBB, theme });
// 必ずファイルに保存(OUT_DIRが指定されていればそこへ、なければ /out)
let savedPath = null;
const outDir = process.env.OUT_DIR || "/out";
try {
await fs.promises.mkdir(outDir, { recursive: true });
const fname = `xrp_${interval}_${limit}_${Date.now()}.svg`;
const fpath = path.join(outDir, fname);
await fs.promises.writeFile(fpath, svg, "utf8");
savedPath = fpath;
} catch {
// 保存失敗時は savedPath=null のまま
}
// チャット本文には短文のみ返す(ループ回避)
return {
content: [
{
type: "text",
text: `SVG chart saved: ${savedPath ?? "(save failed)"} — interval=${interval}, n=${limit}, ${width}x${height}, theme=${theme}`,
},
],
structuredContent: { savedPath },
};
}
);
// stdio 接続
const transport = new StdioServerTransport();
await server.connect(transport);
Dockerfile
FROM node:20-alpine
WORKDIR /app
# 依存のみ先に入れてレイヤーキャッシュを効かせる
COPY package*.json ./
RUN npm install --omit=dev
# アプリ本体
COPY . .
# MCP server 起動
CMD ["node", "server.js"]
mcp.json
LM Studioが起動するMCPサーバーの起動方法が書かれた設定ファイル。
{
"mcpServers": {
"xrp-bitbank": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"OUT_DIR=/out",
"-v",
"C:\\\\Users\\\\作業ディレクトリパス\\\\bitbank_mcp\\\\out:/out",
"ghcr.io/omo-y/mcp-xrp-bitbank:1.3.0"
]
}
}
}
# 作業ディレクトリパスは自身の環境に合わせてください。JSONなので \\ でエスケープしています。
Docker ビルド
MCPサーバーイメージを作成。
docker build -t mcp-xrp-bitbank .
GHCR に公開
PAT 準備
- GitHub → Settings → Developer settings → Personal access tokensを取得
- Classic で
write:packages(+必要ならread:packages)にチェックを入れる。
ログイン & Push
# ログイン(PAT)
docker login ghcr.io -u <YOUR_GH_USER> -p <YOUR_GH_PAT>
# タグ付け
docker tag mcp-xrp-bitbank ghcr.io/<YOUR_GH_USER>/mcp-xrp-bitbank:1.3.0
# push
docker push ghcr.io/<YOUR_GH_USER>/mcp-xrp-bitbank:1.3.0
LM Studio から GHCR のイメージを使う
mcp.jsonを LM Studio に設定する。
設定方法は、LM studio gpt-oss 20bでの作業を参考にしてください。
MCPサーバー使用手順
LM Studioのチャット欄に以下のプロンプトを入力し、XRPチャートの見方を学びます。

チャート学習に使えるプロンプト
- xrpのチャート分析して結果をSVGで出力して
- XRPチャートの短期的な分析をして結果を教えて、さらにSVGも出力して
- xrpチャートのRSIを求めて相場の強弱または過熱感を調べて
関数名を利用
- xrpチャート分析xrp_chart_svg
チャートをSVG画像で出力します。 - xrpチャート分析xrp_advise
XRP/JPY (bitbank) の 1 時間足の分析(最新)を出力します。
まとめ
このMCPサーバーは、bitbankのXRP/JPYを対象に、OHLCV取得 → 指標計算 → SVG描画まで行います。
LM Studioにインストールしたopenai/gpt-oss-20bのチャット欄に、プロンプトを入力することで、SMA20/50・RSI・MACD・BBを独学できます。
不明な点があれば、チャットに問い合わせてすぐに解決できるので、自分のペースでスムーズに学習を勧められます。
⚠️ 免責: 本記事・コードは学習目的です。
特定の投資行動を勧めるものではありません。暗号資産は価格変動が大きく、元本割れのリスクがあります。

