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::{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 declared == Some(false) {
165            let owner = match party {
166                CapabilityParty::Server => "server",
167                CapabilityParty::Client => "client",
168            };
169            sink.push(
170                Some(event.seq),
171                format!(
172                    "{method:?} uses the {owner} capability {}, which was not negotiated in this session",
173                    path.join(".")
174                ),
175            );
176        }
177    }
178}
179
180#[cfg(test)]
181#[allow(clippy::unwrap_used)]
182mod tests {
183    use crate::checks;
184    use crate::context::TraceContext;
185    use crate::reader::{Limits, parse_trace};
186
187    fn findings_for(trace: &str) -> Vec<String> {
188        let events = parse_trace(trace, &Limits::default()).unwrap();
189        let context = TraceContext::new(&events);
190        checks::find("lifecycle.negotiated-capabilities-only")
191            .unwrap()
192            .run(&context)
193            .into_iter()
194            .map(|finding| finding.detail)
195            .collect()
196    }
197
198    fn handshake(client_capabilities: &str, server_capabilities: &str) -> String {
199        let request = format!(
200            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"}}}}}}}}"#
201        );
202        let result = format!(
203            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"}}}}}}}}"#
204        );
205        let initialized = r#"{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
206        format!("{request}\n{result}\n{initialized}")
207    }
208
209    #[test]
210    fn flags_undeclared_sub_capability_but_not_declared_parent() {
211        // resources declared without subscribe: read is fine, subscribe is not.
212        let trace = format!(
213            "{}\n{}\n{}",
214            handshake("{}", r#"{"resources":{}}"#),
215            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"file:///a"}}}"#,
216            r#"{"seq":4,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"resources/subscribe","params":{"uri":"file:///a"}}}"#,
217        );
218        let findings = findings_for(&trace);
219        assert_eq!(findings.len(), 1, "{findings:?}");
220        assert!(findings[0].contains("resources.subscribe"), "{findings:?}");
221    }
222
223    #[test]
224    fn judges_client_capabilities_for_server_initiated_traffic() {
225        let trace = format!(
226            "{}\n{}",
227            handshake(r#"{"roots":{}}"#, "{}"),
228            r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":"s1","method":"sampling/createMessage","params":{}}}"#,
229        );
230        let findings = findings_for(&trace);
231        assert_eq!(findings.len(), 1, "{findings:?}");
232        assert!(
233            findings[0].contains("client capability sampling"),
234            "{findings:?}"
235        );
236    }
237
238    #[test]
239    fn direction_guard_keeps_wrong_way_messages_out_of_scope() {
240        // A *server*-emitted tools/list request is not a client capability use; the
241        // table must not match it (other checks own that weirdness).
242        let trace = format!(
243            "{}\n{}",
244            handshake("{}", "{}"),
245            r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":9,"method":"tools/list"}}"#,
246        );
247        assert!(findings_for(&trace).is_empty());
248    }
249
250    #[test]
251    fn abstains_when_negotiation_is_invisible() {
252        let trace = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#;
253        assert!(findings_for(trace).is_empty());
254    }
255
256    #[test]
257    fn declared_capabilities_pass() {
258        let trace = format!(
259            "{}\n{}\n{}",
260            handshake(r#"{"roots":{"listChanged":true}}"#, r#"{"logging":{}}"#),
261            r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"x"}}}"#,
262            r#"{"seq":4,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}}"#,
263        );
264        assert!(findings_for(&trace).is_empty());
265    }
266}