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}