feat(mcp): add JSON structured logging emitter and JWT sub claim (#373 stack 1/3)#375
Conversation
Introduce allowlisted JSON event builders and a dedicated stdout logger with propagate=False so hosted observability coexists with FastMCP's RichHandler. Add PIPEFY_MCP_LOG_LEVEL to McpSettings and wire it through run_server and FastMCP construction.
Extend the resource-server token mapping with an optional sub field so hosted HTTP request logs can emit caller subject alongside client_id without re-decoding the bearer.
normalize_log_level resolved names via getattr(logging, ...), which returns non-level module attributes (BASIC_FORMAT) and accepts aliases the settings Literal rejects (WARN, FATAL); an explicit map fails loudly on both. The observability package now re-exports its stdlib-only emitter surface per the package convention, and a suite-wide autouse fixture drops leftover stdout handlers so a test that configures the process-global logger cannot leak a closed-stream handler into later tests.
…he token PipefyAccessToken.sub is str | None, so a validly-signed token carrying a numeric sub raised ValidationError inside _to_access_token and verify_token turned it into a 401, a token dev accepted before the field existed. sub feeds request logging only; a malformed value must not gate authentication. RFC 7519 requires StringOrURI, so only non-compliant IdPs ever hit this.
651dec7 to
932c9f2
Compare
Danielmoraisg
left a comment
There was a problem hiding this comment.
On the structured logging design (stack-level, non-blocking)
A few suggestions on the observability shape before 2/3 and 3/3 wire emission on top. These are about the logging design, not this diff specifically, and none of them block merging 1/3.
1. Route structured logs to stderr, not stdout. Under the stdio transport stdout is the JSON-RPC channel, so a structured line written there corrupts the protocol. configure_observability_logging currently runs unconditionally in run_server, before the transport branch, so on stdout it needs gating to HTTP only. Log collectors capture stdout and stderr equally, so moving structured output to stderr removes the hazard without losing anything.
2. Prefer one uniform structured stream over two bespoke events. Today two event types are emitted as JSON while every other log stays human-formatted text (for example the "Inbound bearer rejected" warning in resource_server.py). In a log backend those text lines are unparseable and fall out of structured queries. Attaching a JSON formatter to the app loggers makes every log structured, so the two events become well-known shapes inside one consistent stream rather than the only structured output.
3. Carry request context ambiently rather than per event. A context variable populated once per request (request id plus whatever identity and resource ids apply), merged into every line by the formatter, lets any log within a request correlate back to it, including errors raised deep in a handler. Threading fields through the two builder functions only covers those two call sites.
4. Source the request id from the inbound header. When the caller or an upstream proxy already sends x-request-id or x-correlation-id, honor it and generate an id only when it is absent. That keeps a request correlated across service boundaries instead of minting an id that stops here.
5. Add trace context if these logs feed a tracing backend. Injecting trace_id and span_id into each line lets logs join distributed traces. Without it the request id correlates logs to each other but not to spans.
6. Split the level knob from the format knob. One setting that governs both text verbosity and structured output means raising it to WARNING silently drops all structured events, including tool-call errors, since every event is emitted at INFO. A separate level (how much) and format (json or text) lets you quiet text logs without losing structured error events.
Points 1, 2, and 5 get harder to change once emission is wired, so they are worth settling here.
|
Thanks for the stack-level feedback, @Danielmoraisg , agreeing this is about the observability shape before 2/3 and 3/3 wire emission, not a blocker for the emitter/identity groundwork in this PR. 1. stderr + HTTP-only gate — acceptedAddressed on stack 2/3 (#376):
2. Uniform structured stream vs allowlisted events — keeping current designThe two allowlisted event shapes ( We are keeping bespoke allowlisted events for #373. If we later need every app log line to be queryable as JSON, that should be a separate follow-up (formatter on app loggers), not mixed into this audit-trail contract. 3. Ambient request context via contextvars — declining for correlationUnder stateful Streamable HTTP, the session task freezes contextvars at 4. Honor inbound
|
|
Ask: @Danielmoraisg , since you noted none of these block merging 1/3, and points 1 + 4 are already on #376, could you re-review / approve this PR so we can land the emitter groundwork and keep 2/3 and 3/3 moving? |
Keep the stdlib-only JSON emitter package surface from this stack and the tool-call middleware composition root from #378; wire both into server.py.
|
Rebased/merged latest
@Danielmoraisg could you re-review when you have a moment? Thanks again for the earlier approval. |
Danielmoraisg
left a comment
There was a problem hiding this comment.
Approving, though I disagree with the reframing of logs as audit trail as we generally use it for debugging It is good to be mindfull of privacy.
Regarding the point 6: Documenting "run at INFO" isn't enough, and it sits uneasily with the point 2 framing. If these events are a hosted audit trail, a single knob that silently drops the whole trail at WARNING+ is a real gap, not a doc footnote. Raising log verbosity to quiet noisy text logs is a normal operator action, and here it disables the audit trail with no signal that it happened.
|
@Danielmoraisg following up on your approve notes (framing + point 6) — and holding the merge of this PR until you've had a chance to react. Point 6 + framing — addressed on #376 (not only docs)Agreed that documenting "run at INFO" was not enough. On stack 2/3 (#376):
PR: #376 Point 5 (
|
Part of #373 (stack 1/3).
Summary
propagate=False) for hosted observability.PIPEFY_MCP_LOG_LEVELtoMcpSettingsand wires it throughFastMCP(log_level=...).PipefyAccessTokenwith optionalsubfrom JWT claims for caller subject in request logs (D16).subtonullinstead of rejecting the token.What does not land yet
No structured lines are emitted until stack 2/3 (#376) and 3/3 (#377) merge. This PR only lays the emitter and identity groundwork.
Stack status
devafter refactor: reshape the Pipefy client into an engine/session split #348 merged (8adea173).Test plan
uv run pytest packages/mcp/tests/observability/test_json_logging.py -quv run pytest packages/mcp/tests/auth/test_resource_server.py -quv run ruff check packages/mcp && uv run ruff format --check packages/mcp