Skip to main content

mcp_trace_validator/checks/draft/
capabilities.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Server capability declarations at `2026-07-28`.
5//!
6//! Every feature page states the same clause — "Servers that support X MUST
7//! declare the X capability" — and the `2025-11-25` registry judges it with
8//! `tools.capability-declared` and its four siblings. **Those checks cannot be
9//! reused here.** They resolve declarations through
10//! `support::server_capability`, which returns "abstain" unless the trace
11//! carries an `initialize` *result*, and this revision has no `initialize` at
12//! all. Pointed at a `2026-07-28` entry each would inspect nothing and report a
13//! vacuous `pass` — the TRAN-071 failure mode, five times over.
14//!
15//! The declaration surface here is the `server/discover` result, so that is what
16//! these read.
17//!
18//! The abstention is preserved where it is honest: a session that
19//! never probed carries no declaration, and silence is not a denial.
20
21use 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
30/// The probe whose result is this revision's capability declaration.
31const DISCOVER: &str = "server/discover";
32
33/// Whether the server declared `capability`, or `None` when the trace carries no
34/// `server/discover` result to read a declaration from.
35///
36/// A declaration is present when the key resolves to something that is neither
37/// `false` nor `null` — the same reading ADR-0006 gives the `2025-11-25`
38/// capability objects.
39fn 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
54/// Reports every `methods` request the server *answered* while `capability` was
55/// declared absent — answering is the observable form of supporting a feature.
56fn answered_undeclared(
57    context: &TraceContext<'_>,
58    sink: &mut FindingSink,
59    capability: &str,
60    methods: &[&str],
61) {
62    // No `server/discover` result at all: there is no declaration surface, so
63    // the session's capability state is unknowable rather than empty.
64    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
86/// `COMP-007`: a server answering completions declares the `completions` capability.
87pub(in crate::checks) fn completions_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
88    answered_undeclared(context, sink, "completions", &["completion/complete"]);
89}
90
91/// `LOG-007`: a server emitting log notifications declares the `logging` capability.
92///
93/// Logging has no request of its own at this revision — `logging/setLevel` was
94/// removed and the level rides each request's `_meta` — so the observable form of
95/// "emits log message notifications" is the notification itself.
96pub(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
120/// The list operation each capability's declaration obliges the server to answer.
121const LIST_METHOD: &[(&str, &str)] = &[
122    ("tools", "tools/list"),
123    ("resources", "resources/list"),
124    ("prompts", "prompts/list"),
125];
126
127/// Reports a declared capability whose list operation the server refused as an
128/// unimplemented method — the shared body of TOOL-020, RES-013 and PROM-013.
129fn 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        // The subject is a call of the declared capability's list operation:
138        // a capability nobody exercised is not one this session saw served.
139        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
158/// `TOOL-016`: a server serving tools declares the `tools` capability.
159pub(in crate::checks) fn tools_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
160    answered_undeclared(context, sink, "tools", &["tools/list", "tools/call"]);
161}
162
163/// `RES-012`: a server serving resources declares the `resources` capability.
164pub(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
177/// `PROM-012`: a server serving prompts declares the `prompts` capability.
178pub(in crate::checks) fn prompts_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
179    answered_undeclared(context, sink, "prompts", &["prompts/list", "prompts/get"]);
180}
181
182/// `TOOL-020`: a declared `tools` capability answers `tools/list`.
183pub(in crate::checks) fn tools_list_implemented(
184    context: &TraceContext<'_>,
185    sink: &mut FindingSink,
186) {
187    declared_list_answered(context, sink, "tools");
188}
189
190/// `RES-013`: a declared `resources` capability answers `resources/list`.
191pub(in crate::checks) fn resources_list_implemented(
192    context: &TraceContext<'_>,
193    sink: &mut FindingSink,
194) {
195    declared_list_answered(context, sink, "resources");
196}
197
198/// `PROM-013`: a declared `prompts` capability answers `prompts/list`.
199pub(in crate::checks) fn prompts_list_implemented(
200    context: &TraceContext<'_>,
201    sink: &mut FindingSink,
202) {
203    declared_list_answered(context, sink, "prompts");
204}
205
206/// `TOOL-038`: a server embedding resources in tool results declares `resources`.
207///
208/// The observable form of "uses embedded resources" is a `resource` content block
209/// in a `tools/call` result — the same evidence `tools.embedded-resource-capability`
210/// reads at `2025-11-25`, which cannot be reused because it resolves the
211/// declaration through the removed handshake.
212pub(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}