Skip to main content

sim_lib_mcp/
surface.rs

1use std::collections::BTreeMap;
2
3use sim_kernel::{CapabilityName, Cx, Error, Expr, Result, ShapeRef, Symbol};
4
5use crate::{McpNativeCard, McpProfile, native_surface_rows};
6
7/// Origin record that a projected [`McpSurfaceCard`] was derived from.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum McpSurfaceSource {
10    /// The row was projected from a native browse [`McpNativeCard`].
11    NativeCard,
12    /// The row was projected from an agent skill card.
13    SkillCard,
14}
15
16impl McpSurfaceSource {
17    /// Returns the stable wire symbol naming this source.
18    pub fn as_symbol(&self) -> Symbol {
19        Symbol::new(match self {
20            Self::NativeCard => "native-card",
21            Self::SkillCard => "skill-card",
22        })
23    }
24}
25
26/// MCP role that a projected surface row plays for a client.
27#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
28pub enum McpSurfaceRole {
29    /// A callable tool.
30    Tool,
31    /// A readable resource.
32    Resource,
33    /// A prompt template.
34    Prompt,
35    /// A sampling model surface.
36    Model,
37}
38
39impl McpSurfaceRole {
40    /// Returns the stable wire symbol naming this role.
41    pub fn as_symbol(&self) -> Symbol {
42        Symbol::new(match self {
43            Self::Tool => "tool",
44            Self::Resource => "resource",
45            Self::Prompt => "prompt",
46            Self::Model => "model",
47        })
48    }
49}
50
51/// Streaming behavior a surface row advertises to clients.
52#[derive(Clone, Debug, Default, PartialEq, Eq)]
53pub enum McpStreamPolicy {
54    /// No streaming; a single response is returned.
55    #[default]
56    None,
57    /// The call emits incremental progress notifications.
58    Progress,
59    /// The call emits a stream of data chunks.
60    DataStream,
61}
62
63impl McpStreamPolicy {
64    /// Returns the stable wire symbol naming this stream policy.
65    pub fn as_symbol(&self) -> Symbol {
66        Symbol::new(match self {
67            Self::None => "none",
68            Self::Progress => "progress",
69            Self::DataStream => "data-stream",
70        })
71    }
72}
73
74/// Whether an [`McpAnnotation`] is exposed to clients or kept internal.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum McpAnnotationVisibility {
77    /// The annotation is projected onto the public surface.
78    Public,
79    /// The annotation is retained internally and never surfaced.
80    Private,
81}
82
83/// Key/value annotation attached to a surface row, with a visibility scope.
84#[derive(Clone, Debug, PartialEq)]
85pub struct McpAnnotation {
86    /// Annotation key.
87    pub key: Symbol,
88    /// Annotation value expression.
89    pub value: Expr,
90    /// Whether the annotation is surfaced to clients.
91    pub visibility: McpAnnotationVisibility,
92}
93
94impl McpAnnotation {
95    /// Builds a [`McpAnnotationVisibility::Public`] annotation.
96    pub fn public(key: impl Into<Symbol>, value: Expr) -> Self {
97        Self {
98            key: key.into(),
99            value,
100            visibility: McpAnnotationVisibility::Public,
101        }
102    }
103
104    /// Builds a [`McpAnnotationVisibility::Private`] annotation.
105    pub fn private(key: impl Into<Symbol>, value: Expr) -> Self {
106        Self {
107            key: key.into(),
108            value,
109            visibility: McpAnnotationVisibility::Private,
110        }
111    }
112}
113
114/// A single redacted MCP surface row projected from a native or skill card.
115#[derive(Clone)]
116pub struct McpSurfaceCard {
117    /// Stable identifier for the row.
118    pub id: String,
119    /// Card the row was projected from.
120    pub source: McpSurfaceSource,
121    /// MCP role the row plays.
122    pub role: McpSurfaceRole,
123    /// Public MCP name.
124    pub name: String,
125    /// Backing runtime symbol, when the row maps to one.
126    pub symbol: Option<Symbol>,
127    /// Resource URI, for resource rows.
128    pub uri: Option<String>,
129    /// Human-readable description.
130    pub description: String,
131    /// Input shape, when the row accepts arguments.
132    pub input_shape: Option<ShapeRef>,
133    /// Output shape, when the row declares a result shape.
134    pub output_shape: Option<ShapeRef>,
135    /// Public annotations carried onto the surface.
136    pub annotations: Vec<(Symbol, Expr)>,
137    /// Capabilities required to invoke the row.
138    pub capabilities: Vec<CapabilityName>,
139    /// Streaming behavior advertised by the row.
140    pub stream_policy: McpStreamPolicy,
141}
142
143impl McpSurfaceCard {
144    /// Encodes the row as an `mcp/surface-card` [`Expr`] map.
145    pub fn to_expr(&self, cx: &mut Cx) -> Result<Expr> {
146        Ok(Expr::Map(vec![
147            field(
148                "kind",
149                Expr::Symbol(Symbol::qualified("mcp", "surface-card")),
150            ),
151            field("id", Expr::String(self.id.clone())),
152            field("source", Expr::Symbol(self.source.as_symbol())),
153            field("role", Expr::Symbol(self.role.as_symbol())),
154            field("name", Expr::String(self.name.clone())),
155            field(
156                "symbol",
157                self.symbol.clone().map(Expr::Symbol).unwrap_or(Expr::Nil),
158            ),
159            field(
160                "uri",
161                self.uri
162                    .as_ref()
163                    .map(|uri| Expr::String(uri.clone()))
164                    .unwrap_or(Expr::Nil),
165            ),
166            field("description", Expr::String(self.description.clone())),
167            field("input-shape", shape_expr(cx, &self.input_shape)?),
168            field("output-shape", shape_expr(cx, &self.output_shape)?),
169            field(
170                "annotations",
171                Expr::List(
172                    self.annotations
173                        .iter()
174                        .map(|(key, value)| {
175                            Expr::Map(vec![
176                                field("key", Expr::Symbol(key.clone())),
177                                field("value", value.clone()),
178                            ])
179                        })
180                        .collect(),
181                ),
182            ),
183            field(
184                "capabilities",
185                Expr::List(
186                    self.capabilities
187                        .iter()
188                        .map(|capability| Expr::String(capability.as_str().to_owned()))
189                        .collect(),
190                ),
191            ),
192            field(
193                "stream-policy",
194                Expr::Symbol(self.stream_policy.as_symbol()),
195            ),
196        ]))
197    }
198}
199
200/// Projects native cards into surface rows filtered by `profile`.
201pub fn project_native_surface(
202    native_cards: &[McpNativeCard],
203    profile: &McpProfile,
204) -> Result<Vec<McpSurfaceCard>> {
205    project_surface_rows(native_surface_rows(native_cards)?, profile)
206}
207
208/// Projects native cards plus (when the `skill` feature is on) skill cards into
209/// surface rows filtered by `profile`.
210pub fn project_mcp_surface(
211    cx: &mut Cx,
212    native_cards: &[McpNativeCard],
213    profile: &McpProfile,
214) -> Result<Vec<McpSurfaceCard>> {
215    #[cfg(not(feature = "skill"))]
216    let _ = cx;
217
218    let rows = native_surface_rows(native_cards)?;
219    #[cfg(feature = "skill")]
220    let rows = {
221        let mut rows = rows;
222        rows.extend(crate::skill::skill_surface_rows(cx)?);
223        rows
224    };
225    project_surface_rows(rows, profile)
226}
227
228/// Filters `rows` through `profile` and deduplicates by name, returning the
229/// rows sorted by name; errors on a name collision among allowed rows.
230pub fn project_surface_rows(
231    rows: Vec<McpSurfaceCard>,
232    profile: &McpProfile,
233) -> Result<Vec<McpSurfaceCard>> {
234    let mut by_name = BTreeMap::new();
235    for row in rows.into_iter().filter(|row| profile.allows(row)) {
236        if let Some(existing) = by_name.insert(row.name.clone(), row) {
237            return Err(Error::Eval(format!(
238                "MCP surface name collision for {}",
239                existing.name
240            )));
241        }
242    }
243    Ok(by_name.into_values().collect())
244}
245
246/// Derives a stable, ASCII MCP name from a runtime `symbol`.
247pub fn stable_mcp_name(symbol: &Symbol) -> Result<String> {
248    stable_mcp_name_text(&symbol.to_string())
249}
250
251pub(crate) fn stable_mcp_name_text(text: &str) -> Result<String> {
252    if !text.is_ascii() {
253        return Err(Error::Eval(format!("MCP name {text} is not ASCII")));
254    }
255    let mut output = String::new();
256    let mut last_was_separator = false;
257    for ch in text.chars() {
258        let mapped = match ch {
259            'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' => ch,
260            '/' | '.' => '.',
261            _ => '-',
262        };
263        if mapped == '.' || mapped == '-' {
264            if last_was_separator {
265                continue;
266            }
267            last_was_separator = true;
268        } else {
269            last_was_separator = false;
270        }
271        output.push(mapped);
272    }
273    let output = output.trim_matches(['.', '-']).to_owned();
274    if output.is_empty() {
275        return Err(Error::Eval("MCP name cannot be empty".to_owned()));
276    }
277    Ok(output)
278}
279
280pub(crate) fn public_annotations(annotations: &[McpAnnotation]) -> Vec<(Symbol, Expr)> {
281    annotations
282        .iter()
283        .filter(|annotation| annotation.visibility == McpAnnotationVisibility::Public)
284        .map(|annotation| (annotation.key.clone(), annotation.value.clone()))
285        .collect()
286}
287
288fn shape_expr(cx: &mut Cx, shape: &Option<ShapeRef>) -> Result<Expr> {
289    match shape {
290        Some(shape) => shape.object().as_expr(cx),
291        None => Ok(Expr::Nil),
292    }
293}
294
295use sim_value::build::entry as field;