Skip to main content

pmcp_server_toolkit/workbook/
mod.rs

1//! Governed-Excel workbook served-tool module (Phase 92,
2//! `bundlesource-served-tool-toolkit-module`).
3//!
4//! This is the toolkit-side home for the served `calculate` / `explain` /
5//! `get_manifest` / `diff_version` / `render_workbook` tools that operate on a
6//! verified [`pmcp_workbook_runtime::WorkbookBundle`] (loaded fail-closed via
7//! the runtime's `BundleSource` + `BundleLoader`).
8//!
9//! # Domain failure vs infrastructure failure (Codex LOW)
10//!
11//! The served tools draw a sharp line between two failure classes:
12//!
13//! - A **domain failure** (invalid input, an out-of-range / non-finite output,
14//!   a strict-constant override) is NOT a protocol error. It returns
15//!   `isError:true` INSIDE `structuredContent` via
16//!   [`error::to_iserror_result`] so the MCP App widget can read a stable,
17//!   machine-actionable repair code — never an `Err(pmcp::Error)`.
18//! - An **infrastructure failure** (a poisoned/malformed in-memory bundle state,
19//!   a resource-handler internal fault, a genuine bug) MAY still surface as a
20//!   protocol `Err`. The lift does NOT blanket-swallow infrastructure faults as
21//!   domain errors.
22//!
23//! # The served provenance stamp ([`ProvStamp`], Codex HIGH #3)
24//!
25//! Every tool result (success AND error envelope) carries a [`ProvStamp`] of
26//! `{ bundle_id, version, combined_hash }`. The `combined_hash` field carries
27//! the `BUNDLE.lock` COMBINED hash-of-hashes
28//! ([`pmcp_workbook_runtime::BundleLock::combined`]). It is named `combined_hash`
29//! — NEVER `workbook_hash` — so it can never be confused with
30//! [`pmcp_workbook_runtime::BundleLock::workbook_hash`], which is the SOURCE
31//! workbook content hash, a DIFFERENT value.
32
33use std::sync::Arc;
34
35use pmcp::ServerBuilder;
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39use crate::error::Result;
40
41pub mod error;
42pub mod handler;
43pub mod input;
44pub mod render_resource;
45pub mod render_uri;
46pub mod schema;
47
48#[doc(inline)]
49pub use error::{to_iserror_result, WorkbookToolError};
50#[doc(inline)]
51pub use handler::{
52    sanitize_tool_name, DiffVersionHandler, ExplainHandler, GetManifestHandler,
53    RenderWorkbookHandler, WorkbookToolHandler,
54};
55#[doc(inline)]
56pub use input::{validate_input, ValidatedInput};
57#[doc(inline)]
58pub use render_resource::RenderWorkbookResource;
59#[doc(inline)]
60pub use render_uri::{decode, encode, DecodedRender, MAX_ENCODED_URI_LEN, WORKBOOK_XLSX_MIME};
61
62/// Re-export of the verified runtime bundle the served tools operate on (loaded
63/// fail-closed via [`pmcp_workbook_runtime::load_bundle`]).
64pub use pmcp_workbook_runtime::{CellMap, Manifest, WorkbookBundle};
65
66/// Re-export of the full boot surface (D-11) so Shape A/B consumers register a
67/// served workbook WITHOUT ever naming `pmcp-workbook-runtime`: the
68/// `BundleSource` trait + its on-disk impl, the fail-closed loader entry point,
69/// and both error types. The `EmbeddedSource` impl is re-exported separately
70/// under the `workbook-embedded` feature (it needs the runtime's `embedded`
71/// include_dir support).
72pub use pmcp_workbook_runtime::{
73    load_bundle, BundleLoadError, BundleSource, BundleSourceError, LocalDirSource,
74};
75
76/// The binary-baked [`BundleSource`] (WBSV-09), re-exported only when the
77/// toolkit's `workbook-embedded` feature layers the runtime's `embedded`
78/// (include_dir) support on top of the LocalDirSource-only `workbook` build.
79///
80/// To construct one, invoke the `include_dir::include_dir!` macro over a
81/// committed bundle directory (add `include_dir` as a dependency — the macro
82/// emits unqualified `include_dir::` paths so the crate must be nameable at the
83/// consumer's root) and pass the resulting `&'static Dir` to
84/// [`EmbeddedSource::new`].
85#[cfg(feature = "workbook-embedded")]
86pub use pmcp_workbook_runtime::EmbeddedSource;
87
88/// The UI resource URI every workbook tool advertises (MCP Apps widget hook).
89///
90/// The widget resource itself lands in Plan 04 (`render_workbook` + the
91/// `workbook://` resource); the tools advertise this stable pointer now so a
92/// client's `structuredContent` is widget-routable from the first handler.
93pub const WORKBOOK_TOOL_UI: &str = "ui://workbook/result";
94
95/// The provenance stamp on EVERY served tool result (success AND error
96/// envelope) — the `bundle_id@version` identity plus the `combined_hash`
97/// integrity anchor (Codex HIGH #3).
98///
99/// Constructed from a verified [`WorkbookBundle::stamp`]
100/// ([`pmcp_workbook_runtime::BundleLock`]) by [`ProvStamp::from_bundle`]. The
101/// `combined_hash` field carries [`pmcp_workbook_runtime::BundleLock::combined`]
102/// — NOT [`pmcp_workbook_runtime::BundleLock::workbook_hash`] (the source-workbook
103/// hash). The two MUST never be conflated: `combined_hash` flips when ANY bundle
104/// artifact changes, binding the response to the exact verified bundle.
105/// The field names ARE the wire contract (pinned by
106/// `tests/workbook_provstamp_contract.rs`), so the serde derives serialize the
107/// stamp directly — every projection (`to_json`, the `workbook://` URI payload,
108/// the advertised schema) shares this one definition.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ProvStamp {
111    /// The neutral bundle identifier (e.g. `"tax-calc"`).
112    pub bundle_id: String,
113    /// The semver version (e.g. `"1.1.0"`).
114    pub version: String,
115    /// The `BUNDLE.lock` COMBINED hash-of-hashes (NEVER the source-workbook
116    /// hash — Codex HIGH #3).
117    pub combined_hash: String,
118}
119
120impl ProvStamp {
121    /// Build the served provenance stamp from a verified [`WorkbookBundle`].
122    ///
123    /// The `combined_hash` is taken from `bundle.stamp.combined` (the
124    /// `BUNDLE.lock` combined hash-of-hashes) — explicitly NOT
125    /// `bundle.stamp.workbook_hash`, so the served stamp can never carry the
126    /// source-workbook hash (Codex HIGH #3).
127    #[must_use]
128    pub fn from_bundle(bundle: &WorkbookBundle) -> Self {
129        Self {
130            bundle_id: bundle.stamp.bundle_id.clone(),
131            version: bundle.stamp.version.clone(),
132            combined_hash: bundle.stamp.combined.clone(),
133        }
134    }
135
136    /// The stamp as a JSON object attached to every result payload.
137    #[must_use]
138    pub fn to_json(&self) -> Value {
139        // Infallible: ProvStamp is three plain strings.
140        serde_json::to_value(self).unwrap_or(Value::Null)
141    }
142}
143
144// === Builder extension — the single Shape A/B registration call (D-09) =========
145
146/// Composable builder extension wiring a verified workbook bundle into a
147/// [`pmcp::ServerBuilder`] in ONE call.
148///
149/// [`WorkbookBuilderExt::with_workbook_bundle`] /
150/// [`WorkbookBuilderExt::try_with_workbook_bundle`] load + integrity-verify a
151/// [`BundleSource`] at boot (fail-closed — a tampered bundle aborts the boot,
152/// WBSV-08), then register all FIVE served tools (`calculate`, `explain`,
153/// `get_manifest`, `diff_version`, `render_workbook`) plus the `workbook://`
154/// render resource. Mirrors [`crate::builder_ext::ServerBuilderExt`]'s
155/// panicking-convenience + fallible-companion pair (review R7): production
156/// servers should prefer the `try_` form so a tampered/malformed bundle surfaces
157/// as a `Result`, not a crash.
158///
159/// This is THE consumer-side contract: Shape A/B servers depend ONLY on
160/// `pmcp-server-toolkit` and never name `pmcp-workbook-runtime` (the loader,
161/// source impls, and error types are re-exported at this module / the crate
162/// root, D-11).
163pub trait WorkbookBuilderExt: Sized {
164    /// Load + verify `source` and register all five workbook tools + the
165    /// `workbook://` resource. Panicking convenience wrapping
166    /// [`WorkbookBuilderExt::try_with_workbook_bundle`].
167    ///
168    /// # Panics
169    ///
170    /// Panics with `"with_workbook_bundle: ..."` if the bundle fails to load or
171    /// its recomputed integrity hashes do not match its lock (a tampered /
172    /// malformed bundle, [`BundleLoadError`]). Prefer
173    /// [`WorkbookBuilderExt::try_with_workbook_bundle`] for production servers
174    /// where a bad bundle must surface as a `Result` (WBSV-08).
175    ///
176    /// # Example
177    ///
178    /// ```no_run
179    /// use pmcp::Server;
180    /// use pmcp_server_toolkit::workbook::{LocalDirSource, WorkbookBuilderExt};
181    ///
182    /// let source = LocalDirSource::new("bundles/tax-calc@1.1.0");
183    /// let _builder = Server::builder()
184    ///     .name("workbook-tax-calc")
185    ///     .version("1.1.0")
186    ///     .with_workbook_bundle(&source);
187    /// ```
188    fn with_workbook_bundle(self, source: &dyn BundleSource) -> Self;
189
190    /// Fallible companion to [`WorkbookBuilderExt::with_workbook_bundle`]
191    /// (review R7) — the boot LOAD is fail-closed (WBSV-08): a tampered or
192    /// malformed bundle returns `Err` BEFORE any tool is registered, so the
193    /// server never boots on an unverified bundle.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`crate::ToolkitError`] (wrapping a [`BundleLoadError`]) if the
198    /// bundle fails to load — typically a source read error, a JSON parse
199    /// failure, or an integrity-hash mismatch (a swapped / tampered artifact).
200    ///
201    /// # Example
202    ///
203    /// ```no_run
204    /// use pmcp::Server;
205    /// use pmcp_server_toolkit::workbook::{LocalDirSource, WorkbookBuilderExt};
206    ///
207    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
208    /// let source = LocalDirSource::new("bundles/tax-calc@1.1.0");
209    /// let _builder = Server::builder()
210    ///     .name("workbook-tax-calc")
211    ///     .version("1.1.0")
212    ///     .try_with_workbook_bundle(&source)?;
213    /// # Ok(()) }
214    /// ```
215    fn try_with_workbook_bundle(self, source: &dyn BundleSource) -> Result<Self>;
216}
217
218impl WorkbookBuilderExt for ServerBuilder {
219    fn with_workbook_bundle(self, source: &dyn BundleSource) -> Self {
220        self.try_with_workbook_bundle(source).expect(
221            "with_workbook_bundle: BundleLoader load/verify returned an error — \
222             prefer try_with_workbook_bundle to handle a tampered/malformed bundle \
223             as a Result (WBSV-08 fail-closed)",
224        )
225    }
226
227    fn try_with_workbook_bundle(self, source: &dyn BundleSource) -> Result<Self> {
228        // WBSV-08 fail-closed: load + integrity-verify the bundle BEFORE any
229        // tool is registered. A `WorkbookBundle` value is proof the bundle was
230        // untampered at load, so the server cannot boot on an unverified bundle.
231        let bundle = Arc::new(load_bundle(source)?);
232
233        // Operator visibility (mirrors builder_ext.rs:273-279): a bundle that
234        // declares zero tools would serve nothing useful — surface that as a
235        // warning rather than a silently-empty server (WBV2-04).
236        if bundle.cell_map.tools.is_empty() {
237            tracing::warn!(
238                target: "pmcp_server_toolkit::workbook",
239                bundle_id = %bundle.stamp.bundle_id,
240                version = %bundle.stamp.version,
241                "with_workbook_bundle: bundle declares zero tools — the server will \
242                 register no workbook compute tools (set RUST_LOG=warn to surface this)"
243            );
244        }
245
246        // WBV2-04 fan-out: register ONE named MCP tool per output Table (each a
247        // WorkbookToolHandler with a per-tool DAG-derived inputSchema + a non-empty
248        // outputSchema). An unmappable tool name fails the boot fail-closed (T-100-10)
249        // rather than registering an uncallable tool. Each handler is `Arc`-cloned so
250        // they share ONE verified bundle (no copies).
251        let mut builder = self;
252        for tool in &bundle.cell_map.tools {
253            let name = sanitize_tool_name(&tool.name).map_err(|e| {
254                crate::error::ToolkitError::Synth(format!(
255                    "workbook output Table '{}' has no MCP-mappable tool name: {}",
256                    tool.name, e.reason
257                ))
258            })?;
259            builder = builder.tool_arc(
260                &name,
261                Arc::new(WorkbookToolHandler::new(bundle.clone(), tool.clone())),
262            );
263        }
264
265        // The four META tools (Explain / GetManifest / DiffVersion / RenderWorkbook)
266        // are workbook-wide (not per-Table), registered UNCHANGED.
267        let builder = builder
268            .tool_arc(
269                ExplainHandler::NAME,
270                Arc::new(ExplainHandler::new(bundle.clone())),
271            )
272            .tool_arc(
273                GetManifestHandler::NAME,
274                Arc::new(GetManifestHandler::new(bundle.clone())),
275            )
276            .tool_arc(
277                DiffVersionHandler::NAME,
278                Arc::new(DiffVersionHandler::new(bundle.clone())),
279            )
280            .tool_arc(
281                RenderWorkbookHandler::NAME,
282                Arc::new(RenderWorkbookHandler::new(bundle.clone())),
283            )
284            // The single `workbook://` render resource (A3 — no DispatchingResource
285            // wrapper, exactly one resource handler).
286            .resources_arc(Arc::new(RenderWorkbookResource::new(bundle)));
287
288        Ok(builder)
289    }
290}