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