Skip to main content

context_harness/
profiles.rs

1//! Profile system for reusable MCP prompts and personas.
2//!
3//! Profiles are named personas that combine a system prompt, scoped tools, and
4//! optional dynamic context injection. They enable "assume a role" workflows
5//! in Cursor, Claude Desktop, and other MCP clients.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ┌──────────────────────────────────────────┐
11//! │            ProfileRegistry                 │
12//! │  ┌─────────┐ ┌─────────┐ ┌────────────┐ │
13//! │  │  TOML   │ │  Lua    │ │  Custom    │ │
14//! │  │ Inline  │ │ Script  │ │ (Rust)     │ │
15//! │  └─────────┘ └─────────┘ └────────────┘ │
16//! └──────────────┬───────────────────────────┘
17//!                ▼
18//!   GET /profiles/list  ·  POST /profiles/{name}/prompt
19//! ```
20//!
21//! # Profile Sources
22//!
23//! | Source | Config Key | Struct |
24//! |--------|------------|--------|
25//! | Inline TOML | `[profiles.inline.<name>]` | [`TomlProfile`] |
26//! | Lua script | `[profiles.script.<name>]` | `LuaProfileAdapter` (in [`crate::profile_script`]) |
27//! | Custom Rust | `registry.register(...)` | User-defined [`Profile`] impl |
28//!
29//! # Usage
30//!
31//! ```rust
32//! use context_harness::profiles::{ProfileRegistry, TomlProfile};
33//!
34//! let mut profiles = ProfileRegistry::new();
35//! profiles.register(Box::new(TomlProfile::new(
36//!     "reviewer".to_string(),
37//!     "Reviews code against conventions".to_string(),
38//!     vec!["search".to_string(), "get".to_string()],
39//!     "You are a senior code reviewer.".to_string(),
40//! )));
41//! ```
42//!
43//! See the profiles documentation for the full specification.
44
45use anyhow::Result;
46use async_trait::async_trait;
47use serde::Serialize;
48use serde_json::Value;
49
50use crate::config::Config;
51use crate::traits::ToolContext;
52
53// ═══════════════════════════════════════════════════════════════════════
54// Profile Trait
55// ═══════════════════════════════════════════════════════════════════════
56
57/// A reusable persona that provides a system prompt and tool scoping.
58///
59/// Implement this trait to create a custom profile in Rust. Profiles are
60/// registered in a [`ProfileRegistry`] and exposed via `GET /profiles/list`
61/// for discovery and `POST /profiles/{name}/prompt` for resolution.
62///
63/// # Lifecycle
64///
65/// 1. The profile is registered via [`ProfileRegistry::register`].
66/// 2. At discovery time, [`name`](Profile::name), [`description`](Profile::description),
67///    [`tools`](Profile::tools), and [`arguments`](Profile::arguments) are called.
68/// 3. When a user selects the profile, [`resolve`](Profile::resolve) is called
69///    with any provided arguments and a [`ToolContext`] for KB access.
70///
71/// # Example
72///
73/// ```rust
74/// use async_trait::async_trait;
75/// use anyhow::Result;
76/// use serde_json::{json, Value};
77/// use context_harness::profiles::{Profile, ProfilePrompt, ProfileArgument};
78/// use context_harness::traits::ToolContext;
79///
80/// pub struct ArchitectProfile;
81///
82/// #[async_trait]
83/// impl Profile for ArchitectProfile {
84///     fn name(&self) -> &str { "architect" }
85///     fn description(&self) -> &str { "Answers architecture questions" }
86///     fn tools(&self) -> Vec<String> { vec!["search".into(), "get".into()] }
87///
88///     async fn resolve(&self, _args: Value, _ctx: &ToolContext) -> Result<ProfilePrompt> {
89///         Ok(ProfilePrompt {
90///             system: "You are a software architect.".to_string(),
91///             tools: self.tools(),
92///             messages: vec![],
93///         })
94///     }
95/// }
96/// ```
97#[async_trait]
98pub trait Profile: Send + Sync {
99    /// Returns the profile's unique name (URL-safe, e.g. `"code-reviewer"`).
100    fn name(&self) -> &str;
101
102    /// Returns a one-line description for profile discovery.
103    fn description(&self) -> &str;
104
105    /// Returns the list of tool names this profile exposes.
106    fn tools(&self) -> Vec<String>;
107
108    /// Returns the profile's source type: `"toml"`, `"lua"`, or `"rust"`.
109    fn source(&self) -> &str {
110        "rust"
111    }
112
113    /// Returns the arguments this profile accepts (may be empty).
114    ///
115    /// Arguments are shown to the user in MCP prompt selection UIs
116    /// and passed to [`resolve`](Profile::resolve) as a JSON object.
117    fn arguments(&self) -> Vec<ProfileArgument> {
118        vec![]
119    }
120
121    /// Resolve the profile's prompt, optionally using the [`ToolContext`]
122    /// for dynamic context injection (e.g., pre-searching the KB).
123    ///
124    /// # Arguments
125    ///
126    /// * `args` — User-provided argument values (JSON object).
127    /// * `ctx` — Bridge to the Context Harness knowledge base.
128    ///
129    /// # Returns
130    ///
131    /// A [`ProfilePrompt`] containing the system prompt, tool list,
132    /// and optional pre-injected messages.
133    async fn resolve(&self, args: Value, ctx: &ToolContext) -> Result<ProfilePrompt>;
134}
135
136// ═══════════════════════════════════════════════════════════════════════
137// Data Types
138// ═══════════════════════════════════════════════════════════════════════
139
140/// An argument that an profile accepts.
141///
142/// Arguments are shown in MCP prompt selection UIs. When the user
143/// selects a profile, argument values are collected and passed to
144/// [`Profile::resolve`].
145#[derive(Debug, Clone, Serialize)]
146pub struct ProfileArgument {
147    /// Argument name (e.g. `"service"`).
148    pub name: String,
149    /// Description shown to the user.
150    pub description: String,
151    /// Whether this argument must be provided.
152    pub required: bool,
153}
154
155/// A resolved profile prompt ready for the LLM.
156///
157/// Returned by [`Profile::resolve`]. The client (Cursor, Claude, etc.)
158/// uses this to configure the LLM conversation.
159#[derive(Debug, Clone, Serialize)]
160pub struct ProfilePrompt {
161    /// The system prompt text.
162    pub system: String,
163    /// Which tools should be visible for this profile.
164    pub tools: Vec<String>,
165    /// Optional additional messages to inject at conversation start.
166    #[serde(skip_serializing_if = "Vec::is_empty")]
167    pub messages: Vec<PromptMessage>,
168}
169
170/// A message to inject into the conversation.
171///
172/// Used by profiles that want to pre-populate context (e.g., pre-fetched
173/// search results) or provide an initial assistant greeting.
174#[derive(Debug, Clone, Serialize)]
175pub struct PromptMessage {
176    /// Message role: `"user"`, `"assistant"`, or `"system"`.
177    pub role: String,
178    /// Message content.
179    pub content: String,
180}
181
182/// Serializable profile info for the `/profiles/list` endpoint.
183#[derive(Debug, Clone, Serialize)]
184pub struct ProfileInfo {
185    /// Profile name (used as URL path parameter).
186    pub name: String,
187    /// One-line description.
188    pub description: String,
189    /// Tools this profile uses.
190    pub tools: Vec<String>,
191    /// Source type: `"toml"`, `"lua"`, or `"rust"`.
192    pub source: String,
193    /// Arguments this profile accepts.
194    pub arguments: Vec<ProfileArgument>,
195}
196
197// ═══════════════════════════════════════════════════════════════════════
198// TomlProfile
199// ═══════════════════════════════════════════════════════════════════════
200
201/// An profile defined inline in TOML configuration.
202///
203/// The simplest profile type — has a static system prompt and fixed tool
204/// list. No dynamic context injection or arguments.
205///
206/// Created automatically by [`ProfileRegistry::from_config`] for each
207/// `[profiles.inline.<name>]` entry.
208pub struct TomlProfile {
209    name: String,
210    description: String,
211    tools: Vec<String>,
212    system_prompt: String,
213}
214
215impl TomlProfile {
216    /// Create a new inline TOML profile.
217    pub fn new(
218        name: String,
219        description: String,
220        tools: Vec<String>,
221        system_prompt: String,
222    ) -> Self {
223        Self {
224            name,
225            description,
226            tools,
227            system_prompt,
228        }
229    }
230}
231
232#[async_trait]
233impl Profile for TomlProfile {
234    fn name(&self) -> &str {
235        &self.name
236    }
237
238    fn description(&self) -> &str {
239        &self.description
240    }
241
242    fn tools(&self) -> Vec<String> {
243        self.tools.clone()
244    }
245
246    fn source(&self) -> &str {
247        "toml"
248    }
249
250    async fn resolve(&self, _args: Value, _ctx: &ToolContext) -> Result<ProfilePrompt> {
251        Ok(ProfilePrompt {
252            system: self.system_prompt.clone(),
253            tools: self.tools.clone(),
254            messages: vec![],
255        })
256    }
257}
258
259// ═══════════════════════════════════════════════════════════════════════
260// ProfileRegistry
261// ═══════════════════════════════════════════════════════════════════════
262
263/// Registry for profiles (TOML, Lua, and custom Rust).
264///
265/// Use [`ProfileRegistry::from_config`] to create a registry pre-loaded
266/// with all profiles from the config file, then optionally call
267/// [`register`](ProfileRegistry::register) to add custom Rust profiles.
268///
269/// # Example
270///
271/// ```rust
272/// use context_harness::profiles::ProfileRegistry;
273///
274/// let mut profiles = ProfileRegistry::new();
275/// // profiles.register(Box::new(MyProfile::new()));
276/// ```
277pub struct ProfileRegistry {
278    profiles: Vec<Box<dyn Profile>>,
279}
280
281impl ProfileRegistry {
282    /// Create an empty profile registry.
283    pub fn new() -> Self {
284        Self {
285            profiles: Vec::new(),
286        }
287    }
288
289    /// Create a registry pre-loaded with all profiles from config.
290    ///
291    /// Loads inline TOML profiles from `[profiles.inline.*]` entries.
292    /// Lua script profiles from `[profiles.script.*]` are loaded separately
293    /// via [`crate::profile_script::load_profile_definitions`].
294    pub fn from_config(config: &Config) -> Result<Self> {
295        let mut registry = Self::new();
296
297        // Load inline TOML profiles
298        for (name, cfg) in &config.profiles.inline {
299            registry.register(Box::new(TomlProfile::new(
300                name.clone(),
301                cfg.description.clone(),
302                cfg.tools.clone(),
303                cfg.system_prompt.clone(),
304            )));
305        }
306
307        // Lua profiles are loaded in profile_script::load_profile_definitions
308        // and registered by the caller (server.rs / main.rs).
309
310        Ok(registry)
311    }
312
313    /// Register a profile.
314    pub fn register(&mut self, profile: Box<dyn Profile>) {
315        self.profiles.push(profile);
316    }
317
318    /// Get all registered profiles.
319    pub fn profiles(&self) -> &[Box<dyn Profile>] {
320        &self.profiles
321    }
322
323    /// Find a profile by name.
324    pub fn find(&self, name: &str) -> Option<&dyn Profile> {
325        self.profiles
326            .iter()
327            .find(|a| a.name() == name)
328            .map(|a| a.as_ref())
329    }
330
331    /// Check if the registry is empty.
332    #[allow(dead_code)]
333    pub fn is_empty(&self) -> bool {
334        self.profiles.is_empty()
335    }
336
337    /// Return the count of registered profiles.
338    pub fn len(&self) -> usize {
339        self.profiles.len()
340    }
341}
342
343impl Default for ProfileRegistry {
344    fn default() -> Self {
345        Self::new()
346    }
347}