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::{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 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 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 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}