ai_memory/harness.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7 Track B (B4) — Harness detection from MCP `clientInfo.name`.
5//!
6//! When an MCP client opens a JSON-RPC `initialize` handshake, it sends
7//! a `clientInfo` object with a `name` field that identifies the
8//! harness — e.g. `"claude-code"`, `"codex"`, `"cursor"`, `"cline"`.
9//! The substrate captures that string at handshake time
10//! (`crate::mcp::serve_stdio` already stashes it as `mcp_client_name`)
11//! and then needs to make a behavioural decision: does this harness
12//! surface tools registered *after* the initial `tools/list` to the
13//! LLM, or does it cache the manifest at session start?
14//!
15//! Track B's runtime loaders (B1 `memory_load_family`, B2
16//! `memory_smart_load`) only deliver value on harnesses with deferred
17//! registration — on eager-load harnesses (Codex, Cursor, etc.) they
18//! still return the schemas, but the LLM has to know it should ask the
19//! operator to restart with `--profile <family>` rather than expect
20//! the new tools to appear mid-session. The harness layer carries
21//! that bit so the loaders, the capabilities-v3 response, and the
22//! `to_invoke` helper text can all agree on the contract per harness.
23//!
24//! # Compatibility matrix
25//!
26//! Source of truth: `docs/v0.7/compatibility-matrix.html` (shipped
27//! with Track D2). Today only Claude Code supports deferred-tool
28//! registration via its `ToolSearch` mechanism. Every other harness
29//! defaults to **false** (conservative) so the LLM doesn't promise an
30//! end-user that a tool will appear mid-session and then strand the
31//! conversation when the cached manifest never gets refreshed.
32//!
33//! Unknown harnesses (`Generic(name)`) also default to false. If an
34//! operator runs the substrate behind a custom MCP client that *does*
35//! support deferred registration, they can either (a) add the harness
36//! name to `Harness::detect`'s match arms or (b) wait for a future
37//! release that exposes a runtime override (out of scope for B4).
38//!
39//! # Wire surface
40//!
41//! The detected harness's `supports_deferred_registration()` value is
42//! surfaced verbatim in the v3 capabilities response as the top-level
43//! field `your_harness_supports_deferred_registration` (boolean). When
44//! no `clientInfo` was provided (e.g. an HTTP caller or a malformed
45//! handshake), the field is omitted (`Option::None` +
46//! `skip_serializing_if`) so legacy callers see no schema drift.
47//!
48//! # Example
49//!
50//! ```
51//! use ai_memory::harness::Harness;
52//!
53//! // Fuzzy + case-insensitive substring matching.
54//! assert_eq!(Harness::detect("claude-code"), Harness::ClaudeCode);
55//! assert_eq!(Harness::detect("Claude Code"), Harness::ClaudeCode);
56//! assert_eq!(Harness::detect("claude_code"), Harness::ClaudeCode);
57//!
58//! assert!(Harness::ClaudeCode.supports_deferred_registration());
59//! assert!(!Harness::Codex.supports_deferred_registration());
60//! ```
61
62use serde::{Deserialize, Serialize};
63
64/// MCP harness detected from the `initialize.clientInfo.name` field.
65///
66/// The variants cover the harnesses called out in
67/// `docs/v0.7/compatibility-matrix.html` (Track D2). Unknown harnesses
68/// fall through to `Generic(String)` carrying the original
69/// `clientInfo.name` so downstream logging / metrics can attribute
70/// behaviour to the unrecognised client without losing the name.
71///
72/// `serde` uses `snake_case` so the wire shape matches the rest of the
73/// v3 capabilities document. `Generic` carries an inner string and
74/// serialises as `{"generic": "<name>"}` per serde's default
75/// externally-tagged enum representation.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum Harness {
79 /// Anthropic Claude Code — supports deferred-tool registration via
80 /// `ToolSearch`. The only first-class harness today where B1's
81 /// `memory_load_family` actually surfaces new tools mid-session.
82 ClaudeCode,
83 /// OpenAI Codex CLI — eager-load only.
84 Codex,
85 /// Anysphere Cursor — MCP via stdio, eager-load only.
86 Cursor,
87 /// VS Code Cline extension — MCP via stdio, eager-load only.
88 Cline,
89 /// Continue.dev (VS Code / JetBrains) — MCP via stdio, eager-load only.
90 Continue,
91 /// Aider CLI pair-programmer — MCP via stdio, eager-load only.
92 Aider,
93 /// Block / Square Goose — MCP via stdio, eager-load only.
94 Goose,
95 /// Anthropic Claude Desktop — eager-load only.
96 ClaudeDesktop,
97 /// Unknown harness; carries the original `clientInfo.name` so the
98 /// operator can grep logs for "this is the harness I forgot to
99 /// register" without losing the identifying string.
100 Generic(String),
101}
102
103impl Harness {
104 /// Detect a harness from the `clientInfo.name` field of the MCP
105 /// `initialize` request.
106 ///
107 /// Matching is **case-insensitive** and **fuzzy substring** — the
108 /// raw name is normalised by lower-casing and stripping the
109 /// punctuation harnesses use as separators (`-`, `_`, ` `,
110 /// `.`) before comparison. So `"claude-code"`, `"Claude Code"`,
111 /// `"claude_code"`, and `"CLAUDE.CODE"` all detect as
112 /// `Harness::ClaudeCode`.
113 ///
114 /// Unknown names round-trip into `Generic(<original>)` preserving
115 /// the input verbatim so logging / metrics keep a useful label.
116 #[must_use]
117 pub fn detect(client_name: &str) -> Self {
118 let normalised = client_name
119 .chars()
120 .filter(|c| !matches!(c, '-' | '_' | ' ' | '.'))
121 .flat_map(char::to_lowercase)
122 .collect::<String>();
123
124 // Order matters: `claudecode` and `claudedesktop` both contain
125 // `claude`, so the more-specific match must come first. The
126 // substring check is `contains`, so `claudecodecli` (a
127 // hypothetical wrapper) still detects as ClaudeCode.
128 if normalised.contains("claudecode") {
129 Self::ClaudeCode
130 } else if normalised.contains("claudedesktop") {
131 Self::ClaudeDesktop
132 } else if normalised.contains("codex") {
133 Self::Codex
134 } else if normalised.contains("cursor") {
135 Self::Cursor
136 } else if normalised.contains("cline") {
137 Self::Cline
138 } else if normalised.contains("continue") {
139 Self::Continue
140 } else if normalised.contains("aider") {
141 Self::Aider
142 } else if normalised.contains("goose") {
143 Self::Goose
144 } else {
145 Self::Generic(client_name.to_string())
146 }
147 }
148
149 /// Whether this harness exposes tools registered *after* the
150 /// initial `tools/list` to the LLM mid-session.
151 ///
152 /// `true` only for harnesses with documented deferred-tool
153 /// registration support. Today that's just Claude Code via its
154 /// `ToolSearch` mechanism. Every other known harness eager-loads
155 /// the manifest at session start and won't surface a tool added
156 /// later — so B1's `memory_load_family` falls back to a
157 /// "restart with `--profile <family>`" hint on those harnesses.
158 ///
159 /// **Default for unknown harnesses (`Generic`)**: `false`
160 /// (conservative). It's better to under-promise than to claim a
161 /// tool will appear and have the conversation strand on a stale
162 /// cached manifest.
163 #[must_use]
164 pub fn supports_deferred_registration(&self) -> bool {
165 match self {
166 Self::ClaudeCode => true,
167 Self::Codex
168 | Self::Cursor
169 | Self::Cline
170 | Self::Continue
171 | Self::Aider
172 | Self::Goose
173 | Self::ClaudeDesktop
174 | Self::Generic(_) => false,
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 // -----------------------------------------------------------------
184 // detect() — Claude Code matches under every common spelling.
185 // -----------------------------------------------------------------
186 #[test]
187 fn detect_claude_code_canonical_kebab() {
188 assert_eq!(Harness::detect("claude-code"), Harness::ClaudeCode);
189 }
190
191 #[test]
192 fn detect_claude_code_title_case_with_space() {
193 assert_eq!(Harness::detect("Claude Code"), Harness::ClaudeCode);
194 }
195
196 #[test]
197 fn detect_claude_code_snake_case() {
198 assert_eq!(Harness::detect("claude_code"), Harness::ClaudeCode);
199 }
200
201 #[test]
202 fn detect_claude_code_screaming_with_dots() {
203 assert_eq!(Harness::detect("CLAUDE.CODE"), Harness::ClaudeCode);
204 }
205
206 #[test]
207 fn detect_claude_code_versioned_suffix() {
208 // A real-world harness sometimes ships its name as
209 // `claude-code/1.2.3` or `claude-code-cli`; substring match
210 // catches both without per-version updates.
211 assert_eq!(Harness::detect("claude-code-cli"), Harness::ClaudeCode);
212 assert_eq!(Harness::detect("claude-code/1.2.3"), Harness::ClaudeCode);
213 }
214
215 // -----------------------------------------------------------------
216 // detect() — every other named harness round-trips.
217 // -----------------------------------------------------------------
218 #[test]
219 fn detect_codex_variants() {
220 assert_eq!(Harness::detect("codex"), Harness::Codex);
221 assert_eq!(Harness::detect("Codex"), Harness::Codex);
222 assert_eq!(Harness::detect("codex-cli"), Harness::Codex);
223 assert_eq!(Harness::detect("openai-codex"), Harness::Codex);
224 }
225
226 #[test]
227 fn detect_cursor_variants() {
228 assert_eq!(Harness::detect("cursor"), Harness::Cursor);
229 assert_eq!(Harness::detect("Cursor"), Harness::Cursor);
230 assert_eq!(Harness::detect("cursor-mcp"), Harness::Cursor);
231 }
232
233 #[test]
234 fn detect_cline_variants() {
235 assert_eq!(Harness::detect("cline"), Harness::Cline);
236 assert_eq!(Harness::detect("Cline"), Harness::Cline);
237 assert_eq!(Harness::detect("vscode-cline"), Harness::Cline);
238 }
239
240 #[test]
241 fn detect_continue_variants() {
242 assert_eq!(Harness::detect("continue"), Harness::Continue);
243 assert_eq!(Harness::detect("Continue"), Harness::Continue);
244 assert_eq!(Harness::detect("continue.dev"), Harness::Continue);
245 }
246
247 #[test]
248 fn detect_aider_variants() {
249 assert_eq!(Harness::detect("aider"), Harness::Aider);
250 assert_eq!(Harness::detect("Aider"), Harness::Aider);
251 assert_eq!(Harness::detect("aider-cli"), Harness::Aider);
252 }
253
254 #[test]
255 fn detect_goose_variants() {
256 assert_eq!(Harness::detect("goose"), Harness::Goose);
257 assert_eq!(Harness::detect("Goose"), Harness::Goose);
258 assert_eq!(Harness::detect("block-goose"), Harness::Goose);
259 }
260
261 #[test]
262 fn detect_claude_desktop_variants() {
263 // Claude Desktop must NOT be misclassified as ClaudeCode even
264 // though its name is a superstring of "claude". The match
265 // ordering in `detect()` checks `claudecode` first.
266 assert_eq!(Harness::detect("claude-desktop"), Harness::ClaudeDesktop);
267 assert_eq!(Harness::detect("Claude Desktop"), Harness::ClaudeDesktop);
268 assert_eq!(Harness::detect("ClaudeDesktop"), Harness::ClaudeDesktop);
269 }
270
271 // -----------------------------------------------------------------
272 // detect() — unknown names round-trip into Generic preserving
273 // the original string verbatim (no normalisation, no truncation).
274 // -----------------------------------------------------------------
275 #[test]
276 fn detect_unknown_preserves_original_name() {
277 let raw = "MyCustomMcpClient/0.1";
278 let h = Harness::detect(raw);
279 match h {
280 Harness::Generic(s) => assert_eq!(s, raw),
281 other => panic!("expected Generic; got {other:?}"),
282 }
283 }
284
285 #[test]
286 fn detect_empty_name_is_generic() {
287 // An empty clientInfo.name is malformed but defensively
288 // mapped to Generic("") rather than panicking.
289 assert_eq!(Harness::detect(""), Harness::Generic(String::new()));
290 }
291
292 // -----------------------------------------------------------------
293 // supports_deferred_registration — compat matrix per docs/v0.7.
294 // -----------------------------------------------------------------
295 #[test]
296 fn deferred_registration_only_claude_code_today() {
297 assert!(Harness::ClaudeCode.supports_deferred_registration());
298
299 assert!(!Harness::Codex.supports_deferred_registration());
300 assert!(!Harness::Cursor.supports_deferred_registration());
301 assert!(!Harness::Cline.supports_deferred_registration());
302 assert!(!Harness::Continue.supports_deferred_registration());
303 assert!(!Harness::Aider.supports_deferred_registration());
304 assert!(!Harness::Goose.supports_deferred_registration());
305 assert!(!Harness::ClaudeDesktop.supports_deferred_registration());
306 }
307
308 #[test]
309 fn deferred_registration_unknown_defaults_false() {
310 // Conservative: unknown harnesses default to false so we
311 // never promise mid-session tool surfacing we can't deliver.
312 assert!(
313 !Harness::Generic("some-random-mcp-client".to_string())
314 .supports_deferred_registration()
315 );
316 assert!(!Harness::Generic(String::new()).supports_deferred_registration());
317 }
318
319 // -----------------------------------------------------------------
320 // serde — round-trip through JSON for the wire shape.
321 // -----------------------------------------------------------------
322 #[test]
323 fn serde_round_trips_named_variants() {
324 let h = Harness::ClaudeCode;
325 let s = serde_json::to_string(&h).expect("serialize");
326 assert_eq!(s, "\"claude_code\"");
327 let back: Harness = serde_json::from_str(&s).expect("deserialize");
328 assert_eq!(back, Harness::ClaudeCode);
329 }
330
331 #[test]
332 fn serde_round_trips_generic_variant() {
333 let h = Harness::Generic("foo".to_string());
334 let s = serde_json::to_string(&h).expect("serialize");
335 let back: Harness = serde_json::from_str(&s).expect("deserialize");
336 assert_eq!(back, h);
337 }
338}