Skip to main content

yeti_types/plugins/
mcp.rs

1//! MCP tool registry hooks.
2//!
3//! Two hook chains let wasm plugin-apps extend yeti-mcp's tool surface
4//! without depending on yeti-mcp (a host-only crate) directly:
5//!
6//!  - [`LIST_TOOLS_HOOK_CHAIN_NAME`] — accumulator. The host seeds the
7//!    request with the auto-generated inventory; plugins extend the
8//!    list. Final response is the union (host-prefilled +
9//!    plugin-contributed).
10//!  - [`CALL_TOOL_HOOK_CHAIN_NAME`] — dispatcher. yeti-mcp consults
11//!    the chain before its built-in `{table}_{op}` CRUD dispatcher.
12//!    First plugin whose service returns `Some(value)` wins; if every
13//!    plugin returns `None`, the host falls through to the auto-
14//!    inventory dispatch.
15//!
16//! `ToolDefinition` / `ToolAnnotations` mirror the shapes already
17//! defined in `yeti-mcp-domain::types`, but live here so foundation-
18//! layer crates can reference them without violating layering
19//! (yeti-types is L0; yeti-mcp-domain is request-band L3). yeti-mcp
20//! converts host-typed ↔ domain-typed at the dispatch boundary.
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use serde_json::Value;
24use tower::util::BoxCloneSyncService;
25
26use crate::error::YetiError;
27
28/// JSON-as-string wire helper: bincode 1.3 cannot round-trip
29/// `serde_json::Value` because `Value::deserialize` uses
30/// `deserialize_any`, which bincode rejects (`DeserializeAnyNotSupported`).
31/// The hook bridge uses bincode for performance and consistency
32/// with the auth bridges, so any `Value` field we want to carry across
33/// must be encoded as a JSON string and parsed back on the other side.
34///
35/// These helpers are used via `#[serde(with = "value_as_json_string")]`
36/// on the `Value` fields below. The host- and guest-side serializers
37/// both round-trip through the same JSON text, so the wire shape is
38/// stable across the WIT boundary.
39mod value_as_json_string {
40    use super::{Deserialize, Deserializer, Serializer, Value};
41
42    pub(super) fn serialize<S>(value: &Value, serializer: S) -> Result<S::Ok, S::Error>
43    where
44        S: Serializer,
45    {
46        let s = serde_json::to_string(value).map_err(serde::ser::Error::custom)?;
47        serializer.serialize_str(&s)
48    }
49
50    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Value, D::Error>
51    where
52        D: Deserializer<'de>,
53    {
54        let s = String::deserialize(deserializer)?;
55        serde_json::from_str(&s).map_err(serde::de::Error::custom)
56    }
57}
58
59/// Versioned hook chain name for tool-list accumulator services
60/// (ADR-009).
61///
62/// See [`super::oauth::OAUTH_HOOK_CHAIN_NAME`] for the rationale
63/// behind placing the constant in yeti-types rather than in the
64/// owning static plugin (yeti-mcp).
65pub const LIST_TOOLS_HOOK_CHAIN_NAME: &str = "yeti.mcp.list_tools.v1";
66
67/// Versioned hook chain name for tool-call dispatcher services
68/// (ADR-009).
69pub const CALL_TOOL_HOOK_CHAIN_NAME: &str = "yeti.mcp.call_tool.v1";
70
71/// Tower-shaped tool-list accumulator service. Plugins register
72/// `BoxCloneSyncService::new(service_fn(...))` against this type;
73/// yeti-mcp chains every registered service per `tools/list` MCP
74/// request, threading the accumulating tool list through each.
75pub type ListToolsService = BoxCloneSyncService<ListToolsRequest, ListToolsResponse, YetiError>;
76
77/// Tower-shaped tool-call dispatcher service. Plugins register
78/// `BoxCloneSyncService::new(service_fn(...))` against this type;
79/// yeti-mcp consults the chain per `tools/call` MCP request and the
80/// first service to return `Some(value)` wins.
81pub type CallToolService = BoxCloneSyncService<CallToolRequest, CallToolResponse, YetiError>;
82
83/// MCP tool definition — wire-shape mirror of
84/// `yeti_mcp_domain::types::ToolDefinition`.
85///
86/// Kept here so wasm plugin-apps can construct tool definitions
87/// against `yeti_sdk::prelude::plugins::mcp::ToolDefinition` without
88/// pulling in yeti-mcp-domain (which is request-band and not part of
89/// the SDK surface). yeti-mcp converts to/from the domain type at the
90/// dispatch boundary.
91///
92/// Bincode is the on-the-wire format for the hook bridge.
93/// We intentionally do **not** use `#[serde(skip_serializing_if)]` /
94/// `#[serde(rename_all)]` here — bincode is a positional binary
95/// format and omitting fields on serialize but expecting them on
96/// deserialize is a decode error. The JSON-friendly attributes live
97/// on the domain-side `yeti_mcp_domain::types::ToolDefinition` and
98/// are applied at the dispatch boundary in
99/// `yeti_mcp::dev_resource::ext_tool_to_rmcp`.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ToolDefinition {
102    /// Tool name (e.g. `"yeti_hello"`, `"widgets_list"`).
103    pub name: String,
104    /// Human-readable tool description shown in MCP clients.
105    pub description: String,
106    /// JSON Schema describing the tool's arguments. Most tools use
107    /// an object schema (`{"type": "object", "properties": { ... }}`).
108    ///
109    /// Stored as `serde_json::Value` for ergonomic in-process use; on
110    /// the bincode wire it serializes as a JSON string to dodge
111    /// `DeserializeAnyNotSupported`.
112    #[serde(with = "value_as_json_string")]
113    pub input_schema: Value,
114    /// Optional MCP-2025-03-26 behavior hints. `None` is equivalent
115    /// to "all hints unset."
116    pub annotations: Option<ToolAnnotations>,
117}
118
119/// Tool behavior annotations (MCP 2025-03-26) — wire-shape mirror of
120/// `yeti_mcp_domain::types::ToolAnnotations`.
121///
122/// See note on [`ToolDefinition`] for why this struct deliberately
123/// omits `skip_serializing_if` / `rename_all`.
124#[derive(Debug, Clone, Serialize, Deserialize, Default)]
125pub struct ToolAnnotations {
126    /// Tool does not mutate state. Clients may treat the result as
127    /// cacheable.
128    pub read_only_hint: Option<bool>,
129    /// Tool performs a destructive action (delete, drop, etc.).
130    /// Clients should require explicit user confirmation.
131    pub destructive_hint: Option<bool>,
132    /// Calling the tool multiple times with the same arguments yields
133    /// the same effect (no cumulative side effects beyond the first).
134    pub idempotent_hint: Option<bool>,
135}
136
137/// Input to the tool-list accumulator pipeline.
138///
139/// `Serialize` + `Deserialize` derives are wire-shape carriers for
140/// cross-component hook chains via WIT (bincode payload). Each end of
141/// the WIT boundary needs to encode/decode the same struct shape.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ListToolsRequest {
144    /// Deployment hash for the request. Hardcoded `"local"` today until
145    /// Fabric multi-deployment hosting plumbs a real value through.
146    pub deployment_hash: String,
147    /// Accumulator. yeti-mcp pre-fills this with the auto-generated
148    /// inventory before invoking the chain; each plugin in the chain
149    /// receives the (possibly extended) list and returns the next-stage
150    /// version. Plugins typically push their additional tools onto the
151    /// vec and return it.
152    pub tools: Vec<ToolDefinition>,
153}
154
155/// Output of the tool-list accumulator pipeline — the final tool list
156/// returned to the MCP client.
157pub type ListToolsResponse = Vec<ToolDefinition>;
158
159/// Input to the tool-call dispatcher pipeline.
160///
161/// `Serialize` + `Deserialize` derives carry the wire shape.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct CallToolRequest {
164    /// Deployment hash for the request. See [`ListToolsRequest`].
165    pub deployment_hash: String,
166    /// Tool name the client is invoking.
167    pub tool_name: String,
168    /// JSON-shaped tool arguments. yeti-mcp passes the raw client
169    /// payload here; the plugin is responsible for any shape validation.
170    ///
171    /// See note on [`ToolDefinition::input_schema`] for why this is
172    /// bincode-wire-encoded as a JSON string rather than a raw `Value`.
173    #[serde(with = "value_as_json_string")]
174    pub arguments: Value,
175}
176
177/// Output of the tool-call dispatcher pipeline.
178///
179/// - `Some(value)` — this plugin handled the call; `value` is the
180///   tool's response (returned to the MCP client as the tool result).
181/// - `None` — this plugin did not handle the call. yeti-mcp continues
182///   iterating the chain; if every registered plugin returns `None`,
183///   the host falls through to its built-in auto-inventory dispatcher.
184///
185/// Wrapped in a transparent newtype so the inner `Option<String>` can
186/// carry the bincode-safe JSON-text wire encoding of the `Value` (see
187/// [`ToolDefinition::input_schema`] for why bincode can't ride a
188/// `serde_json::Value` directly). [`CallToolResponse::some`] /
189/// [`CallToolResponse::none`] / [`CallToolResponse::value`] are the
190/// construction / read helpers.
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192#[serde(transparent)]
193pub struct CallToolResponse {
194    inner: Option<String>,
195}
196
197impl CallToolResponse {
198    /// `Ok(None)` equivalent — the plugin did not handle the call.
199    /// Yeti-mcp falls through to the next service / auto-inventory.
200    #[must_use]
201    pub const fn none() -> Self {
202        Self { inner: None }
203    }
204
205    /// Wrap a `Value` for the wire. Equivalent to `Some(value)` —
206    /// the plugin handled the call and `value` is the tool's response.
207    #[must_use]
208    pub fn some(value: &Value) -> Self {
209        // serde_json::to_string is infallible on Value — it only fails
210        // for non-serializable types, which Value never is.
211        Self {
212            inner: Some(serde_json::to_string(value).unwrap_or_else(|_| "null".to_owned())),
213        }
214    }
215
216    /// Parse the wire form back into the typed `Option<Value>` the
217    /// host's dispatcher consumes. Decoding errors flatten to `None`
218    /// so a malformed plugin response degrades to "not handled" rather
219    /// than poisoning the chain.
220    #[must_use]
221    pub fn value(&self) -> Option<Value> {
222        self.inner
223            .as_deref()
224            .and_then(|s| serde_json::from_str(s).ok())
225    }
226
227    /// True if this response carries a plugin-supplied value
228    /// (i.e. `Some(...)`).
229    #[must_use]
230    pub const fn is_some(&self) -> bool {
231        self.inner.is_some()
232    }
233
234    /// True if this response is the no-op fall-through (`None`).
235    #[must_use]
236    pub const fn is_none(&self) -> bool {
237        self.inner.is_none()
238    }
239}
240
241impl From<Option<Value>> for CallToolResponse {
242    fn from(value: Option<Value>) -> Self {
243        value.as_ref().map_or_else(Self::none, Self::some)
244    }
245}
246
247impl From<CallToolResponse> for Option<Value> {
248    fn from(resp: CallToolResponse) -> Self {
249        resp.value()
250    }
251}