Skip to content

Commit 711edae

Browse files
committed
Streamable HTTP support
1 parent 5f66fab commit 711edae

22 files changed

Lines changed: 1100 additions & 169 deletions

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ When using custom CA certificate bundles, you must configure both:
157157
158158
---
159159

160+
## Transports
161+
162+
Perfecto MCP runs over **stdio** by default. It can also serve **streamable HTTP**, where credentials are
163+
resolved per request from an `Authorization: Bearer` header and the target cloud from a `Perfecto-Cloud-Name`
164+
header, so one server can serve several users and clouds.
165+
166+
```bash
167+
perfecto-mcp --mcp http
168+
```
169+
170+
Transport resolution precedence: **CLI `--mcp` > `PERFECTO_MCP_TRANSPORT` > stdio**.
171+
172+
See [docs/hosted-http.md](docs/hosted-http.md) for client configuration, auth behavior, health probes and
173+
environment variables.
174+
175+
---
176+
160177
## OpenTelemetry
161178

162179
Perfecto MCP reports traces and metrics for MCP tool calls using [OpenTelemetry](https://opentelemetry.io/). This gives you visibility into which tools are used, how long they take, and when errors occur.

build.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ def run_pyinstaller(name: str, icon: str):
123123
'--hidden-import=opentelemetry.propagate',
124124
'--collect-submodules=opentelemetry',
125125
'--collect-all=grpc',
126+
# Streamable HTTP transport: uvicorn resolves its loop/protocol
127+
# implementations by name at runtime, so PyInstaller cannot see them.
128+
'--collect-submodules=uvicorn',
126129
])
127130

128131

config/auth.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Per-request authentication for the streamable HTTP transport."""
2+
from __future__ import annotations
3+
4+
import os
5+
from typing import Optional, Protocol, runtime_checkable
6+
7+
from mcp.server.fastmcp import Context, FastMCP
8+
from starlette.requests import Request
9+
from starlette.responses import JSONResponse
10+
from starlette.types import ASGIApp, Receive, Scope, Send
11+
12+
from config.perfecto import PERFECTO_CLOUD_NAME_ENV_NAME
13+
from config.token import PerfectoToken, PerfectoTokenError
14+
15+
PERFECTO_TOKEN_STATE_ATTR = "token"
16+
PERFECTO_USER_CONFIG_STATE_ATTR = "user_config"
17+
PERFECTO_CLOUD_NAME_HEADER = "perfecto-cloud-name"
18+
19+
# Unauthenticated probe paths for orchestrators / load balancers.
20+
HEALTH_PATHS = frozenset({"/health", "/healthz"})
21+
22+
23+
class AuthError(Exception):
24+
"""Raised when Authorization cannot be parsed into credentials."""
25+
26+
27+
@runtime_checkable
28+
class AuthPort(Protocol):
29+
"""Resolves the Perfecto security token for the current tool invocation."""
30+
31+
def get_token(self, ctx: Context) -> Optional[PerfectoToken]:
32+
...
33+
34+
35+
class StdioAuthProvider:
36+
"""Process-lifetime token from env / token file / Docker secrets."""
37+
38+
def __init__(self, token: Optional[PerfectoToken]):
39+
self._token = token
40+
41+
def get_token(self, ctx: Context) -> Optional[PerfectoToken]:
42+
return self._token
43+
44+
45+
class HttpAuthProvider:
46+
"""Per-request token attached by Bearer auth middleware to request.state."""
47+
48+
def get_token(self, ctx: Context) -> Optional[PerfectoToken]:
49+
request = ctx.request_context.request
50+
if request is None:
51+
return None
52+
return getattr(request.state, PERFECTO_TOKEN_STATE_ATTR, None)
53+
54+
55+
def resolve_cloud_name(header_value: Optional[str] = None) -> Optional[str]:
56+
"""
57+
Resolve the Perfecto cloud for a request.
58+
59+
Precedence: ``Perfecto-Cloud-Name`` header > PERFECTO_CLOUD_NAME env var.
60+
"""
61+
candidate = (header_value or "").strip()
62+
if candidate:
63+
return candidate
64+
return os.getenv(PERFECTO_CLOUD_NAME_ENV_NAME, "").strip() or None
65+
66+
67+
def parse_authorization_header(value: Optional[str], cloud_name: Optional[str] = None) -> PerfectoToken:
68+
"""
69+
Parse ``Authorization: Bearer <security-token>`` into a PerfectoToken.
70+
71+
The cloud name is not carried in the credentials; it comes from the
72+
``Perfecto-Cloud-Name`` header or PERFECTO_CLOUD_NAME. Does not call the
73+
Perfecto API — parse only.
74+
"""
75+
if not value or not value.strip():
76+
raise AuthError("Missing Authorization header")
77+
78+
scheme, _, credentials = value.strip().partition(" ")
79+
if scheme.lower() != "bearer" or not credentials.strip():
80+
raise AuthError("Authorization header must use Bearer scheme")
81+
82+
try:
83+
return PerfectoToken.from_bearer_credentials(credentials.strip(), cloud_name)
84+
except PerfectoTokenError as exc:
85+
raise AuthError("Unparseable Bearer credentials") from exc
86+
87+
88+
class BearerAuthMiddleware:
89+
"""
90+
HTTP gate: require a parseable Bearer token on every request.
91+
92+
Attaches PerfectoToken to ``request.state``; does not validate against Perfecto.
93+
A missing cloud name is not rejected here — tools surface it as a
94+
configuration error, the same way stdio does.
95+
"""
96+
97+
def __init__(self, app: ASGIApp):
98+
self.app = app
99+
100+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
101+
if scope["type"] != "http":
102+
await self.app(scope, receive, send)
103+
return
104+
105+
if scope.get("method") == "OPTIONS":
106+
await self.app(scope, receive, send)
107+
return
108+
109+
path = scope.get("path", "") or ""
110+
if path in HEALTH_PATHS:
111+
await self.app(scope, receive, send)
112+
return
113+
114+
request = Request(scope, receive)
115+
cloud_name = resolve_cloud_name(request.headers.get(PERFECTO_CLOUD_NAME_HEADER))
116+
try:
117+
token = parse_authorization_header(
118+
request.headers.get("authorization"),
119+
cloud_name,
120+
)
121+
except AuthError:
122+
response = JSONResponse(
123+
{"error": "Unauthorized"},
124+
status_code=401,
125+
headers={"WWW-Authenticate": "Bearer"},
126+
)
127+
await response(scope, receive, send)
128+
return
129+
130+
setattr(request.state, PERFECTO_TOKEN_STATE_ATTR, token)
131+
setattr(
132+
request.state,
133+
PERFECTO_USER_CONFIG_STATE_ATTR,
134+
{"token": token, "cloud_name": token.cloud_name},
135+
)
136+
await self.app(scope, receive, send)
137+
138+
139+
def register_health_routes(mcp: FastMCP) -> None:
140+
"""Register unauthenticated health probes on the FastMCP ASGI app."""
141+
142+
@mcp.custom_route("/health", methods=["GET"])
143+
async def health(_request: Request) -> JSONResponse:
144+
return JSONResponse({"status": "ok"})
145+
146+
@mcp.custom_route("/healthz", methods=["GET"])
147+
async def healthz(_request: Request) -> JSONResponse:
148+
return JSONResponse({"status": "ok"})
149+
150+
151+
def run_streamable_http(mcp: FastMCP) -> None:
152+
"""Serve FastMCP over streamable HTTP with Bearer auth middleware."""
153+
import anyio
154+
import uvicorn
155+
156+
register_health_routes(mcp)
157+
158+
async def _serve() -> None:
159+
app = BearerAuthMiddleware(mcp.streamable_http_app())
160+
config = uvicorn.Config(
161+
app,
162+
host=mcp.settings.host,
163+
port=mcp.settings.port,
164+
log_level=mcp.settings.log_level.lower(),
165+
)
166+
await uvicorn.Server(config).serve()
167+
168+
anyio.run(_serve)

config/context_resolution.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Helpers to read the per-session user config carried by the MCP context."""
2+
from typing import Any
3+
4+
from config.auth import PERFECTO_TOKEN_STATE_ATTR, PERFECTO_USER_CONFIG_STATE_ATTR
5+
6+
7+
def get_request_context(ctx: Any) -> Any:
8+
return getattr(ctx, "request_context", None)
9+
10+
11+
def get_request_state(ctx: Any) -> Any:
12+
request_context = get_request_context(ctx)
13+
request = getattr(request_context, "request", None)
14+
return getattr(request, "state", None)
15+
16+
17+
def resolve_ctx_user_config(ctx: Any) -> dict[str, Any]:
18+
request_context = get_request_context(ctx)
19+
request_state = get_request_state(ctx)
20+
21+
request_context_config = getattr(request_context, PERFECTO_USER_CONFIG_STATE_ATTR, None)
22+
if isinstance(request_context_config, dict):
23+
return request_context_config
24+
25+
request_state_config = getattr(request_state, PERFECTO_USER_CONFIG_STATE_ATTR, None)
26+
if isinstance(request_state_config, dict):
27+
return request_state_config
28+
29+
return {}
30+
31+
32+
def resolve_ctx_token(ctx: Any) -> Any:
33+
user_config = resolve_ctx_user_config(ctx)
34+
request_state = get_request_state(ctx)
35+
request_state_token = getattr(request_state, PERFECTO_TOKEN_STATE_ATTR, None)
36+
return user_config.get("token") or request_state_token

config/perfecto.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
SECURITY_TOKEN_ENV_NAME: str = "PERFECTO_SECURITY_TOKEN"
99
PERFECTO_CLOUD_NAME_ENV_NAME: str = 'PERFECTO_CLOUD_NAME'
1010

11-
SECURITY_TOKEN_NOT_SET_MESSAGE: str = f"Perfecto Security Token not set. Set environment variable {SECURITY_TOKEN_FILE_ENV_NAME} or {SECURITY_TOKEN_ENV_NAME}"
12-
PERFECTO_CLOUD_NAME_NOT_SET_MESSAGE: str = f"Perfecto Environment Cloud Name not set. Set environment variable {PERFECTO_CLOUD_NAME_ENV_NAME}"
11+
SECURITY_TOKEN_NOT_SET_MESSAGE: str = f"Perfecto Security Token not set. Set environment variable {SECURITY_TOKEN_FILE_ENV_NAME} or {SECURITY_TOKEN_ENV_NAME}, or send it as 'Authorization: Bearer <security-token>' when connecting over HTTP"
12+
PERFECTO_CLOUD_NAME_NOT_SET_MESSAGE: str = f"Perfecto Environment Cloud Name not set. Set environment variable {PERFECTO_CLOUD_NAME_ENV_NAME}, or send the 'Perfecto-Cloud-Name' header when connecting over HTTP"
1313

1414
HELP_TOC_URL = "https://help.perfecto.io/perfecto-help/Data/Tocs/"
1515
HELP_INDEX_URL = f"{HELP_TOC_URL}perfecto_help.js"

config/runtime.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Process-level runtime wiring shared by all tool registrations."""
2+
from dataclasses import dataclass
3+
from typing import Any, Literal, Optional
4+
5+
from config.auth import (
6+
AuthPort,
7+
PERFECTO_USER_CONFIG_STATE_ATTR,
8+
HttpAuthProvider,
9+
StdioAuthProvider,
10+
)
11+
from config.token import PerfectoToken
12+
13+
Transport = Literal["stdio", "streamable-http"]
14+
15+
16+
@dataclass(frozen=True)
17+
class AppRuntime:
18+
"""Process-level collaborators shared by tool registrations."""
19+
20+
transport: Transport
21+
auth: AuthPort
22+
user_config: dict[str, Any]
23+
24+
def resolve_user_config(self, ctx: Any) -> dict[str, Any]:
25+
user_config = dict(self.user_config)
26+
user_config.update(_read_ctx_user_config(ctx))
27+
token = self.auth.get_token(ctx)
28+
if token is not None:
29+
user_config["token"] = token
30+
return user_config
31+
32+
def configure_context(self, ctx: Any) -> dict[str, Any]:
33+
user_config = self.resolve_user_config(ctx)
34+
_hydrate_ctx_user_config(ctx, user_config)
35+
return user_config
36+
37+
38+
def _read_ctx_user_config(ctx: Any) -> dict[str, Any]:
39+
if ctx is None:
40+
return {}
41+
42+
user_config: dict[str, Any] = {}
43+
request_context = getattr(ctx, "request_context", None)
44+
request = getattr(request_context, "request", None)
45+
request_state = getattr(request, "state", None)
46+
47+
for target, attr_name in (
48+
(ctx, "user_config"),
49+
(request_context, PERFECTO_USER_CONFIG_STATE_ATTR),
50+
(request_state, PERFECTO_USER_CONFIG_STATE_ATTR),
51+
):
52+
request_config = getattr(target, attr_name, None)
53+
if isinstance(request_config, dict):
54+
user_config.update(request_config)
55+
56+
return user_config
57+
58+
59+
def _hydrate_ctx_user_config(ctx: Any, user_config: dict[str, Any]) -> None:
60+
if ctx is None:
61+
return
62+
63+
config_copy = dict(user_config)
64+
request_context = getattr(ctx, "request_context", None)
65+
request = getattr(request_context, "request", None)
66+
request_state = getattr(request, "state", None)
67+
68+
for target in (request_context, request_state):
69+
if target is not None:
70+
setattr(target, PERFECTO_USER_CONFIG_STATE_ATTR, dict(config_copy))
71+
72+
73+
def build_runtime(
74+
transport: Transport,
75+
startup_token: Optional[PerfectoToken] = None,
76+
) -> AppRuntime:
77+
"""
78+
Compose auth for the selected transport.
79+
80+
- stdio: process-lifetime ``startup_token``.
81+
- streamable-http: request-scoped Bearer auth.
82+
"""
83+
if transport == "stdio":
84+
stdio_user_config = {
85+
"startup_token": startup_token,
86+
"token": startup_token,
87+
"cloud_name": startup_token.cloud_name if startup_token else None,
88+
}
89+
return AppRuntime(
90+
transport=transport,
91+
auth=StdioAuthProvider(startup_token),
92+
user_config=stdio_user_config,
93+
)
94+
95+
if transport == "streamable-http":
96+
return AppRuntime(
97+
transport=transport,
98+
auth=HttpAuthProvider(),
99+
user_config={},
100+
)
101+
102+
raise ValueError(f"Unknown transport: {transport}")

config/token.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from functools import lru_cache
22
from pathlib import Path
3-
from typing import Union
3+
from typing import Optional, Union
44

55
from config.perfecto import SECURITY_TOKEN_NOT_SET_MESSAGE, PERFECTO_CLOUD_NAME_NOT_SET_MESSAGE
66

@@ -52,5 +52,22 @@ def from_file(cls, path: Union[str, Path], cloud_name: str) -> "PerfectoToken":
5252

5353
return cls(token=token_val, cloud_name=cloud_name)
5454

55+
@classmethod
56+
def from_bearer_credentials(cls, credentials: str, cloud_name: Optional[str] = None) -> "PerfectoToken":
57+
"""
58+
Parse Bearer credential material into a PerfectoToken.
59+
60+
Perfecto credentials are a single security token, so the cloud name is
61+
not part of them: it is supplied by the caller from the
62+
``Perfecto-Cloud-Name`` header (falling back to PERFECTO_CLOUD_NAME).
63+
Does not call the Perfecto API.
64+
"""
65+
raw = (credentials or "").strip()
66+
if not raw:
67+
raise PerfectoTokenError("Empty bearer credentials")
68+
69+
normalized_cloud_name = (cloud_name or "").strip() or None
70+
return cls(token=raw, cloud_name=normalized_cloud_name)
71+
5572
def __repr__(self):
5673
return "<PerfectoToken cloud_name=******** token=********>"

0 commit comments

Comments
 (0)