mcp_trace_validator/checks/draft/logging.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Logging at `2026-07-28`, where the level rides each request's `_meta`.
5//!
6//! `logging/setLevel` is gone: a client asks for log messages by putting
7//! `io.modelcontextprotocol/logLevel` in a request's `_meta`, and the server may
8//! answer with `notifications/message` on that request's response stream and
9//! nowhere else. The feature is deprecated as of this revision (SEP-2577), which
10//! changes nothing about how its remaining clauses are judged.
11//!
12//! The capability declaration for logging lives with the other four in
13//! [`super::capabilities`], because all five state one rule per feature page and
14//! all five need this revision's declaration surface.
15
16use mcp_conformance_core::message::MessageKind;
17use mcp_conformance_core::trace::Direction;
18use serde_json::Value;
19
20use super::super::FindingSink;
21use crate::context::TraceContext;
22
23#[cfg(test)]
24mod tests;
25
26/// The `_meta` key a request sets to opt into log messages.
27const LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel";
28
29/// The log notification itself.
30const MESSAGE: &str = "notifications/message";
31
32/// Whether `event` is a server `notifications/message`.
33fn is_log(event: &mcp_conformance_core::trace::TraceEvent, kind: &MessageKind<'_>) -> bool {
34 event.direction == Direction::ServerToClient
35 && matches!(kind, MessageKind::Notification { method } if *method == MESSAGE)
36}
37
38/// `LOG-008`: `notifications/message` only for a request that asked for it.
39///
40/// The level rides `_meta.io.modelcontextprotocol/logLevel` on the request, and a
41/// log notification belongs to the response stream of the request that set it.
42/// On a recording this is judged by the one thing that survives: if *no* request
43/// in the session set a log level, no `notifications/message` may appear at all.
44/// Attributing a notification to a particular request is not possible on stdio,
45/// where one channel carries everything, so a session with at least one
46/// level-setting request is not judged further.
47pub(in crate::checks) fn level_requested(context: &TraceContext<'_>, sink: &mut FindingSink) {
48 let any_requested = context.messages().any(|(event, _, _)| {
49 event.direction == Direction::ClientToServer
50 && event.message_payload().is_some_and(|payload| {
51 payload
52 .get("params")
53 .and_then(|params| params.get("_meta"))
54 .and_then(|meta| meta.get(LOG_LEVEL))
55 .is_some()
56 })
57 });
58 for (event, kind, _) in context.messages() {
59 if !is_log(event, kind) {
60 continue;
61 }
62 // The subject is an emitted log notification: a session in which the
63 // server never logged puts nothing to this test, whether or not a
64 // request asked for logs.
65 sink.examined();
66 if !any_requested {
67 sink.push(
68 Some(event.seq),
69 "server emitted `notifications/message` though no request in this session \
70 carried `io.modelcontextprotocol/logLevel`"
71 .to_owned(),
72 );
73 }
74 }
75}
76
77/// `LOG-009`: log notifications stay off a `subscriptions/listen` stream.
78///
79/// A `notifications/message` tagged with `io.modelcontextprotocol/subscriptionId`
80/// is by definition travelling on a subscription's stream, which this clause
81/// forbids: logging is request-scoped, and the subscription stream carries the
82/// response to a different request entirely.
83pub(in crate::checks) fn not_on_subscription(context: &TraceContext<'_>, sink: &mut FindingSink) {
84 for (event, kind, _) in context.messages() {
85 if event.direction != Direction::ServerToClient {
86 continue;
87 }
88 if !is_log(event, kind) {
89 continue;
90 }
91 sink.examined();
92 let tagged = event
93 .message_payload()
94 .and_then(|payload| payload.get("params"))
95 .and_then(|params| params.get("_meta"))
96 .and_then(|meta| meta.get("io.modelcontextprotocol/subscriptionId"))
97 .is_some_and(|id| !id.is_null());
98 if tagged {
99 sink.push(
100 Some(event.seq),
101 "`notifications/message` carries a subscription id, so it is travelling on a \
102 `subscriptions/listen` stream; logging is request-scoped"
103 .to_owned(),
104 );
105 }
106 }
107}
108
109/// `LOG-010`: an unrecognized log level draws `-32602`.
110///
111/// The levels are RFC 5424's eight, which the page lists as the complete set.
112pub(in crate::checks) fn invalid_level_rejected(
113 context: &TraceContext<'_>,
114 sink: &mut FindingSink,
115) {
116 const LEVELS: &[&str] = &[
117 "debug",
118 "info",
119 "notice",
120 "warning",
121 "error",
122 "critical",
123 "alert",
124 "emergency",
125 ];
126 for exchange in context.exchanges() {
127 let Some(level) = exchange
128 .params
129 .and_then(|params| params.get("_meta"))
130 .and_then(|meta| meta.get(LOG_LEVEL))
131 else {
132 continue;
133 };
134 if level.as_str().is_some_and(|name| LEVELS.contains(&name)) {
135 continue;
136 }
137 // The subject is a request that named a level outside the eight; a
138 // session that only ever named valid ones leaves this clause untested.
139 sink.examined();
140 let code = exchange
141 .response
142 .message_payload()
143 .and_then(|payload| payload.get("error"))
144 .and_then(|error| error.get("code"))
145 .and_then(Value::as_i64);
146 if code != Some(-32602) {
147 sink.push(
148 Some(exchange.response.seq),
149 format!(
150 "the request declared log level {level}, which is not one of the eight \
151 RFC 5424 levels, and drew {} rather than -32602",
152 code.map_or_else(|| "a result".to_owned(), |code| format!("error {code}"))
153 ),
154 );
155 }
156 }
157}