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}