Skip to main content

vent_mcp/
types.rs

1//! Shared data model for vent-mcp requests, responses, and stored events.
2//!
3//! This module defines the narrow contract between MCP tools, the optional CLI,
4//! and delivery sinks. The types keep the tool surface intentionally small: a
5//! caller can send a message, optionally choose a configured channel, and receive
6//! a concise acknowledgement or first delivery error. Event construction also
7//! limits project context to the directory name so feedback can be useful without
8//! recording a full local path.
9
10use std::env;
11
12use chrono::{DateTime, Utc};
13use rmcp::schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17/// One configured vent channel exposed to MCP clients.
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
19pub struct ChannelInfo {
20    pub name: String,
21    pub description: String,
22}
23
24/// Channel catalog returned by the `list_channels` MCP tool.
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
26#[serde(rename_all = "camelCase")]
27pub struct ListChannelsOutput {
28    pub default_channel: String,
29    pub channels: Vec<ChannelInfo>,
30}
31
32/// `vent` tool input when multiple channels are configured.
33#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
34pub struct VentInput {
35    pub message: String,
36    pub channel: Option<String>,
37}
38
39/// Narrowed `vent` tool input when only the default channel exists.
40#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
41pub struct VentDefaultChannelInput {
42    pub message: String,
43}
44
45/// MCP acknowledgement for an accepted or rejected vent request.
46#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
47#[serde(rename_all = "camelCase")]
48pub struct VentOutput {
49    pub ok: bool,
50    /// Short trace id for the accepted event, not a deduplication key.
51    pub event_id: String,
52    pub channel: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub error: Option<String>,
55}
56
57/// Per-sink delivery outcome produced while fanning one event to a channel route.
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
59pub struct SinkDeliveryStatus {
60    pub sink: String,
61    pub ok: bool,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub message: Option<String>,
64}
65
66/// Short base62 trace id assigned once per accepted vent event.
67#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
68#[serde(transparent)]
69pub struct EventId(String);
70
71impl EventId {
72    #[must_use]
73    fn new_random() -> Self {
74        Self(short_event_id())
75    }
76
77    #[cfg(test)]
78    #[must_use]
79    fn as_str(&self) -> &str {
80        &self.0
81    }
82}
83
84impl std::fmt::Display for EventId {
85    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        formatter.write_str(&self.0)
87    }
88}
89
90/// Canonical vent record written to JSONL and rendered into webhook payloads.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct VentEvent {
93    pub id: EventId,
94    pub timestamp: DateTime<Utc>,
95    pub channel: String,
96    pub message: String,
97    pub project: String,
98}
99
100impl VentEvent {
101    /// Creates a timestamped event with a fresh ID for dispatch to all sinks.
102    ///
103    /// The caller supplies only the already-validated channel, message, and
104    /// project label. IDs and timestamps are assigned here so every sink receives
105    /// the same immutable record.
106    #[must_use]
107    pub fn new(channel: String, message: String, project: String) -> Self {
108        Self {
109            id: EventId::new_random(),
110            timestamp: Utc::now(),
111            channel,
112            message,
113            project,
114        }
115    }
116}
117
118/// Returns the first sink failure message for caller-facing error reporting.
119#[must_use]
120pub fn first_delivery_error(statuses: &[SinkDeliveryStatus]) -> Option<String> {
121    statuses.iter().find(|status| !status.ok).map(|status| {
122        status
123            .message
124            .clone()
125            .unwrap_or_else(|| format!("{} failed", status.sink))
126    })
127}
128
129fn short_event_id() -> String {
130    const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
131    const ID_LEN: usize = 8;
132
133    let mut value = Uuid::new_v4().as_u128();
134    let mut id = String::with_capacity(ID_LEN);
135    for _ in 0..ID_LEN {
136        let index = (value % ALPHABET.len() as u128) as usize;
137        id.push(ALPHABET[index] as char);
138        value /= ALPHABET.len() as u128;
139    }
140    id
141}
142
143/// Returns a privacy-preserving project label based on the current directory.
144///
145/// Only the final path component is used, which gives receivers enough context
146/// to understand where feedback came from without exposing the full workspace
147/// path. If the directory cannot be read, the label falls back to `unknown`.
148#[must_use]
149pub fn project_name_from_current_dir() -> String {
150    env::current_dir()
151        .ok()
152        .and_then(|path| {
153            path.file_name()
154                .map(|name| name.to_string_lossy().to_string())
155        })
156        .filter(|name| !name.trim().is_empty())
157        .unwrap_or_else(|| "unknown".to_string())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::VentEvent;
163
164    #[test]
165    fn event_ids_are_short_base62_strings() {
166        let event = VentEvent::new(
167            "general".to_string(),
168            "Something happened.".to_string(),
169            "vent-mcp".to_string(),
170        );
171
172        assert_eq!(event.id.as_str().len(), 8);
173        assert!(event
174            .id
175            .as_str()
176            .chars()
177            .all(|character| character.is_ascii_alphanumeric()));
178    }
179}