Skip to main content

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}