Skip to main content

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}