summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md12
-rw-r--r--config.example.toml6
-rw-r--r--doc/Design.md4
-rw-r--r--doc/Specifications.md1
-rw-r--r--doc/config.md47
-rw-r--r--doc/mcp.md4
-rw-r--r--script/smoke_streamable_http.py64
-rw-r--r--src/mcp_sd15/cli.py10
-rw-r--r--src/mcp_sd15/config.py23
-rw-r--r--tests/conftest.py2
-rw-r--r--tests/test_config.py72
11 files changed, 240 insertions, 5 deletions
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`へ接続します。
+
モデルは `<root>/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://<host>:<port>/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の`<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)
@@ -72,11 +73,19 @@ class OutputConfig:
@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)