From c35b99e357c05cefdc57190f4f01f22158b40c32 Mon Sep 17 00:00:00 2001 From: commdevin Date: Sat, 12 Sep 2026 11:18:38 +0000 Subject: [PATCH 1/2] feat: single-port serve-all mode (HTTP + MCP at /mcp) for PaaS deployments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 9 +++ README.md | 5 ++ deploy/fly.toml.example | 22 ++++++ docker/entrypoint.sh | 3 + docs/DEPLOYMENT.md | 18 +++++ main.py | 33 +++++++++ pyproject.toml | 1 + src/pip_protocol/cli.py | 16 +++++ src/pip_protocol/transport/combined.py | 23 +++++++ src/pip_protocol/transport/http.py | 9 ++- tests/test_combined.py | 92 ++++++++++++++++++++++++++ 12 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 deploy/fly.toml.example create mode 100644 main.py create mode 100644 src/pip_protocol/transport/combined.py create mode 100644 tests/test_combined.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f8c252..9aad62d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Ruff format run: .venv/bin/ruff format --check . - name: Mypy - run: .venv/bin/mypy src + run: .venv/bin/mypy src main.py - name: Pytest run: .venv/bin/pytest -q diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b63e2..c941269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.2.0 + +- Added `pip-node serve-all`: single-port mode serving the HTTP API plus MCP + at `/mcp` (`transport.combined.create_combined_app`), for PaaS deployments + with platform TLS termination (e.g. Fly.io — see `deploy/fly.toml.example` + and `main.py`, which honors `PORT`/`FLY_APP_NAME`). +- `create_app` accepts a `lifespan=` kwarg so mounted transports' lifespans + run. + ## 1.1.0 - Added `PIP_MCP_PORT` (default 8643) — MCP streamable-http now binds its own port. diff --git a/README.md b/README.md index 20debc1..12a8a9c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ PIP_PRIVATE_KEY_FILE=./data/instance.key PIP_POLICY_FILE=./config/policy.yaml \ pip-node serve-http # HTTP on 127.0.0.1:8642 pip-node serve-mcp --transport stdio # MCP over stdio pip-node serve-mcp --transport streamable-http # MCP over HTTP +pip-node serve-all # one port: HTTP + MCP at /mcp ``` Other CLI commands: `pip-node identity`, `pip-node sign`, `pip-node send` @@ -83,6 +84,10 @@ Resources: `pip://identity`, `pip://capabilities`, `pip://policy/scopes`. Prompts: `pip_compose_message(to, subject, intent)`, `pip_request_data(dataset, purpose)`. +`pip-node serve-all` runs both transports on `PIP_HTTP_PORT` (single-port/PaaS +mode — see `docs/DEPLOYMENT.md`); the MCP endpoint is then `/mcp` on the same +port as the HTTP API. + ## Configuration (§12) | Variable | Default | Notes | diff --git a/deploy/fly.toml.example b/deploy/fly.toml.example new file mode 100644 index 0000000..223f960 --- /dev/null +++ b/deploy/fly.toml.example @@ -0,0 +1,22 @@ +# Example fly.toml for the single-port combined mode (`serve-all`). +# Fly terminates TLS; PIP listens on 0.0.0.0:8642 inside the VM. +app = "your-pip-node" # replace with your Fly app name + +[http_service] + internal_port = 8642 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + + [[http_service.checks]] + path = "/healthz" + interval = "30s" + timeout = "3s" + +[mounts] + source = "pip_data" + destination = "/data" + +[env] + PIP_HTTP_HOST = "0.0.0.0" + # PIP_HTTP_BEARER_TOKEN: set via `fly secrets set` — never commit it. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index da04ab2..32d77b8 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,6 +12,9 @@ case "$cmd" in serve-http) exec pip-node serve-http ;; + serve-all) + exec pip-node serve-all + ;; serve-mcp) # HEALTHCHECK reads PIP_HEALTH_PORT from the container env — set it # per-service in compose (or -e PIP_HEALTH_PORT=8643 for docker run). diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 53407d9..5b8dcff 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -54,6 +54,24 @@ Both services share the `pip-data` volume (same key + sqlite store; WAL mode plus a 5s busy timeout make the concurrent access safe). Ports bind to `127.0.0.1` only — never publish 8642/8643 on a public interface. +## Single-port / PaaS mode + +`pip-node serve-all` serves the full HTTP API **and** MCP on +`PIP_HTTP_HOST:PIP_HTTP_PORT` (the MCP endpoint is `/mcp`). Use it on +platforms that expose a single port with TLS terminated by the platform — +e.g. Fly.io (`deploy/fly.toml.example`, or run `uvicorn main:app` which +honors `PORT` and derives public URLs from `FLY_APP_NAME`). + +```bash +docker run -d -v pip-data:/data -v $PWD/config/policy.yaml:/config/policy.yaml:ro \ + -p 127.0.0.1:8642:8642 -e PIP_HEALTH_PORT=8642 poke-interconnect serve-all +``` + +- Set `PIP_HTTP_HOST=0.0.0.0` only behind a TLS-terminating platform proxy. +- Set `PIP_PUBLIC_HTTP_URL=https://` and + `PIP_PUBLIC_MCP_URL=https:///mcp` so the identity document advertises + the right endpoints (`main.py` derives these automatically on Fly.io). + ## TLS Terminate TLS at a reverse proxy; PIP v1 has no in-transport encryption. diff --git a/main.py b/main.py new file mode 100644 index 0000000..69e57be --- /dev/null +++ b/main.py @@ -0,0 +1,33 @@ +"""PaaS entrypoint (Fly.io et al.): single-port combined app. + +Usage: ``uvicorn main:app`` (the platform sets PORT and, on Fly.io, +FLY_APP_NAME; TLS is terminated by the platform). +""" + +import os +from pathlib import Path + +from pip_protocol.bootstrap import build_node_from_settings +from pip_protocol.config import Settings +from pip_protocol.observability import configure_logging +from pip_protocol.transport.combined import create_combined_app + +settings = Settings() + +# PaaS conveniences — env vars set by the platform, not by the operator. +if settings.public_http_url is None and os.environ.get("FLY_APP_NAME"): + base = f"https://{os.environ['FLY_APP_NAME']}.fly.dev" + settings.public_http_url = base + settings.public_mcp_url = f"{base}/mcp" +if os.environ.get("PORT"): + settings.http_port = int(os.environ["PORT"]) +_default_key = Settings.model_fields["private_key_file"].default +if settings.private_key_file == _default_key and Path("/data").is_dir(): + if os.access("/data", os.W_OK): + settings.private_key_file = "/data/instance.key" + if settings.store_url == "memory://": + settings.store_url = "sqlite:////data/pip.db" + +configure_logging(settings.log_level) +node = build_node_from_settings(settings) +app = create_combined_app(node, settings) diff --git a/pyproject.toml b/pyproject.toml index 0faf13a..cd3d95c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ asyncio_mode = "auto" python_version = "3.10" strict = true mypy_path = "src" +files = ["src", "main.py"] plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] diff --git a/src/pip_protocol/cli.py b/src/pip_protocol/cli.py index 5fa4aef..127ab2c 100644 --- a/src/pip_protocol/cli.py +++ b/src/pip_protocol/cli.py @@ -46,6 +46,19 @@ def _cmd_serve_http(args: argparse.Namespace) -> int: return 0 +def _cmd_serve_all(args: argparse.Namespace) -> int: + import uvicorn + + from .transport.combined import create_combined_app + + settings = Settings() + configure_logging(settings.log_level) + node = build_node_from_settings(settings) + app = create_combined_app(node, settings) + uvicorn.run(app, host=settings.http_host, port=settings.http_port) + return 0 + + def _cmd_serve_mcp(args: argparse.Namespace) -> int: from .mcp_server import create_mcp_server, run_mcp @@ -113,6 +126,9 @@ def build_parser() -> argparse.ArgumentParser: p = sub.add_parser("serve-http", help="run the HTTP transport") p.set_defaults(func=_cmd_serve_http) + p = sub.add_parser("serve-all", help="single-port mode: HTTP plus MCP at /mcp") + p.set_defaults(func=_cmd_serve_all) + p = sub.add_parser("serve-mcp", help="run the MCP transport") p.add_argument("--transport", choices=["stdio", "streamable-http"], default="stdio") p.set_defaults(func=_cmd_serve_mcp) diff --git a/src/pip_protocol/transport/combined.py b/src/pip_protocol/transport/combined.py new file mode 100644 index 0000000..bd96fb8 --- /dev/null +++ b/src/pip_protocol/transport/combined.py @@ -0,0 +1,23 @@ +"""Single-process, single-port transport: FastAPI PIP routes + MCP at /mcp. + +For PaaS deployments (e.g. Fly.io) that expose one port and terminate TLS at +the platform. The FastAPI routes (`/healthz`, `/.well-known/pip`, +`/pip/v1/inbox`, `/metrics`) win; everything else falls through to the mounted +MCP app (`/mcp`). The MCP app's own `/healthz` is shadowed — the FastAPI one +answers instead. +""" + +from fastapi import FastAPI + +from ..config import Settings +from ..mcp_server import build_mcp_http_app, create_mcp_server +from ..node import Node +from .http import create_app + + +def create_combined_app(node: Node, settings: Settings | None = None) -> FastAPI: + settings = settings or Settings() + mcp_app = build_mcp_http_app(create_mcp_server(node, settings), settings.http_bearer_token) + app = create_app(node, settings, lifespan=mcp_app.router.lifespan_context) + app.mount("/", mcp_app) + return app diff --git a/src/pip_protocol/transport/http.py b/src/pip_protocol/transport/http.py index da0c463..4331e7f 100644 --- a/src/pip_protocol/transport/http.py +++ b/src/pip_protocol/transport/http.py @@ -37,9 +37,14 @@ def unauthorized() -> JSONResponse: return JSONResponse({"error": "unauthorized"}, status_code=401) -def create_app(node: Node, settings: Settings | None = None) -> FastAPI: +def create_app( + node: Node, + settings: Settings | None = None, + *, + lifespan: Any = None, +) -> FastAPI: settings = settings or Settings() - app = FastAPI(title="poke-interconnect", version="1.0.0") + app = FastAPI(title="poke-interconnect", version="1.0.0", lifespan=lifespan) @app.middleware("http") async def request_context(request: Request, call_next): # type: ignore[no-untyped-def] diff --git a/tests/test_combined.py b/tests/test_combined.py new file mode 100644 index 0000000..69eaaf0 --- /dev/null +++ b/tests/test_combined.py @@ -0,0 +1,92 @@ +import importlib +import json +import sys + +from starlette.testclient import TestClient + +from pip_protocol.config import Settings +from pip_protocol.identity import KeyPair +from pip_protocol.node import Node +from pip_protocol.schemas import parse_envelope +from pip_protocol.transport.combined import create_combined_app + +_MCP_INIT = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }, +} +_MCP_HEADERS = {"Accept": "application/json, text/event-stream"} + + +def _mcp_body(resp): + if resp.headers["content-type"].startswith("text/event-stream"): + for line in resp.text.splitlines(): + if line.startswith("data:"): + return json.loads(line[len("data:") :]) + raise AssertionError("no data line in SSE response") + return resp.json() + + +def test_combined_app(clock): + node = Node(KeyPair.generate(), clock=clock) + app = create_combined_app(node) + # ported loopback Host passes MCP's DNS-rebinding guard + with TestClient(app, base_url="http://127.0.0.1:8642") as client: + assert client.get("/healthz").json()["status"] == "ok" + hs = parse_envelope(client.get("/.well-known/pip").json()) + assert hs.typed_payload().identity.instance_id == node.instance_id + resp = client.post("/mcp", json=_MCP_INIT, headers=_MCP_HEADERS) + assert resp.status_code == 200, resp.text + assert _mcp_body(resp)["result"]["serverInfo"]["name"] == "pip-v1" + + +def test_combined_bearer_gates_mcp_not_healthz(clock): + node = Node(KeyPair.generate(), clock=clock) + settings = Settings(http_bearer_token="tok") # noqa: S106 + app = create_combined_app(node, settings) + with TestClient(app, base_url="http://127.0.0.1:8642") as client: + assert client.get("/healthz").status_code == 200 + assert client.post("/mcp", json=_MCP_INIT, headers=_MCP_HEADERS).status_code == 401 + ok = client.post( + "/mcp", + json=_MCP_INIT, + headers={**_MCP_HEADERS, "Authorization": "Bearer tok"}, # noqa: S106 + ) + assert ok.status_code == 200 + + +def _load_main(monkeypatch, tmp_path, **env): + monkeypatch.setenv("PIP_PRIVATE_KEY_FILE", str(tmp_path / "k.key")) + monkeypatch.setenv("PIP_POLICY_FILE", str(tmp_path / "policy.yaml")) + monkeypatch.setenv("PIP_STORE_URL", "memory://") + for var in ("FLY_APP_NAME", "PORT", "PIP_PUBLIC_HTTP_URL", "PIP_PUBLIC_MCP_URL"): + monkeypatch.delenv(var, raising=False) + for key, value in env.items(): + monkeypatch.setenv(key, value) + sys.path.insert(0, ".") + try: + import main + + return importlib.reload(main) + finally: + sys.path.remove(".") + + +def test_main_module_fly_env(tmp_path, monkeypatch): + main = _load_main(monkeypatch, tmp_path, FLY_APP_NAME="demo") + with TestClient(main.app, base_url="http://127.0.0.1:8642") as client: + assert client.get("/healthz").status_code == 200 + hs = parse_envelope(client.get("/.well-known/pip").json()) + ep = hs.typed_payload().identity.endpoints + assert ep["http"] == "https://demo.fly.dev" + assert ep["mcp"] == "https://demo.fly.dev/mcp" + + +def test_main_module_port_env(tmp_path, monkeypatch): + main = _load_main(monkeypatch, tmp_path, PORT="9999") + assert main.settings.http_port == 9999 From 9d877f9225e7a061812ed2320d23390ea2dbc749 Mon Sep 17 00:00:00 2001 From: commdevin Date: Sat, 12 Sep 2026 11:19:13 +0000 Subject: [PATCH 2/2] main.py: bind 0.0.0.0 under platform proxy so MCP accepts public Host Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/main.py b/main.py index 69e57be..c5040c5 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,9 @@ settings.public_mcp_url = f"{base}/mcp" if os.environ.get("PORT"): settings.http_port = int(os.environ["PORT"]) +if os.environ.get("FLY_APP_NAME") or os.environ.get("PORT"): + # platform proxy in front: bind all interfaces and accept the public Host header + settings.http_host = "0.0.0.0" # noqa: S104 _default_key = Settings.model_fields["private_key_file"].default if settings.private_key_file == _default_key and Path("/data").is_dir(): if os.access("/data", os.W_OK):