Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 |
Expand Down
22 changes: 22 additions & 0 deletions deploy/fly.toml.example
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
18 changes: 18 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>` and
`PIP_PUBLIC_MCP_URL=https://<host>/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.
Expand Down
36 changes: 36 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""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"])
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):
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)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
16 changes: 16 additions & 0 deletions src/pip_protocol/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions src/pip_protocol/transport/combined.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions src/pip_protocol/transport/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
92 changes: 92 additions & 0 deletions tests/test_combined.py
Original file line number Diff line number Diff line change
@@ -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
Loading