-
Notifications
You must be signed in to change notification settings - Fork 10
feat(mcp): add HTTP request log middleware and hosted wiring (#373 stack 2/3) #376
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
8285edb
feat(mcp): add stdout JSON structured logging emitter
adriannoes 9d0f061
feat(mcp): preserve JWT sub claim in PipefyAccessToken
adriannoes 33662e0
fix(mcp): map log levels explicitly and re-export the emitter surface
adriannoes 932c9f2
fix(mcp): degrade a non-string JWT sub to None instead of rejecting t…
adriannoes cb9e505
merge(dev): resolve conflicts with tool-call middleware (#378)
adriannoes a03af02
feat(mcp): add pure-ASGI HTTP request log middleware
adriannoes 8f5e57f
feat(mcp): serve HTTP with request logging via manual uvicorn
adriannoes 44ce147
fix(mcp): configure the stdout emitter only on the HTTP serve path
adriannoes ed0eec8
fix(mcp): emit structured JSON logs on stderr
adriannoes 52bb9a8
feat(mcp): honor inbound x-request-id and x-correlation-id
adriannoes e707d9e
fix(mcp): pin structured logger at INFO independent of text level
adriannoes 323206e
refactor(mcp): route tool_log_middleware through the structured emitter
adriannoes 739dcd6
merge(dev): resolve conflicts after #375 and #381
adriannoes 3549daf
merge(dev): resolve conflicts after #396 bind interlock
adriannoes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
packages/mcp/src/pipefy_mcp/observability/request_log_middleware.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| """Pure-ASGI middleware that emits one structured JSON line per HTTP request.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import time | ||
| from collections.abc import Awaitable, Callable, MutableMapping | ||
| from typing import Any | ||
| from uuid import uuid4 | ||
|
|
||
| from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser | ||
|
|
||
| from pipefy_mcp.auth.resource_server import PipefyAccessToken | ||
| from pipefy_mcp.observability.json_logging import ( | ||
| build_http_request_event, | ||
| emit_structured_event, | ||
| ) | ||
|
|
||
| ASGIApp = Callable[ | ||
| [ | ||
| MutableMapping[str, Any], | ||
| Callable[..., Awaitable[Any]], | ||
| Callable[..., Awaitable[None]], | ||
| ], | ||
| Awaitable[None], | ||
| ] | ||
| ASGIReceive = Callable[[], Awaitable[MutableMapping[str, Any]]] | ||
| ASGISend = Callable[[MutableMapping[str, Any]], Awaitable[None]] | ||
|
|
||
| MCP_SESSION_ID_HEADER = "mcp-session-id" | ||
| REQUEST_ID_HEADERS = ("x-request-id", "x-correlation-id") | ||
|
|
||
|
|
||
| def _header_from_scope(scope: MutableMapping[str, Any], name: str) -> str | None: | ||
| name_bytes = name.lower().encode("latin-1") | ||
| for key, value in scope.get("headers", []): | ||
| if key.lower() == name_bytes: | ||
| return value.decode("latin-1") | ||
| return None | ||
|
|
||
|
|
||
| def _header_from_response(headers: list[tuple[bytes, bytes]], name: str) -> str | None: | ||
| name_bytes = name.lower().encode("latin-1") | ||
| for key, value in headers: | ||
| if key.lower() == name_bytes: | ||
| return value.decode("latin-1") | ||
| return None | ||
|
|
||
|
|
||
| def resolve_request_id(scope: MutableMapping[str, Any]) -> str: | ||
| """Prefer an inbound correlation header; otherwise mint a server-side id. | ||
|
|
||
| Honors ``x-request-id`` first, then ``x-correlation-id``. Empty or | ||
| whitespace-only values are ignored so a proxy that sends a blank header | ||
| does not suppress generation. | ||
| """ | ||
| for name in REQUEST_ID_HEADERS: | ||
| raw = _header_from_scope(scope, name) | ||
| if raw is None: | ||
| continue | ||
| candidate = raw.strip() | ||
| if candidate: | ||
| return candidate | ||
| return str(uuid4()) | ||
|
|
||
|
|
||
| def _client_ip(scope: MutableMapping[str, Any]) -> str | None: | ||
| client = scope.get("client") | ||
| if not client: | ||
| return None | ||
| return client[0] | ||
|
|
||
|
|
||
| def _caller_identity( | ||
| scope: MutableMapping[str, Any], | ||
| ) -> tuple[str | None, str | None]: | ||
| user = scope.get("user") | ||
| if not isinstance(user, AuthenticatedUser): | ||
| return None, None | ||
| access_token = user.access_token | ||
| sub = access_token.sub if isinstance(access_token, PipefyAccessToken) else None | ||
| return sub, access_token.client_id | ||
|
|
||
|
|
||
| class RequestLogMiddleware: | ||
| """Log one HTTP request as JSON without buffering the response body.""" | ||
|
|
||
| def __init__(self, app: ASGIApp) -> None: | ||
| self.app = app | ||
|
|
||
| async def __call__( | ||
| self, | ||
| scope: MutableMapping[str, Any], | ||
| receive: ASGIReceive, | ||
| send: ASGISend, | ||
| ) -> None: | ||
| if scope["type"] != "http": | ||
| await self.app(scope, receive, send) | ||
| return | ||
|
|
||
| started_at = time.perf_counter() | ||
| request_id = resolve_request_id(scope) | ||
| scope.setdefault("state", {})["request_id"] = request_id | ||
|
|
||
| status: int | None = None | ||
| response_session_id: str | None = None | ||
| request_session_id = _header_from_scope(scope, MCP_SESSION_ID_HEADER) | ||
|
|
||
| async def send_wrapper(message: MutableMapping[str, Any]) -> None: | ||
| nonlocal status, response_session_id | ||
| if message["type"] == "http.response.start": | ||
| status = message["status"] | ||
| response_session_id = _header_from_response( | ||
| message.get("headers", []), | ||
| MCP_SESSION_ID_HEADER, | ||
| ) | ||
| await send(message) | ||
|
|
||
| try: | ||
| await self.app(scope, receive, send_wrapper) | ||
| finally: | ||
| sub, client_id = _caller_identity(scope) | ||
| duration_ms = round((time.perf_counter() - started_at) * 1000, 3) | ||
| emit_structured_event( | ||
| build_http_request_event( | ||
| method=scope["method"], | ||
| path=scope["path"], | ||
| status=status, | ||
| duration_ms=duration_ms, | ||
| client_ip=_client_ip(scope), | ||
| session_id=request_session_id or response_session_id, | ||
| request_id=request_id, | ||
| sub=sub, | ||
| client_id=client_id, | ||
| ) | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| """Wire hosted observability into the Streamable HTTP Starlette app.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from mcp.server.fastmcp import FastMCP | ||
| from starlette.applications import Starlette | ||
|
|
||
| from pipefy_mcp.observability.request_log_middleware import RequestLogMiddleware | ||
| from pipefy_mcp.settings import Settings | ||
|
|
||
|
|
||
| def wire_hosted_observability(app: FastMCP, _settings: Settings) -> Starlette: | ||
|
adriannoes marked this conversation as resolved.
|
||
| """Build the HTTP app once and attach hosted observability. | ||
|
|
||
| ``streamable_http_app()`` must run exactly once: each call builds a new | ||
| Starlette app (only the session manager is cached), so calling it again after | ||
| wiring would drop the middleware. | ||
| """ | ||
| http_app = app.streamable_http_app() | ||
| http_app.add_middleware(RequestLogMiddleware) | ||
| return http_app | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.