1use 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
21const 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
147pub(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 continue;
169 }
170 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 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 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 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}