Skip to main content

mcp_trace_validator/checks/
negotiation.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The negotiated-capability usage check (`LIFE-009`): "Both parties MUST: Only use
5//! capabilities that were successfully negotiated".
6//!
7//! A method table maps each capability-gated request and notification to the
8//! declaration its use depends on. The check abstains when the trace carries no
9//! `initialize` result (nothing was negotiated *or* the trace is truncated — the
10//! handshake checks own that finding); it judges only sessions whose negotiation
11//! outcome is visible.
12
13use mcp_conformance_core::capability::CapabilityParty;
14use mcp_conformance_core::message::MessageKind;
15use mcp_conformance_core::trace::Direction;
16
17use super::FindingSink;
18use super::support::{Declaration, client_capability, server_capability};
19use crate::context::TraceContext;
20
21/// Capability-gated methods of `2025-11-25`: who sends them, and which declared
22/// capability their use depends on. Ungated methods (`initialize`, `ping`,
23/// cancellation, progress) are deliberately absent.
24const GATED_METHODS: &[(Direction, &str, CapabilityParty, &[&str])] = &[
25    (
26        Direction::ClientToServer,
27        "tools/list",
28        CapabilityParty::Server,
29        &["tools"],
30    ),
31    (
32        Direction::ClientToServer,
33        "tools/call",
34        CapabilityParty::Server,
35        &["tools"],
36    ),
37    (
38        Direction::ClientToServer,
39        "resources/list",
40        CapabilityParty::Server,
41        &["resources"],
42    ),
43    (
44        Direction::ClientToServer,
45        "resources/read",
46        CapabilityParty::Server,
47        &["resources"],
48    ),
49    (
50        Direction::ClientToServer,
51        "resources/templates/list",
52        CapabilityParty::Server,
53        &["resources"],
54    ),
55    (
56        Direction::ClientToServer,
57        "resources/subscribe",
58        CapabilityParty::Server,
59        &["resources", "subscribe"],
60    ),
61    (
62        Direction::ClientToServer,
63        "resources/unsubscribe",
64        CapabilityParty::Server,
65        &["resources", "subscribe"],
66    ),
67    (
68        Direction::ClientToServer,
69        "prompts/list",
70        CapabilityParty::Server,
71        &["prompts"],
72    ),
73    (
74        Direction::ClientToServer,
75        "prompts/get",
76        CapabilityParty::Server,
77        &["prompts"],
78    ),
79    (
80        Direction::ClientToServer,
81        "completion/complete",
82        CapabilityParty::Server,
83        &["completions"],
84    ),
85    (
86        Direction::ClientToServer,
87        "logging/setLevel",
88        CapabilityParty::Server,
89        &["logging"],
90    ),
91    (
92        Direction::ClientToServer,
93        "notifications/roots/list_changed",
94        CapabilityParty::Client,
95        &["roots", "listChanged"],
96    ),
97    (
98        Direction::ServerToClient,
99        "notifications/tools/list_changed",
100        CapabilityParty::Server,
101        &["tools", "listChanged"],
102    ),
103    (
104        Direction::ServerToClient,
105        "notifications/resources/list_changed",
106        CapabilityParty::Server,
107        &["resources", "listChanged"],
108    ),
109    (
110        Direction::ServerToClient,
111        "notifications/resources/updated",
112        CapabilityParty::Server,
113        &["resources", "subscribe"],
114    ),
115    (
116        Direction::ServerToClient,
117        "notifications/prompts/list_changed",
118        CapabilityParty::Server,
119        &["prompts", "listChanged"],
120    ),
121    (
122        Direction::ServerToClient,
123        "notifications/message",
124        CapabilityParty::Server,
125        &["logging"],
126    ),
127    (
128        Direction::ServerToClient,
129        "sampling/createMessage",
130        CapabilityParty::Client,
131        &["sampling"],
132    ),
133    (
134        Direction::ServerToClient,
135        "elicitation/create",
136        CapabilityParty::Client,
137        &["elicitation"],
138    ),
139    (
140        Direction::ServerToClient,
141        "roots/list",
142        CapabilityParty::Client,
143        &["roots"],
144    ),
145];
146
147/// `LIFE-009`: every capability-gated message must ride on a declared capability.
148pub(super) fn negotiated_capabilities_only(context: &TraceContext<'_>, sink: &mut FindingSink) {
149    for (event, kind, _) in context.messages() {
150        let method = match kind {
151            MessageKind::Request { method, .. } | MessageKind::Notification { method } => *method,
152            _ => continue,
153        };
154        let gate = GATED_METHODS
155            .iter()
156            .find(|(direction, gated, ..)| *direction == event.direction && *gated == method);
157        let Some((_, _, party, path)) = gate else {
158            continue;
159        };
160        let declared = match party {
161            CapabilityParty::Server => server_capability(context, path),
162            CapabilityParty::Client => client_capability(context, path),
163        };
164        if matches!(declared, Declaration::Unknowable) {
165            // No initialize result, so neither side's declarations are in this
166            // trace. The message is gated on something the capture cannot show,
167            // which is not the same as riding on a capability nobody negotiated.
168            continue;
169        }
170        // The subject is a capability-gated message in a session whose
171        // declarations are readable; one that sent none put nothing to the
172        // test, however long it ran.
173        sink.examined();
174        if matches!(declared, Declaration::Withheld) {
175            let owner = match party {
176                CapabilityParty::Server => "server",
177                CapabilityParty::Client => "client",
178            };
179            sink.push(
180                Some(event.seq),
181                format!(
182                    "{method:?} uses the {owner} capability {}, which was not negotiated in this session",
183                    path.join(".")
184                ),
185            );
186        }
187    }
188}
189
190#[cfg(test)]
191#[allow(clippy::unwrap_used)]
192mod tests {
193    use crate::checks;
194    use crate::context::TraceContext;
195    use crate::reader::{Limits, parse_trace};
196
197    fn findings_for(trace: &str) -> Vec<String> {
198        let events = parse_trace(trace, &Limits::default()).unwrap();
199        let context = TraceContext::new(&events);
200        checks::find("lifecycle.negotiated-capabilities-only")
201            .unwrap()
202            .run(&context)
203            .findings
204            .into_iter()
205            .map(|finding| finding.detail)
206            .collect()
207    }
208
209    fn handshake(client_capabilities: &str, server_capabilities: &str) -> String {
210        let request = format!(
211            r#"{{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-11-25","capabilities":{client_capabilities},"clientInfo":{{"name":"t","version":"0"}}}}}}}}"#
212        );
213        let result = format!(
214            r#"{{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":"2025-11-25","capabilities":{server_capabilities},"serverInfo":{{"name":"s","version":"0"}}}}}}}}"#
215        );
216        let initialized = r#"{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
217        format!("{request}\n{result}\n{initialized}")
218    }
219
220    #[test]
221    fn flags_undeclared_sub_capability_but_not_declared_parent() {
222        // resources declared without subscribe: read is fine, subscribe is not.
223        let trace = format!(
224            "{}\n{}\n{}",
225            handshake("{}", r#"{"resources":{}}"#),
226            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"file:///a"}}}"#,
227            r#"{"seq":4,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"resources/subscribe","params":{"uri":"file:///a"}}}"#,
228        );
229        let findings = findings_for(&trace);
230        assert_eq!(findings.len(), 1, "{findings:?}");
231        assert!(findings[0].contains("resources.subscribe"), "{findings:?}");
232    }
233
234    #[test]
235    fn judges_client_capabilities_for_server_initiated_traffic() {
236        let trace = format!(
237            "{}\n{}",
238            handshake(r#"{"roots":{}}"#, "{}"),
239            r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":"s1","method":"sampling/createMessage","params":{}}}"#,
240        );
241        let findings = findings_for(&trace);
242        assert_eq!(findings.len(), 1, "{findings:?}");
243        assert!(
244            findings[0].contains("client capability sampling"),
245            "{findings:?}"
246        );
247    }
248
249    #[test]
250    fn direction_guard_keeps_wrong_way_messages_out_of_scope() {
251        // A *server*-emitted tools/list request is not a client capability use; the
252        // table must not match it (other checks own that weirdness).
253        let trace = format!(
254            "{}\n{}",
255            handshake("{}", "{}"),
256            r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":9,"method":"tools/list"}}"#,
257        );
258        assert!(findings_for(&trace).is_empty());
259    }
260
261    #[test]
262    fn abstains_when_negotiation_is_invisible() {
263        let trace = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#;
264        let events = parse_trace(trace, &Limits::default()).unwrap();
265        let context = TraceContext::new(&events);
266        let outcome = checks::find("lifecycle.negotiated-capabilities-only")
267            .unwrap()
268            .run(&context);
269        assert!(outcome.findings.is_empty());
270        // The half this test used to omit, and the reason it passed for as long
271        // as the check reported *pass* here: an abstention and a pass both have
272        // no findings, and only the subject count separates them.
273        assert_eq!(
274            outcome.subjects, 0,
275            "a session with no handshake shows neither compliance nor violation"
276        );
277    }
278
279    #[test]
280    fn declared_capabilities_pass() {
281        let trace = format!(
282            "{}\n{}\n{}",
283            handshake(r#"{"roots":{"listChanged":true}}"#, r#"{"logging":{}}"#),
284            r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"x"}}}"#,
285            r#"{"seq":4,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}}"#,
286        );
287        assert!(findings_for(&trace).is_empty());
288    }
289}