mcp_trace_validator/checks/draft/
capabilities.rs1use mcp_conformance_core::message::MessageKind;
22use mcp_conformance_core::trace::Direction;
23
24use super::super::FindingSink;
25use crate::context::TraceContext;
26
27#[cfg(test)]
28mod tests;
29
30const DISCOVER: &str = "server/discover";
32
33fn declares(context: &TraceContext<'_>, capability: &str) -> Option<bool> {
40 let capabilities = context
41 .exchanges_for(DISCOVER)
42 .find_map(|exchange| exchange.result)?
43 .get("capabilities");
44 let Some(capabilities) = capabilities else {
45 return Some(false);
46 };
47 Some(
48 capabilities
49 .get(capability)
50 .is_some_and(|value| !value.is_null() && value.as_bool() != Some(false)),
51 )
52}
53
54fn answered_undeclared(
57 context: &TraceContext<'_>,
58 sink: &mut FindingSink,
59 capability: &str,
60 methods: &[&str],
61) {
62 let Some(declared) = declares(context, capability) else {
65 return;
66 };
67 for method in methods {
68 for exchange in context.exchanges_for(method) {
69 if exchange.result.is_none() {
70 continue;
71 }
72 sink.examined();
73 if !declared {
74 sink.push(
75 Some(exchange.response.seq),
76 format!(
77 "server answered `{method}` while its `{DISCOVER}` result declared no \
78 `{capability}` capability"
79 ),
80 );
81 }
82 }
83 }
84}
85
86pub(in crate::checks) fn completions_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
88 answered_undeclared(context, sink, "completions", &["completion/complete"]);
89}
90
91pub(in crate::checks) fn logging_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
97 let Some(declared) = declares(context, "logging") else {
98 return;
99 };
100 for (event, kind, _) in context.messages() {
101 if event.direction != Direction::ServerToClient {
102 continue;
103 }
104 if matches!(kind, MessageKind::Notification { method } if *method == "notifications/message")
105 {
106 sink.examined();
107 if !declared {
108 sink.push(
109 Some(event.seq),
110 format!(
111 "server emitted `notifications/message` while its `{DISCOVER}` result \
112 declared no `logging` capability"
113 ),
114 );
115 }
116 }
117 }
118}
119
120const LIST_METHOD: &[(&str, &str)] = &[
122 ("tools", "tools/list"),
123 ("resources", "resources/list"),
124 ("prompts", "prompts/list"),
125];
126
127fn declared_list_answered(context: &TraceContext<'_>, sink: &mut FindingSink, capability: &str) {
130 if declares(context, capability) != Some(true) {
131 return;
132 }
133 let Some((_, method)) = LIST_METHOD.iter().find(|(name, _)| *name == capability) else {
134 return;
135 };
136 for exchange in context.exchanges_for(method) {
137 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(serde_json::Value::as_i64);
146 if code == Some(-32601) {
147 sink.push(
148 Some(exchange.response.seq),
149 format!(
150 "server declared the `{capability}` capability but answered `{method}` \
151 with -32601; a declared capability must be served"
152 ),
153 );
154 }
155 }
156}
157
158pub(in crate::checks) fn tools_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
160 answered_undeclared(context, sink, "tools", &["tools/list", "tools/call"]);
161}
162
163pub(in crate::checks) fn resources_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
165 answered_undeclared(
166 context,
167 sink,
168 "resources",
169 &[
170 "resources/list",
171 "resources/templates/list",
172 "resources/read",
173 ],
174 );
175}
176
177pub(in crate::checks) fn prompts_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
179 answered_undeclared(context, sink, "prompts", &["prompts/list", "prompts/get"]);
180}
181
182pub(in crate::checks) fn tools_list_implemented(
184 context: &TraceContext<'_>,
185 sink: &mut FindingSink,
186) {
187 declared_list_answered(context, sink, "tools");
188}
189
190pub(in crate::checks) fn resources_list_implemented(
192 context: &TraceContext<'_>,
193 sink: &mut FindingSink,
194) {
195 declared_list_answered(context, sink, "resources");
196}
197
198pub(in crate::checks) fn prompts_list_implemented(
200 context: &TraceContext<'_>,
201 sink: &mut FindingSink,
202) {
203 declared_list_answered(context, sink, "prompts");
204}
205
206pub(in crate::checks) fn embedded_resource_declared(
213 context: &TraceContext<'_>,
214 sink: &mut FindingSink,
215) {
216 let Some(declared) = declares(context, "resources") else {
217 return;
218 };
219 for exchange in context.exchanges_for("tools/call") {
220 let embedded = exchange
221 .result
222 .and_then(|result| result.get("content"))
223 .and_then(serde_json::Value::as_array)
224 .is_some_and(|blocks| {
225 blocks.iter().any(|block| {
226 block.get("type").and_then(serde_json::Value::as_str) == Some("resource")
227 })
228 });
229 if !embedded {
230 continue;
231 }
232 sink.examined();
233 if !declared {
234 sink.push(
235 Some(exchange.response.seq),
236 format!(
237 "a `tools/call` result embeds a resource while the `{DISCOVER}` result \
238 declared no `resources` capability"
239 ),
240 );
241 }
242 }
243}