Skip to content

Commit a79bd00

Browse files
kitepon-rgbclaude
andcommitted
feat(image-hub): tools/call response intercept で生成物を /files/<id> URL に rewrite
openai-image MCP のような「コンテナ内 tmp に保存しパスを返す」上流に対して、 proxy 層で SSE response を buffer parse → ファイルを storage に格上げ → text フィールドの絶対パスを公開 URL に置換する仕組みを追加。 これで Bell (別コンテナ) が openai-image-mcp の tmp ファイルにアクセスできない 問題が解消し、bell_attach_for_reply({ url }) で Discord に貼れるようになった。 仕組み: - compose.yml: openai-image-tmp named volume を openai-image-mcp (rw, TMPDIR) と image-hub-app (ro, 同 path) で共有 - openai-image-mcp/sitecustomize.py: Python tempfile.mkdtemp が固定 0o700 で 作る dir mode を 0o755 に補正 (image-hub-app uid 1000 から ro 参照するため) - image-hub-app/src/intercept.ts: SSE buffer parse + path 検出 + sha256 prefix12 で artifact 化 + path → /files/<id>.<ext> URL に rewrite。tools/call response のみ intercept、tools/list 等 discovery は素通し。MCP image content (base64) は不変 (Bell が画像を見て会話できる必要があるため) - image-hub-app/src/index.ts: /files/<id> を bearer から mcpAuth に変更し、 静的 bearer (Bell など長寿トークン消費者) からも取得可能に mermaid・excalidraw 用の rewrite rule は REWRITE_RULES に追加するだけで拡張可能。 Phase 9 後追い (2026-05-04)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 580d6c4 commit a79bd00

5 files changed

Lines changed: 276 additions & 5 deletions

File tree

server/compose.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ services:
3131
volumes:
3232
- ./storage:/var/lib/image-hub/storage
3333
- image-hub-data:/var/lib/image-hub
34+
# openai-image-mcp が tmp に書いた生成物を proxy intercept で /files/<id> に
35+
# 昇格するため ro マウント (path は両 service で同一)。
36+
- openai-image-tmp:/var/lib/openai-image-tmp:ro
3437
ports:
3538
# LAN 限定 listen (Caddy が同ホストから内部ネットワーク経由で叩ける)
3639
- "192.168.1.2:18810:18810"
@@ -55,6 +58,11 @@ services:
5558
- .env
5659
environment:
5760
MCP_PROXY_PORT: 8001
61+
# 生成物の出力先 (Python tempfile.mkdtemp が TMPDIR を見る)。
62+
# 同名 volume を image-hub-app にも ro マウントし proxy が intercept する。
63+
TMPDIR: /var/lib/openai-image-tmp
64+
volumes:
65+
- openai-image-tmp:/var/lib/openai-image-tmp
5866
# mcp-proxy の /sse は閉じない long-lived stream なので、GET だと wget が必ず timeout する。
5967
# TCP 開通だけ確認する (Python は image に既存)。
6068
healthcheck:
@@ -108,6 +116,9 @@ services:
108116
volumes:
109117
image-hub-data:
110118
driver: local
119+
# openai-image-mcp の Python tempfile 出力先 (TMPDIR)。image-hub-app から ro 参照される。
120+
openai-image-tmp:
121+
driver: local
111122

112123
networks:
113124
image-hub-net:

server/image-hub-app/src/index.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/
1818
import { loadConfig } from './config.js';
1919
import { openStorage } from './storage.js';
2020
import { openAuthSubsystem } from './auth.js';
21+
import { maybeRewriteSseBody, shouldIntercept } from './intercept.js';
2122

2223
const config = loadConfig();
2324
const storage = openStorage(config.dbPath);
@@ -120,8 +121,10 @@ const mcpAuth: express.RequestHandler = (req, res, next) => {
120121
bearer(req, res, next);
121122
};
122123

123-
// --- /files/{id} 配信 (Bearer 必須) ---
124-
app.get(`/files/:id`, bearer, async (req: Request, res: Response) => {
124+
// --- /files/{id} 配信 (静的 bearer または OAuth bearer) ---
125+
// /mcp/ と同じ mcpAuth を流用 (Bell など長寿トークン消費者からの取得を許可)。
126+
// GET-only handler で req.body は undefined なので discovery method 例外は発火しない。
127+
app.get(`/files/:id`, mcpAuth, async (req: Request, res: Response) => {
125128
const idParam = req.params.id;
126129
const id = typeof idParam === 'string' ? idParam : '';
127130
// basename チェック (path traversal 防止)
@@ -181,11 +184,27 @@ for (const [name, upstream] of Object.entries(config.mcpUpstreams)) {
181184
if (HOP_BY_HOP.has(key.toLowerCase())) return;
182185
res.setHeader(key, value);
183186
});
184-
if (upstreamRes.body !== null) {
185-
Readable.fromWeb(upstreamRes.body as never).pipe(res);
186-
} else {
187+
if (upstreamRes.body === null) {
187188
res.end();
189+
return;
188190
}
191+
// 対象 MCP の tools/call 応答だけ intercept する。それ以外 (tools/list 等
192+
// discovery, ping, notifications) は従来通り stream pipe で素通し。
193+
if (shouldIntercept(name, req.body)) {
194+
const ab = await upstreamRes.arrayBuffer();
195+
const original = Buffer.from(ab);
196+
const rewritten = maybeRewriteSseBody(original, name, {
197+
storage,
198+
storageDir: config.storageDir,
199+
publicFilesUrlBase: `${config.publicMcpUrl.origin}/files`,
200+
log: (m) => console.log('[image-hub]', m),
201+
});
202+
// body 長が変わるので Content-Length を再計算させる。
203+
res.removeHeader('Content-Length');
204+
res.end(rewritten);
205+
return;
206+
}
207+
Readable.fromWeb(upstreamRes.body as never).pipe(res);
189208
} catch (err) {
190209
const message = err instanceof Error ? err.message : String(err);
191210
console.error(`[image-hub] proxy error for ${name}:`, message);
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// intercept.ts — proxy response post-processing for image-generating MCPs.
2+
//
3+
// 上流 MCP が tool result の text content に「コンテナ内の絶対パス」を返してくる
4+
// ケースを救う層。/var/lib/openai-image-tmp 配下を image-hub-app から ro マウントで
5+
// 見えるようにしておき、proxy が tools/call レスポンスを buffer → SSE parse →
6+
// 該当 path を検出 → SHA256 prefix12 で artifact 化 → text を /files/<id> URL に
7+
// rewrite して再 emit する。
8+
//
9+
// 設計判断:
10+
// - 対象は `tools/call` レスポンスのみ (tools/list 等の discovery は素通り)
11+
// - 上流 MCP の `Image` content block (base64) は触らない (Bell が画像を見て
12+
// 会話できる必要があるため、token cost は受容)
13+
// - file id = SHA256(content) prefix 12 文字 (collision 確率実用ゼロ、
14+
// idempotent: 同じ画像なら ON CONFLICT で artifact upsert)
15+
// - file が見つからない / size が cap を超える / それ以外の I/O エラーは
16+
// silent ではなく log + 元の path を残す (fallback 禁止ルールに準拠)
17+
//
18+
// 拡張: REWRITE_RULES に upstream 名 → RewriteRule を追加すれば mermaid /
19+
// excalidraw も同じ仕組みに乗る (それぞれの path pattern を確認してから)。
20+
21+
import { createHash } from 'node:crypto';
22+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
23+
import { extname, join } from 'node:path';
24+
import type { Storage } from './storage.js';
25+
26+
export interface RewriteRule {
27+
pathPattern: RegExp;
28+
mimeByExt: Record<string, string>;
29+
maxFileBytes: number;
30+
project: string;
31+
}
32+
33+
const OPENAI_IMAGE_RULE: RewriteRule = {
34+
// Python tempfile.mkdtemp(prefix="openai_gen_image_") が TMPDIR=/var/lib/openai-image-tmp
35+
// 配下に作る親ディレクトリ。子ファイル名は generated_xxx.{png,jpg,jpeg,webp}
36+
// (kazyam53/openai_gen_image_mcp の output_format 仕様に対応)。
37+
pathPattern: /\/var\/lib\/openai-image-tmp\/openai_gen_image_[A-Za-z0-9_-]+\/[A-Za-z0-9._-]+\.(?:png|jpg|jpeg|webp)/g,
38+
mimeByExt: {
39+
'.png': 'image/png',
40+
'.jpg': 'image/jpeg',
41+
'.jpeg': 'image/jpeg',
42+
'.webp': 'image/webp',
43+
},
44+
maxFileBytes: 32 * 1024 * 1024,
45+
project: 'openai-image',
46+
};
47+
48+
export const REWRITE_RULES: Readonly<Record<string, RewriteRule>> = Object.freeze({
49+
'openai-image': OPENAI_IMAGE_RULE,
50+
});
51+
52+
export interface InterceptDeps {
53+
storage: Storage;
54+
storageDir: string;
55+
publicFilesUrlBase: string;
56+
log: (msg: string) => void;
57+
}
58+
59+
interface SseEvent {
60+
event: string;
61+
id: string;
62+
data: string;
63+
}
64+
65+
function splitSseEvents(text: string): string[] {
66+
return text.split(/\r?\n\r?\n/);
67+
}
68+
69+
function parseSseEvent(block: string): SseEvent | null {
70+
const lines = block.split(/\r?\n/);
71+
let event = 'message';
72+
let id = '';
73+
const dataLines: string[] = [];
74+
for (const line of lines) {
75+
if (line.startsWith(':')) continue; // SSE comment
76+
if (line.startsWith('event:')) event = line.slice(6).trim();
77+
else if (line.startsWith('id:')) id = line.slice(3).trim();
78+
else if (line.startsWith('data:')) {
79+
const v = line.slice(5);
80+
dataLines.push(v.startsWith(' ') ? v.slice(1) : v);
81+
}
82+
}
83+
if (dataLines.length === 0) return null;
84+
return { event, id, data: dataLines.join('\n') };
85+
}
86+
87+
function reEncodeSseEvent(ev: SseEvent): string {
88+
const lines: string[] = [];
89+
if (ev.event && ev.event !== 'message') lines.push(`event: ${ev.event}`);
90+
if (ev.id) lines.push(`id: ${ev.id}`);
91+
for (const dl of ev.data.split(/\r?\n/)) {
92+
lines.push(`data: ${dl}`);
93+
}
94+
return lines.join('\n') + '\n\n';
95+
}
96+
97+
interface ToolCallEnvelope {
98+
jsonrpc?: string;
99+
id?: number | string;
100+
result?: {
101+
content?: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
102+
[k: string]: unknown;
103+
};
104+
error?: unknown;
105+
[k: string]: unknown;
106+
}
107+
108+
function rewriteText(text: string, rule: RewriteRule, deps: InterceptDeps): { text: string; rewrites: number } {
109+
let rewrites = 0;
110+
const newText = text.replace(rule.pathPattern, (match) => {
111+
try {
112+
const st = statSync(match);
113+
if (!st.isFile()) {
114+
deps.log(`intercept: not a file, skip: ${match}`);
115+
return match;
116+
}
117+
if (st.size > rule.maxFileBytes) {
118+
deps.log(`intercept: file too large (${st.size} > ${rule.maxFileBytes}), skip: ${match}`);
119+
return match;
120+
}
121+
const buf = readFileSync(match);
122+
const sha = createHash('sha256').update(buf).digest('hex');
123+
const id = sha.slice(0, 12);
124+
const ext = extname(match).toLowerCase();
125+
const mime = rule.mimeByExt[ext] ?? 'application/octet-stream';
126+
const fileName = `${id}${ext}`;
127+
const destPath = join(deps.storageDir, fileName);
128+
if (!existsSync(destPath)) {
129+
mkdirSync(deps.storageDir, { recursive: true });
130+
copyFileSync(match, destPath);
131+
}
132+
deps.storage.putArtifact({ id, mime, project: rule.project });
133+
rewrites += 1;
134+
const url = `${deps.publicFilesUrlBase.replace(/\/$/, '')}/${fileName}`;
135+
return url;
136+
} catch (e) {
137+
deps.log(`intercept: rewrite skip for ${match}: ${e instanceof Error ? e.message : String(e)}`);
138+
return match;
139+
}
140+
});
141+
return { text: newText, rewrites };
142+
}
143+
144+
export function maybeRewriteSseBody(body: Buffer, ruleName: string, deps: InterceptDeps): Buffer {
145+
const rule = REWRITE_RULES[ruleName];
146+
if (rule === undefined) return body;
147+
148+
const text = body.toString('utf8');
149+
const blocks = splitSseEvents(text);
150+
151+
let totalRewrites = 0;
152+
const out: string[] = [];
153+
for (let i = 0; i < blocks.length; i += 1) {
154+
const block = blocks[i];
155+
if (block === undefined || block.length === 0) {
156+
// SSE 末尾の空 chunk (input が "...\n\n" で終わっていた場合)。
157+
// 区切りは reEncodeSseEvent が "\n\n" を付けるので、ここでは何も足さない。
158+
continue;
159+
}
160+
const ev = parseSseEvent(block);
161+
if (ev === null) {
162+
out.push(block + '\n\n');
163+
continue;
164+
}
165+
let parsed: ToolCallEnvelope | null = null;
166+
try {
167+
parsed = JSON.parse(ev.data) as ToolCallEnvelope;
168+
} catch {
169+
out.push(reEncodeSseEvent(ev));
170+
continue;
171+
}
172+
const content = parsed.result?.content;
173+
if (!Array.isArray(content)) {
174+
out.push(reEncodeSseEvent(ev));
175+
continue;
176+
}
177+
let touched = false;
178+
for (const item of content) {
179+
if (item.type === 'text' && typeof item.text === 'string') {
180+
const r = rewriteText(item.text, rule, deps);
181+
if (r.rewrites > 0) {
182+
item.text = r.text;
183+
touched = true;
184+
totalRewrites += r.rewrites;
185+
}
186+
}
187+
}
188+
if (touched) {
189+
out.push(reEncodeSseEvent({ ...ev, data: JSON.stringify(parsed) }));
190+
} else {
191+
out.push(reEncodeSseEvent(ev));
192+
}
193+
}
194+
195+
if (totalRewrites === 0) return body;
196+
deps.log(`intercept[${ruleName}]: rewrote ${totalRewrites} path(s) to /files URL`);
197+
return Buffer.from(out.join(''), 'utf8');
198+
}
199+
200+
export function shouldIntercept(name: string, reqBody: unknown): boolean {
201+
if (!(name in REWRITE_RULES)) return false;
202+
if (typeof reqBody !== 'object' || reqBody === null) return false;
203+
return (reqBody as { method?: unknown }).method === 'tools/call';
204+
}

server/openai-image-mcp/Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
1414
RUN uv tool install git+https://github.com/kazyam53/openai_gen_image_mcp.git
1515
RUN npm install -g mcp-proxy
1616

17+
# sitecustomize.py で tempfile.mkdtemp の mode を 0o755 に補正
18+
# (image-hub-app コンテナ uid 1000 から ro 参照するため、root 0o700 だと EACCES)
19+
COPY sitecustomize.py /opt/sitecustomize/sitecustomize.py
20+
ENV PYTHONPATH=/opt/sitecustomize
21+
1722
ENV MCP_PROXY_PORT=8001
1823
EXPOSE 8001
1924
CMD ["sh", "-c", "mcp-proxy --port ${MCP_PROXY_PORT} --host 0.0.0.0 -- /root/.local/bin/openai-gen-image-mcp"]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# sitecustomize.py — Python 起動時に自動 import される hook。
2+
#
3+
# 目的: image-hub-app (uid 1000 node) が openai-image-mcp (uid 0 root) の
4+
# tempfile.mkdtemp 出力ディレクトリを読めるよう、mkdtemp が固定で 0o700 で
5+
# 作るのを 0o755 にチmod する monkey-patch をかける。
6+
# (mkdtemp の mode 引数は廃止されており Python レベルで 0o700 固定のため)。
7+
#
8+
# ファイル本体は Python が open() で書き込む際 umask 022 → 0644 になるので
9+
# そのまま image-hub-app から read 可能。
10+
#
11+
# 副作用: 同じプロセス内の他の mkdtemp 呼出も world-readable 化する。
12+
# このコンテナは openai-image MCP 専用 (uvx tool でラップされた単一プロセス) で
13+
# 機密性のある一時ディレクトリは作らない想定なので許容。
14+
15+
import tempfile as _tempfile
16+
import os as _os
17+
18+
_orig_mkdtemp = _tempfile.mkdtemp
19+
20+
21+
def _mkdtemp_world_readable(*args, **kwargs):
22+
path = _orig_mkdtemp(*args, **kwargs)
23+
try:
24+
_os.chmod(path, 0o755)
25+
except OSError:
26+
# FALLBACK-ALLOWED: 失敗しても元の動作 (0o700) で続行する方が安全
27+
# (例外条件 3: 最終救命的な hook で他に書く場所がない)
28+
pass
29+
return path
30+
31+
32+
_tempfile.mkdtemp = _mkdtemp_world_readable

0 commit comments

Comments
 (0)