From 4f3f548b1b55d4c7477250fc3373b3bd6d0d397f Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sat, 12 Sep 2026 20:40:07 +0900 Subject: streamable-httpトランスポート対応を追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP SDK 2.xのMCPServer.run(transport="streamable-http")を使い、stdioに 加えてStreamable HTTPトランスポートで起動できるようにする。SSEはMCP仕様で 非推奨のため選択肢から除外し、transport enumはstdioとstreamable-httpの 2種類のみとした。 transportの選択はTOML [server] tableで指定し、CLI引数は追加しない。 仕様の「CLI上書きを行わない」「TOML集約」原則に準拠する。[server]省略時は stdio(後方互換)、streamable-http時はhost=127.0.0.1 port=8000を既定値とする。 変更内容: - config.py: ServerConfig dataclass、VALID_TRANSPORTS定数、AppConfig.server フィールド、load_configの[server]読込・validation追加 - cli.py: main()でconfig.server.transportに応じたrun()引数分岐 - tests/conftest.py: app_config fixtureにserver=ServerConfig()追加 - tests/test_config.py: [server]の正常系・異常系・境界値テスト追加 (省略時stdio既定値、streamable-http読込、sse/http/ws/unknown拒否、 port境界値0/65536/-1/文字列、host空文字拒否) - doc/config.md: [server]セクション追記、設定項目一覧表更新 - config.example.toml: [server]コメント例追記 - doc/Specifications.md: スコープ表にMCPトランスポート行追加 - doc/Design.md: 責務表と起動フロー更新 - doc/mcp.md: 概要のtransport記載更新 - README.md: streamable-http起動例追記 - script/smoke_streamable_http.py: streamable-http経由の検証スクリプト追加 検証: - ruff format/check、pyright 0 errors、pytest 50 passed - 実GPU・実モデルでstreamable-http経由generate_imageの全ルート確認 (HTTP接続→MCP initialize→tools/list→tools/call→PNG生成・返却) - 生成結果: 335KB PNG、512x512、20 steps、CFG 7.0 --- README.md | 12 +++++++ config.example.toml | 6 ++++ doc/Design.md | 4 +-- doc/Specifications.md | 1 + doc/config.md | 47 +++++++++++++++++++++++++++ doc/mcp.md | 4 +-- script/smoke_streamable_http.py | 64 ++++++++++++++++++++++++++++++++++++ src/mcp_sd15/cli.py | 10 +++++- src/mcp_sd15/config.py | 23 +++++++++++++ tests/conftest.py | 2 ++ tests/test_config.py | 72 +++++++++++++++++++++++++++++++++++++++++ 11 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 script/smoke_streamable_http.py diff --git a/README.md b/README.md index 1eab39c..46c3b0c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ 3. `uv sync` を実行します。PyTorchは専用indexからCUDA 13.2版が導入されます。 4. `uv run mcp-sd15 --config path/to/config.toml` で起動します。 +トランスポートはTOMLの`[server]`で切り替えます。省略時は`stdio`です。 +`streamable-http`を使う場合は次のように設定します。 + +```toml +[server] +transport = "streamable-http" +host = "127.0.0.1" +port = 8000 +``` + +この場合、MCPクライアントは`http://127.0.0.1:8000/mcp`へ接続します。 + モデルは `/checkpoints`、`vae`、`lora`、`controlnet` からだけ読み込みます。 VAE componentは生成に常に必要です。`model.vae`を省略した場合はcheckpoint内蔵VAEを使い、 指定した場合だけ外部VAEへ置き換えます。設定されたLoRAは起動時にすべてロードされ、3つの diff --git a/config.example.toml b/config.example.toml index 4226cee..ed8988a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -29,3 +29,9 @@ model = "control_v11p_sd15_inpaint.safetensors" [output] enabled = false directory = "./outputs" + +# [server] +# transport = "stdio" +# transport = "streamable-http" の場合は host と port も指定する +# host = "127.0.0.1" +# port = 8000 diff --git a/doc/Design.md b/doc/Design.md index 96eb505..ede063c 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -89,7 +89,7 @@ mcp-sd15/ | モジュール | 責務 | |---|---| -| `cli.py` | `--config`, `--version`の処理 | +| `cli.py` | `--config`, `--version`の処理、transport起動分岐 | | `server.py` | MCP server起動、Tool登録 | | `config.py` | TOML読込、型変換、validation | | `schema.py` | MCP入力値・内部request型 | @@ -117,7 +117,7 @@ mcp-sd15/ | 9 | LoRAを順次ロード | | 10 | GPUへ配置 | | 11 | MCP Toolを公開 | -| 12 | リクエスト待受開始 | +| 12 | `[server].transport`に従いstdioまたはstreamable-httpでリクエスト待受開始 | モデル構成の初期化に失敗した場合はToolを公開せず、プロセスを異常終了する。 diff --git a/doc/Specifications.md b/doc/Specifications.md index 1b98f96..db59c28 100644 --- a/doc/Specifications.md +++ b/doc/Specifications.md @@ -27,6 +27,7 @@ LLM側には生成意図に必要な最小限の入力のみを公開し、モ | バッチ生成 | 1枚固定 | | 同時生成 | 1ジョブのみ | | 出力形式 | PNG | +| MCPトランスポート | `stdio`、`streamable-http` | --- diff --git a/doc/config.md b/doc/config.md index 804fbf8..48142dc 100644 --- a/doc/config.md +++ b/doc/config.md @@ -56,6 +56,9 @@ uv run mcp-sd15 --config C:\path\to\config.toml | `controlnet.inpaint.model` | string | 有効時 | なし | | `output.enabled` | boolean | No | `false` | | `output.directory` | string | No | `./outputs` | +| `server.transport` | string enum | No | `stdio` | +| `server.host` | string | No | `127.0.0.1` | +| `server.port` | integer | No | `8000` | ## 4. `[model]` @@ -248,6 +251,50 @@ directory = "./outputs" | `enabled` | `true`で保存、`false`でMCP返却のみ。既定値`false` | | `directory` | 保存先。既定値`./outputs` | +## 9. `[server]` + +MCPトランスポートとHTTP待受アドレスを指定する。table自体を省略した場合は +`stdio`トランスポートになる。 + +```toml +[server] +transport = "stdio" +``` + +streamable-httpトランスポートを使う場合は次のように指定する。 + +```toml +[server] +transport = "streamable-http" +host = "127.0.0.1" +port = 8000 +``` + +### `transport` + +次の値だけを指定できる。 + +| 値 | 動作 | +|---|---| +| `stdio` | 標準入出力でMCPクライアントと通信する。既定値 | +| `streamable-http` | HTTPでMCPクライアントと通信する。`http://:/mcp`で接続する | + +`sse`はMCP仕様で非推奨のため選択できない。 + +### `host` + +- `transport`が`streamable-http`のときに使用する待受アドレス +- 既定値は`127.0.0.1`(localhostのみ) +- `0.0.0.0`を指定すると全インターフェースでリッスンする +- `transport`が`stdio`のときは使用されない + +### `port` + +- `transport`が`streamable-http`のときに使用する待受ポート +- `1`以上`65535`以下のinteger +- 既定値は`8000` +- `transport`が`stdio`のときは使用されない + 相対`directory`はconfigファイルのディレクトリを基準に解決する。保存が有効な場合、 ディレクトリは最初の保存時に作成され、ファイル名はランダムUUIDの`.png`となる。 diff --git a/doc/mcp.md b/doc/mcp.md index 0c50f75..32c644d 100644 --- a/doc/mcp.md +++ b/doc/mcp.md @@ -2,8 +2,8 @@ ## 1. 概要 -`mcp-sd15` はstdio transportで動作し、Stable Diffusion 1.xによる画像生成を行う -次の3ツールを公開する。 +`mcp-sd15` は`stdio`または`streamable-http`トランスポートで動作し、 +Stable Diffusion 1.xによる画像生成を行う次の3ツールを公開する。 | ツール | 用途 | |---|---| diff --git a/script/smoke_streamable_http.py b/script/smoke_streamable_http.py new file mode 100644 index 0000000..5c762a2 --- /dev/null +++ b/script/smoke_streamable_http.py @@ -0,0 +1,64 @@ +"""Verify generate_image over the Streamable HTTP transport with the real GPU backend. + +This script connects to a running ``mcp-sd15`` server that was started with +``transport = "streamable-http"`` and calls ``generate_image`` through the HTTP +endpoint. It exercises the full request path: HTTP connect → MCP initialize → +tools/call → PNG response. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +from pathlib import Path + +from mcp import Client +from mcp.types import ImageContent + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="http://127.0.0.1:8123/mcp") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--prompt", + default="a small red cube on a white table, studio photograph, detailed", + ) + parser.add_argument("--negative-prompt", default="blurry, low quality") + parser.add_argument("--size", default="square_512") + return parser.parse_args() + + +async def run(args: argparse.Namespace) -> None: + async with Client(args.url, raise_exceptions=True) as client: + tools = (await client.list_tools()).tools + tool_names = {tool.name for tool in tools} + if tool_names != {"generate_image", "generate_guided_image", "inpaint_image"}: + raise RuntimeError(f"unexpected tools: {tool_names}") + + result = await client.call_tool( + "generate_image", + { + "positive_prompt": args.prompt, + "negative_prompt": args.negative_prompt, + "size": args.size, + }, + ) + + if result.is_error or len(result.content) != 1: + raise RuntimeError(f"generate_image failed: {result}") + content = result.content[0] + if not isinstance(content, ImageContent) or content.mime_type != "image/png": + raise RuntimeError(f"generate_image returned unexpected content: {content}") + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(base64.b64decode(content.data, validate=True)) + print(f"generated={args.output.resolve()}") + + +def main() -> None: + asyncio.run(run(parse_args())) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/mcp_sd15/cli.py b/src/mcp_sd15/cli.py index 8bc1e20..3a3a9f7 100644 --- a/src/mcp_sd15/cli.py +++ b/src/mcp_sd15/cli.py @@ -37,7 +37,15 @@ def main(argv: Sequence[str] | None = None) -> int: ) backend = ModelManager(config) service = ImageGenerationService(config, backend) - create_server(service).run() + server = create_server(service) + if config.server.transport == "streamable-http": + server.run( + transport="streamable-http", + host=config.server.host, + port=config.server.port, + ) + else: + server.run() except SD15Error as exc: logging.getLogger(__name__).error("startup failed: %s", exc) return 2 diff --git a/src/mcp_sd15/config.py b/src/mcp_sd15/config.py index 1a5a71d..df2da34 100644 --- a/src/mcp_sd15/config.py +++ b/src/mcp_sd15/config.py @@ -12,6 +12,7 @@ from .errors import ConfigError VALID_SIZES = frozenset({"square_512", "landscape_512", "portrait_512"}) VALID_SCHEDULERS = frozenset({"dpmpp_2m_karras", "dpmpp_2m_sde_karras", "euler_a", "ddim", "lcm"}) +VALID_TRANSPORTS = frozenset({"stdio", "streamable-http"}) @dataclass(frozen=True) @@ -71,12 +72,20 @@ class OutputConfig: enabled: bool = False +@dataclass(frozen=True) +class ServerConfig: + transport: str = "stdio" + host: str = "127.0.0.1" + port: int = 8000 + + @dataclass(frozen=True) class AppConfig: model: ModelConfig generation: GenerationConfig controlnet: ControlNetConfig output: OutputConfig + server: ServerConfig = ServerConfig() def controlnet_path(self, mode: str) -> Path: item = self.controlnet.for_mode(mode) @@ -208,11 +217,25 @@ def load_config(path: str | Path, *, validate_paths: bool = True) -> AppConfig: if not output_dir.is_absolute(): output_dir = config_path.parent / output_dir + server_raw = _table(raw, "server") + transport = _string(server_raw, "transport") or "stdio" + if transport not in VALID_TRANSPORTS: + raise ConfigError(f"unsupported transport: {transport}") + host = _string(server_raw, "host") or "127.0.0.1" + port_value = server_raw.get("port", 8000) + if ( + isinstance(port_value, bool) + or not isinstance(port_value, int) + or not 1 <= port_value <= 65535 + ): + raise ConfigError("server.port must be an integer between 1 and 65535") + config = AppConfig( model=ModelConfig(root, checkpoint, vae, tuple(loras)), generation=GenerationConfig(steps_value, cfg_scale, scheduler, default_size), controlnet=controlnet, output=OutputConfig(output_dir.resolve(), _bool(output_raw, "enabled", False)), + server=ServerConfig(transport, host, port_value), ) if validate_paths: validate_model_paths(config) diff --git a/tests/conftest.py b/tests/conftest.py index d364dbd..862b4f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ from mcp_sd15.config import ( GenerationConfig, ModelConfig, OutputConfig, + ServerConfig, ) @@ -28,6 +29,7 @@ def app_config(tmp_path: Path) -> AppConfig: inpaint=ControlModelConfig(True, "inpaint.safetensors"), ), output=OutputConfig(tmp_path / "outputs", False), + server=ServerConfig(), ) diff --git a/tests/test_config.py b/tests/test_config.py index c24064b..9a6bf89 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -123,3 +123,75 @@ def test_can_skip_path_checks_for_schema_only_use(tmp_path: Path) -> None: config = load_config(path, validate_paths=False) assert config.model.root == (tmp_path / "missing").resolve() assert config.generation.scheduler == "dpmpp_2m_karras" + + +def test_server_defaults_to_stdio_when_omitted(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_text( + f'[model]\nroot = "{tmp_path.as_posix()}"\ncheckpoint = "base.safetensors"\n', + encoding="utf-8", + ) + config = load_config(path, validate_paths=False) + assert config.server.transport == "stdio" + assert config.server.host == "127.0.0.1" + assert config.server.port == 8000 + + +def test_server_reads_streamable_http(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_text( + f'[model]\nroot = "{tmp_path.as_posix()}"\ncheckpoint = "base.safetensors"\n' + '[server]\ntransport = "streamable-http"\nhost = "0.0.0.0"\nport = 9000\n', + encoding="utf-8", + ) + config = load_config(path, validate_paths=False) + assert config.server.transport == "streamable-http" + assert config.server.host == "0.0.0.0" + assert config.server.port == 9000 + + +@pytest.mark.parametrize( + "transport", + ["sse", "http", "ws", "unknown"], +) +def test_rejects_invalid_transport(tmp_path: Path, transport: str) -> None: + path = tmp_path / "config.toml" + path.write_text( + f'[model]\nroot = "{tmp_path.as_posix()}"\ncheckpoint = "base.safetensors"\n' + f'[server]\ntransport = "{transport}"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="transport"): + load_config(path, validate_paths=False) + + +@pytest.mark.parametrize( + "port, message", + [ + (0, "port"), + (65536, "port"), + (-1, "port"), + ("8000", "port"), + ], +) +def test_rejects_invalid_port(tmp_path: Path, port: str | int, message: str) -> None: + path = tmp_path / "config.toml" + port_literal = f'"{port}"' if isinstance(port, str) else str(port) + path.write_text( + f'[model]\nroot = "{tmp_path.as_posix()}"\ncheckpoint = "base.safetensors"\n' + f'[server]\ntransport = "streamable-http"\nport = {port_literal}\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match=message): + load_config(path, validate_paths=False) + + +def test_rejects_empty_host(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_text( + f'[model]\nroot = "{tmp_path.as_posix()}"\ncheckpoint = "base.safetensors"\n' + '[server]\ntransport = "streamable-http"\nhost = ""\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="host"): + load_config(path, validate_paths=False) -- cgit v1.2.3