/rs/...
+ if let Ok(src_dir) = std::env::var("TEST_SRCDIR") {
+ let workspace = std::env::var("TEST_WORKSPACE").unwrap_or_else(|_| "ic".to_string());
+ return PathBuf::from(src_dir).join(workspace);
+ }
+ // Under Cargo, CARGO_MANIFEST_DIR = .../rs/webmcp/codegen → 3 levels up
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .unwrap() // rs/webmcp
+ .parent()
+ .unwrap() // rs
+ .parent()
+ .unwrap() // repo root
+ .to_path_buf()
+}
+
+fn ledger_did_path() -> PathBuf {
+ repo_root().join("rs/ledger_suite/icp/ledger.did")
+}
+
+#[test]
+fn test_parse_icp_ledger_did() {
+ let path = ledger_did_path();
+ assert!(path.exists(), "ledger.did not found at {}", path.display());
+
+ let parsed = parse_did_file(&path).expect("Failed to parse ledger.did");
+ assert!(!parsed.methods.is_empty(), "Expected methods in ledger.did");
+
+ // Check that some known methods exist
+ let method_names: Vec<&str> = parsed.methods.iter().map(|m| m.name.as_str()).collect();
+ assert!(
+ method_names.contains(&"transfer"),
+ "Expected 'transfer' method, found: {:?}",
+ method_names
+ );
+ assert!(
+ method_names.contains(&"account_balance"),
+ "Expected 'account_balance' method, found: {:?}",
+ method_names
+ );
+
+ // Check query vs update classification
+ let account_balance = parsed
+ .methods
+ .iter()
+ .find(|m| m.name == "account_balance")
+ .unwrap();
+ assert!(
+ account_balance.is_query,
+ "account_balance should be a query method"
+ );
+
+ let transfer = parsed
+ .methods
+ .iter()
+ .find(|m| m.name == "transfer")
+ .unwrap();
+ assert!(!transfer.is_query, "transfer should be an update method");
+}
+
+#[test]
+fn test_schema_generation_for_ledger_args() {
+ let path = ledger_did_path();
+ let parsed = parse_did_file(&path).expect("Failed to parse ledger.did");
+
+ // Generate schemas for all method args and rets — should not panic
+ for method in &parsed.methods {
+ for arg in &method.args {
+ let schema = candid_to_json_schema(arg, &parsed.env);
+ assert!(
+ schema.is_object(),
+ "Schema for {}.arg should be a JSON object",
+ method.name
+ );
+ }
+ for ret in &method.rets {
+ let schema = candid_to_json_schema(ret, &parsed.env);
+ assert!(
+ schema.is_object(),
+ "Schema for {}.ret should be a JSON object",
+ method.name
+ );
+ }
+ }
+}
+
+#[test]
+fn test_generate_manifest_from_ledger() {
+ let config = Config {
+ did_file: ledger_did_path(),
+ canister_id: Some("ryjl3-tyaaa-aaaaa-aaaba-cai".to_string()),
+ name: Some("ICP Ledger".to_string()),
+ description: Some("ICP token ledger".to_string()),
+ expose_methods: Some(vec!["transfer".to_string(), "account_balance".to_string()]),
+ require_auth: vec!["transfer".to_string()],
+ certified_queries: vec!["account_balance".to_string()],
+ method_descriptions: [
+ ("transfer".to_string(), "Transfer ICP tokens".to_string()),
+ (
+ "account_balance".to_string(),
+ "Get account balance".to_string(),
+ ),
+ ]
+ .into(),
+ param_descriptions: Default::default(),
+ };
+
+ let manifest = generate_manifest(&config).expect("Failed to generate manifest");
+
+ assert_eq!(manifest.schema_version, "1.0");
+ assert_eq!(manifest.canister.name, "ICP Ledger");
+ assert_eq!(
+ manifest.canister.id.as_deref(),
+ Some("ryjl3-tyaaa-aaaaa-aaaba-cai")
+ );
+ assert_eq!(manifest.tools.len(), 2);
+
+ let transfer_tool = manifest
+ .tools
+ .iter()
+ .find(|t| t.name == "transfer")
+ .unwrap();
+ assert_eq!(transfer_tool.method_type, "update");
+ assert!(transfer_tool.requires_auth);
+ assert_eq!(transfer_tool.description, "Transfer ICP tokens");
+
+ let balance_tool = manifest
+ .tools
+ .iter()
+ .find(|t| t.name == "account_balance")
+ .unwrap();
+ assert_eq!(balance_tool.method_type, "query");
+ assert!(balance_tool.certified);
+ assert_eq!(balance_tool.description, "Get account balance");
+
+ // Auth section should be present since transfer requires auth
+ let auth = manifest
+ .authentication
+ .as_ref()
+ .expect("Expected auth section");
+ assert_eq!(auth.auth_type, "internet-identity");
+ assert!(
+ auth.delegation_targets
+ .contains(&"ryjl3-tyaaa-aaaaa-aaaba-cai".to_string())
+ );
+
+ // Verify the manifest serializes to valid JSON
+ let json = serde_json::to_string_pretty(&manifest).expect("Failed to serialize manifest");
+ assert!(json.contains("transfer"));
+ assert!(json.contains("account_balance"));
+
+ // Print it for manual inspection
+ println!("Generated manifest:\n{}", json);
+}
+
+#[test]
+fn test_js_emitter() {
+ let config = Config {
+ did_file: ledger_did_path(),
+ canister_id: Some("ryjl3-tyaaa-aaaaa-aaaba-cai".to_string()),
+ name: Some("ICP Ledger".to_string()),
+ description: Some("ICP token ledger".to_string()),
+ expose_methods: Some(vec!["account_balance".to_string()]),
+ require_auth: vec![],
+ certified_queries: vec![],
+ method_descriptions: Default::default(),
+ param_descriptions: Default::default(),
+ };
+
+ let manifest = generate_manifest(&config).expect("Failed to generate manifest");
+ let js = ic_webmcp_codegen::js_emitter::emit_js(&manifest);
+
+ assert!(js.contains("ICP Ledger"), "JS should contain canister name");
+ assert!(
+ js.contains("ryjl3-tyaaa-aaaaa-aaaba-cai"),
+ "JS should contain canister ID"
+ );
+ assert!(
+ js.contains("@dfinity/webmcp"),
+ "JS should import from @dfinity/webmcp"
+ );
+ assert!(
+ js.contains("initWebMCP"),
+ "JS should define initWebMCP function"
+ );
+}
+
+/// Test that complex .did files with recursive types, deeply nested variants, etc.
+/// all parse and generate manifests without panicking.
+#[test]
+fn test_complex_did_files() {
+ let fixtures = [
+ ("ICRC-1 Ledger", "rs/ledger_suite/icrc1/ledger/ledger.did"),
+ (
+ "NNS Governance",
+ "rs/nns/governance/canister/governance.did",
+ ),
+ ("SNS Swap", "rs/sns/swap/canister/swap.did"),
+ ("CMC", "rs/nns/cmc/cmc.did"),
+ (
+ "Management Canister",
+ "rs/types/management_canister_types/tests/ic.did",
+ ),
+ ("ckBTC Minter", "rs/bitcoin/ckbtc/minter/ckbtc_minter.did"),
+ ];
+
+ let root = repo_root();
+ for (name, rel_path) in &fixtures {
+ let path = root.join(rel_path);
+ if !path.exists() {
+ // Skip fixtures that don't exist (e.g., in partial checkouts)
+ continue;
+ }
+
+ let parsed =
+ parse_did_file(&path).unwrap_or_else(|e| panic!("Failed to parse {}: {}", name, e));
+ assert!(
+ !parsed.methods.is_empty(),
+ "{} should have at least one method",
+ name
+ );
+
+ // Generate schemas for all args and rets — should not panic or stack overflow
+ for method in &parsed.methods {
+ for arg in &method.args {
+ let _schema = candid_to_json_schema(arg, &parsed.env);
+ }
+ for ret in &method.rets {
+ let _schema = candid_to_json_schema(ret, &parsed.env);
+ }
+ }
+
+ // Full manifest generation should succeed
+ let config = Config {
+ did_file: path,
+ canister_id: None,
+ name: Some(name.to_string()),
+ description: None,
+ expose_methods: None,
+ require_auth: vec![],
+ certified_queries: vec![],
+ method_descriptions: Default::default(),
+ param_descriptions: Default::default(),
+ };
+ let manifest = generate_manifest(&config)
+ .unwrap_or_else(|e| panic!("Failed to generate manifest for {}: {}", name, e));
+
+ // Serialization roundtrip
+ let json = serde_json::to_string(&manifest)
+ .unwrap_or_else(|e| panic!("Failed to serialize manifest for {}: {}", name, e));
+ assert!(json.contains("schema_version"));
+
+ println!(
+ "{}: {} methods, {} tools",
+ name,
+ parsed.methods.len(),
+ manifest.tools.len()
+ );
+ }
+}
diff --git a/rs/webmcp/demo/BUILD.bazel b/rs/webmcp/demo/BUILD.bazel
new file mode 100644
index 000000000000..da74df6d7469
--- /dev/null
+++ b/rs/webmcp/demo/BUILD.bazel
@@ -0,0 +1,42 @@
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+load("//bazel:canisters.bzl", "rust_canister")
+
+package(default_visibility = ["//visibility:public"])
+
+ALIASES = {}
+
+DEPENDENCIES = [
+ # Keep sorted.
+ "@crate_index//:candid",
+ "@crate_index//:ic-cdk",
+ "@crate_index//:serde",
+]
+
+rust_library(
+ name = "demo_backend_lib",
+ srcs = glob(
+ ["src/**/*.rs"],
+ exclude = ["src/main.rs"],
+ ),
+ aliases = ALIASES,
+ crate_name = "demo_backend_lib",
+ version = "0.1.0",
+ deps = DEPENDENCIES,
+)
+
+rust_canister(
+ name = "demo_backend_canister",
+ srcs = ["src/main.rs"],
+ aliases = ALIASES,
+ compile_data = [":backend.did"],
+ proc_macro_deps = [],
+ service_file = ":backend.did",
+ version = "0.1.0",
+ deps = DEPENDENCIES + [":demo_backend_lib"],
+)
+
+rust_test(
+ name = "unit_tests",
+ aliases = ALIASES,
+ crate = ":demo_backend_lib",
+)
diff --git a/rs/webmcp/demo/Cargo.toml b/rs/webmcp/demo/Cargo.toml
new file mode 100644
index 000000000000..cb18a9a9bc88
--- /dev/null
+++ b/rs/webmcp/demo/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "demo-backend"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+description.workspace = true
+documentation.workspace = true
+license = "Apache-2.0"
+
+[[bin]]
+name = "demo_backend"
+path = "src/main.rs"
+
+[dependencies]
+candid = { workspace = true }
+ic-cdk = { workspace = true }
+serde = { workspace = true }
+
+[lib]
+name = "demo_backend_lib"
+path = "src/lib.rs"
+
+[dev-dependencies]
+candid = { workspace = true }
diff --git a/rs/webmcp/demo/README.md b/rs/webmcp/demo/README.md
new file mode 100644
index 000000000000..e4d1377abda2
--- /dev/null
+++ b/rs/webmcp/demo/README.md
@@ -0,0 +1,48 @@
+# WebMCP Demo Shop
+
+A minimal e-commerce canister that demonstrates the full WebMCP pipeline:
+`.did` → `webmcp.json` → AI agent tool calls.
+
+## What's included
+
+| File | Purpose |
+|---|---|
+| `backend.did` | Candid interface: products, cart, checkout |
+| `src/lib.rs` | Business logic (per-caller carts, order IDs) |
+| `src/main.rs` | `ic_cdk` entry points wiring `msg_caller()` |
+| `dfx.json` | dfx config with `webmcp` section |
+| `assets/index.html` | Minimal frontend loading `webmcp.js` |
+
+## Running locally
+
+```bash
+# From this directory:
+icp start --background
+icp deploy
+
+# Generate the WebMCP manifest from dfx.json:
+ic-webmcp-codegen dfx --dfx-json dfx.json --out-dir assets/
+
+# The manifest is now at assets/backend.webmcp.json
+# Copy it to /.well-known/ for browser discovery:
+cp assets/backend.webmcp.json assets/.well-known/webmcp.json
+cp assets/backend.webmcp.js assets/webmcp.js
+
+# Redeploy assets:
+icp deploy frontend
+```
+
+Open the canister URL shown by `icp deploy` in Chrome 146+ with
+WebMCP enabled. An AI agent can then discover and call:
+
+- `list_products` — browse the catalog (certified query)
+- `get_product` — get a single product by ID
+- `get_cart` — view cart contents
+- `add_to_cart` — add items (requires Internet Identity login)
+- `checkout` — complete the purchase (requires Internet Identity login)
+
+## Running tests
+
+```bash
+cargo test -p demo-backend
+```
diff --git a/rs/webmcp/demo/assets/index.html b/rs/webmcp/demo/assets/index.html
new file mode 100644
index 000000000000..608fe93fa7df
--- /dev/null
+++ b/rs/webmcp/demo/assets/index.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ WebMCP Demo Shop
+
+
+
+ WebMCP Demo Shop AI-ready
+
+ This demo canister exposes an e-commerce shop to AI agents via
+ WebMCP.
+ Open Chrome 146+ with the WebMCP flag enabled and an AI agent will
+ be able to browse products, manage your cart, and complete purchases.
+
+ Available tools
+
+ list_products — Browse the product catalog
+ get_product — Get details for a specific product
+ get_cart — View current cart contents
+ add_to_cart — Add items to cart (requires login)
+ checkout — Complete the purchase (requires login)
+
+
+ The manifest is served at
+ /.well-known/webmcp.json.
+
+
+
+
+
diff --git a/rs/webmcp/demo/backend.did b/rs/webmcp/demo/backend.did
new file mode 100644
index 000000000000..4732e25094b9
--- /dev/null
+++ b/rs/webmcp/demo/backend.did
@@ -0,0 +1,52 @@
+// WebMCP Demo Canister — Candid interface
+//
+// A simple e-commerce canister exposing products and a cart,
+// used to demonstrate the full WebMCP pipeline.
+
+type Product = record {
+ id : nat32;
+ name : text;
+ description : text;
+ price_e8s : nat64;
+ in_stock : bool;
+};
+
+type CartItem = record {
+ product_id : nat32;
+ quantity : nat32;
+};
+
+type Cart = record {
+ items : vec CartItem;
+ total_e8s : nat64;
+};
+
+type AddToCartResult = variant {
+ Ok : Cart;
+ Err : text;
+};
+
+type CheckoutResult = variant {
+ Ok : record { order_id : nat64; total_paid_e8s : nat64 };
+ Err : text;
+};
+
+service : {
+ // List all available products
+ list_products : () -> (vec Product) query;
+
+ // Get a single product by ID
+ get_product : (nat32) -> (opt Product) query;
+
+ // Get the current user's cart
+ get_cart : () -> (Cart) query;
+
+ // Add an item to the cart
+ add_to_cart : (CartItem) -> (AddToCartResult);
+
+ // Remove an item from the cart
+ remove_from_cart : (nat32) -> (Cart);
+
+ // Complete the purchase
+ checkout : () -> (CheckoutResult);
+};
diff --git a/rs/webmcp/demo/dfx.json b/rs/webmcp/demo/dfx.json
new file mode 100644
index 000000000000..ed57f9f612f6
--- /dev/null
+++ b/rs/webmcp/demo/dfx.json
@@ -0,0 +1,50 @@
+{
+ "canisters": {
+ "backend": {
+ "type": "rust",
+ "candid": "backend.did",
+ "package": "demo-backend",
+ "webmcp": {
+ "enabled": true,
+ "name": "WebMCP Demo Shop",
+ "description": "A demo e-commerce canister exposing products and cart management to AI agents",
+ "expose_methods": [
+ "list_products",
+ "get_product",
+ "get_cart",
+ "add_to_cart",
+ "remove_from_cart",
+ "checkout"
+ ],
+ "require_auth": ["add_to_cart", "remove_from_cart", "checkout"],
+ "certified_queries": ["list_products", "get_product", "get_cart"],
+ "descriptions": {
+ "list_products": "List all available products with names, descriptions, and prices",
+ "get_product": "Get details for a specific product by its numeric ID",
+ "get_cart": "Get the current contents and total of the shopping cart",
+ "add_to_cart": "Add a product to the shopping cart with optional quantity",
+ "remove_from_cart": "Remove a product from the cart by product ID",
+ "checkout": "Complete the purchase and pay for all items in the cart"
+ },
+ "param_descriptions": {
+ "add_to_cart.product_id": "The numeric ID of the product to add",
+ "add_to_cart.quantity": "How many units to add (minimum 1)",
+ "get_product.arg0": "The numeric product ID to look up",
+ "remove_from_cart.arg0": "The numeric product ID to remove"
+ }
+ }
+ },
+ "frontend": {
+ "type": "assets",
+ "source": ["assets"],
+ "dependencies": ["backend"]
+ }
+ },
+ "networks": {
+ "local": {
+ "bind": "127.0.0.1:4943",
+ "type": "ephemeral"
+ }
+ },
+ "version": 1
+}
diff --git a/rs/webmcp/demo/src/lib.rs b/rs/webmcp/demo/src/lib.rs
new file mode 100644
index 000000000000..965a0b46c2bd
--- /dev/null
+++ b/rs/webmcp/demo/src/lib.rs
@@ -0,0 +1,481 @@
+//! WebMCP Demo Canister — e-commerce backend.
+//!
+//! Demonstrates exposing IC canister methods as WebMCP tools.
+//! Each caller (principal) gets their own cart; products are hard-coded
+//! at init time so the demo works without any external dependencies.
+
+use candid::{CandidType, Principal};
+use serde::Deserialize;
+use std::cell::RefCell;
+use std::collections::BTreeMap;
+
+// ── Types (mirror backend.did) ───────────────────────────────────────
+
+#[derive(CandidType, Deserialize, Clone, Debug)]
+pub struct Product {
+ pub id: u32,
+ pub name: String,
+ pub description: String,
+ pub price_e8s: u64,
+ pub in_stock: bool,
+}
+
+#[derive(CandidType, Deserialize, Clone, Debug)]
+pub struct CartItem {
+ pub product_id: u32,
+ pub quantity: u32,
+}
+
+#[derive(CandidType, Deserialize, Clone, Debug, Default)]
+pub struct Cart {
+ pub items: Vec,
+ pub total_e8s: u64,
+}
+
+#[derive(CandidType, Deserialize, Clone, Debug)]
+pub enum AddToCartResult {
+ Ok(Cart),
+ Err(String),
+}
+
+#[derive(CandidType, Deserialize, Clone, Debug)]
+pub struct OrderConfirmation {
+ pub order_id: u64,
+ pub total_paid_e8s: u64,
+}
+
+#[derive(CandidType, Deserialize, Clone, Debug)]
+pub enum CheckoutResult {
+ Ok(OrderConfirmation),
+ Err(String),
+}
+
+// ── State ─────────────────────────────────────────────────────────────
+
+#[derive(Default)]
+pub struct State {
+ pub products: Vec,
+ /// Per-caller carts: principal → cart
+ pub carts: BTreeMap,
+ /// Monotonically increasing order counter
+ pub next_order_id: u64,
+}
+
+thread_local! {
+ static STATE: RefCell = RefCell::new(State::default());
+}
+
+/// Seed the product catalog. Called from `#[init]`.
+pub fn init_products() {
+ STATE.with(|s| {
+ s.borrow_mut().products = vec![
+ Product {
+ id: 1,
+ name: "ICP T-Shirt".to_string(),
+ description: "100% organic cotton, infinity logo on the front".to_string(),
+ price_e8s: 500_000_000, // 5 ICP
+ in_stock: true,
+ },
+ Product {
+ id: 2,
+ name: "Neuron Hoodie".to_string(),
+ description: "Warm hoodie with NNS neuron diagram on the back".to_string(),
+ price_e8s: 1_500_000_000, // 15 ICP
+ in_stock: true,
+ },
+ Product {
+ id: 3,
+ name: "DFINITY Sticker Pack".to_string(),
+ description: "10 high-quality vinyl stickers for your laptop".to_string(),
+ price_e8s: 100_000_000, // 1 ICP
+ in_stock: true,
+ },
+ Product {
+ id: 4,
+ name: "IC Coffee Mug".to_string(),
+ description: "Ceramic mug with the Internet Computer logo".to_string(),
+ price_e8s: 300_000_000, // 3 ICP
+ in_stock: false, // out of stock — agents should notice
+ },
+ ];
+ });
+}
+
+// ── Canister methods ─────────────────────────────────────────────────
+//
+// The public functions take `caller: Principal` explicitly so they can
+// be called from both the canister entry point (`main.rs`, which passes
+// `ic_cdk::api::msg_caller()`) and from unit tests (which pass a fixed
+// test principal). This avoids requiring the IC host environment in tests.
+
+/// List all available products.
+pub fn list_products() -> Vec {
+ STATE.with(|s| s.borrow().products.clone())
+}
+
+/// Get a single product by ID. Returns `None` if not found.
+pub fn get_product(id: u32) -> Option {
+ STATE.with(|s| s.borrow().products.iter().find(|p| p.id == id).cloned())
+}
+
+/// Get the given caller's current cart.
+pub fn get_cart(caller: Principal) -> Cart {
+ STATE.with(|s| s.borrow().carts.get(&caller).cloned().unwrap_or_default())
+}
+
+/// Add a product to the given caller's cart.
+pub fn add_to_cart(caller: Principal, item: CartItem) -> AddToCartResult {
+ if item.quantity == 0 {
+ return AddToCartResult::Err("Quantity must be at least 1".to_string());
+ }
+
+ let product = match get_product(item.product_id) {
+ Some(p) => p,
+ None => return AddToCartResult::Err(format!("Product {} not found", item.product_id)),
+ };
+
+ if !product.in_stock {
+ return AddToCartResult::Err(format!("Product \"{}\" is out of stock", product.name));
+ }
+
+ let cart = STATE.with(|s| {
+ let mut state = s.borrow_mut();
+ // Clone products first to avoid simultaneous mutable + immutable borrow of state
+ let products = state.products.clone();
+ let cart = state.carts.entry(caller).or_default();
+
+ if let Some(existing) = cart
+ .items
+ .iter_mut()
+ .find(|i| i.product_id == item.product_id)
+ {
+ existing.quantity += item.quantity;
+ } else {
+ cart.items.push(item);
+ }
+
+ cart.total_e8s = compute_total(&cart.items, &products);
+ cart.clone()
+ });
+
+ AddToCartResult::Ok(cart)
+}
+
+/// Remove a product from the given caller's cart.
+pub fn remove_from_cart(caller: Principal, product_id: u32) -> Cart {
+ STATE.with(|s| {
+ let mut state = s.borrow_mut();
+ let products = state.products.clone();
+ let cart = state.carts.entry(caller).or_default();
+ cart.items.retain(|i| i.product_id != product_id);
+ cart.total_e8s = compute_total(&cart.items, &products);
+ cart.clone()
+ })
+}
+
+/// Check out the given caller's cart, clearing it and returning an order confirmation.
+pub fn checkout(caller: Principal) -> CheckoutResult {
+ let cart = STATE.with(|s| s.borrow().carts.get(&caller).cloned().unwrap_or_default());
+
+ if cart.items.is_empty() {
+ return CheckoutResult::Err("Cart is empty".to_string());
+ }
+
+ let total_paid_e8s = cart.total_e8s;
+
+ let order_id = STATE.with(|s| {
+ let mut state = s.borrow_mut();
+ let id = state.next_order_id;
+ state.next_order_id += 1;
+ state.carts.remove(&caller);
+ id
+ });
+
+ CheckoutResult::Ok(OrderConfirmation {
+ order_id,
+ total_paid_e8s,
+ })
+}
+
+// ── Stable memory: upgrade hooks ─────────────────────────────────────
+
+/// Serialisable snapshot of the mutable parts of canister state.
+///
+/// Products are hard-coded at init and do not need to survive upgrades;
+/// only carts and the order counter do.
+#[derive(CandidType, Deserialize)]
+pub struct StableState {
+ pub carts: BTreeMap,
+ pub next_order_id: u64,
+}
+
+/// Extract the state that must survive a canister upgrade.
+pub fn take_stable_state() -> StableState {
+ STATE.with(|s| {
+ let state = s.borrow();
+ StableState {
+ carts: state.carts.clone(),
+ next_order_id: state.next_order_id,
+ }
+ })
+}
+
+/// Restore state after a canister upgrade and re-seed the product catalog.
+pub fn restore_stable_state(stable: StableState) {
+ STATE.with(|s| {
+ let mut state = s.borrow_mut();
+ state.carts = stable.carts;
+ state.next_order_id = stable.next_order_id;
+ });
+ init_products();
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+fn compute_total(items: &[CartItem], products: &[Product]) -> u64 {
+ items.iter().fold(0_u64, |acc, item| {
+ let price = products
+ .iter()
+ .find(|p| p.id == item.product_id)
+ .map(|p| p.price_e8s)
+ .unwrap_or(0);
+ acc.saturating_add(price.saturating_mul(item.quantity as u64))
+ })
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn alice() -> Principal {
+ Principal::from_text("aaaaa-aa").unwrap()
+ }
+
+ fn setup() {
+ STATE.with(|s| *s.borrow_mut() = State::default());
+ init_products();
+ }
+
+ #[test]
+ fn test_list_products_returns_all() {
+ setup();
+ assert_eq!(list_products().len(), 4);
+ assert!(list_products().iter().any(|p| p.name == "ICP T-Shirt"));
+ }
+
+ #[test]
+ fn test_get_product_found() {
+ setup();
+ let p = get_product(1).expect("product 1 should exist");
+ assert_eq!(p.name, "ICP T-Shirt");
+ assert_eq!(p.price_e8s, 500_000_000);
+ }
+
+ #[test]
+ fn test_get_product_not_found() {
+ setup();
+ assert!(get_product(999).is_none());
+ }
+
+ #[test]
+ fn test_add_to_cart_success() {
+ setup();
+ match add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 2,
+ },
+ ) {
+ AddToCartResult::Ok(cart) => {
+ assert_eq!(cart.items.len(), 1);
+ assert_eq!(cart.items[0].quantity, 2);
+ assert_eq!(cart.total_e8s, 1_000_000_000); // 2 × 5 ICP
+ }
+ AddToCartResult::Err(e) => panic!("unexpected error: {e}"),
+ }
+ }
+
+ #[test]
+ fn test_add_to_cart_out_of_stock() {
+ setup();
+ // product 4 (mug) is out of stock
+ assert!(matches!(
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 4,
+ quantity: 1
+ }
+ ),
+ AddToCartResult::Err(_)
+ ));
+ }
+
+ #[test]
+ fn test_add_to_cart_zero_quantity() {
+ setup();
+ assert!(matches!(
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 0
+ }
+ ),
+ AddToCartResult::Err(_)
+ ));
+ }
+
+ #[test]
+ fn test_add_to_cart_unknown_product() {
+ setup();
+ assert!(matches!(
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 999,
+ quantity: 1
+ }
+ ),
+ AddToCartResult::Err(_)
+ ));
+ }
+
+ #[test]
+ fn test_add_same_product_twice_merges_quantity() {
+ setup();
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 1,
+ },
+ );
+ match add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 2,
+ },
+ ) {
+ AddToCartResult::Ok(cart) => {
+ assert_eq!(cart.items.len(), 1);
+ assert_eq!(cart.items[0].quantity, 3);
+ }
+ AddToCartResult::Err(e) => panic!("unexpected error: {e}"),
+ }
+ }
+
+ #[test]
+ fn test_remove_from_cart() {
+ setup();
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 1,
+ },
+ );
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 2,
+ quantity: 1,
+ },
+ );
+ let cart = remove_from_cart(alice(), 1);
+ assert_eq!(cart.items.len(), 1);
+ assert_eq!(cart.items[0].product_id, 2);
+ }
+
+ #[test]
+ fn test_checkout_success() {
+ setup();
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 1,
+ },
+ );
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 3,
+ quantity: 2,
+ },
+ );
+ match checkout(alice()) {
+ CheckoutResult::Ok(order) => {
+ assert_eq!(order.order_id, 0);
+ // 1 T-shirt (5 ICP) + 2 sticker packs (1 ICP each) = 7 ICP
+ assert_eq!(order.total_paid_e8s, 700_000_000);
+ }
+ CheckoutResult::Err(e) => panic!("unexpected error: {e}"),
+ }
+ assert!(get_cart(alice()).items.is_empty());
+ }
+
+ #[test]
+ fn test_checkout_empty_cart() {
+ setup();
+ assert!(matches!(checkout(alice()), CheckoutResult::Err(_)));
+ }
+
+ #[test]
+ fn test_order_ids_increment() {
+ setup();
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 1,
+ },
+ );
+ let first = checkout(alice());
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 1,
+ },
+ );
+ let second = checkout(alice());
+ match (first, second) {
+ (CheckoutResult::Ok(a), CheckoutResult::Ok(b)) => {
+ assert_eq!(b.order_id, a.order_id + 1);
+ }
+ _ => panic!("both checkouts should succeed"),
+ }
+ }
+
+ #[test]
+ fn test_carts_are_per_caller() {
+ setup();
+ let bob = Principal::from_text("2vxsx-fae").unwrap();
+ add_to_cart(
+ alice(),
+ CartItem {
+ product_id: 1,
+ quantity: 1,
+ },
+ );
+ add_to_cart(
+ bob,
+ CartItem {
+ product_id: 2,
+ quantity: 3,
+ },
+ );
+
+ let alice_cart = get_cart(alice());
+ let bob_cart = get_cart(bob);
+ assert_eq!(alice_cart.items.len(), 1);
+ assert_eq!(alice_cart.items[0].product_id, 1);
+ assert_eq!(bob_cart.items.len(), 1);
+ assert_eq!(bob_cart.items[0].product_id, 2);
+ }
+}
diff --git a/rs/webmcp/demo/src/main.rs b/rs/webmcp/demo/src/main.rs
new file mode 100644
index 000000000000..3709645b7dfb
--- /dev/null
+++ b/rs/webmcp/demo/src/main.rs
@@ -0,0 +1,54 @@
+use demo_backend_lib::{AddToCartResult, Cart, CartItem, CheckoutResult, Product};
+use ic_cdk::api::msg_caller;
+
+fn main() {}
+
+#[ic_cdk::init]
+fn init() {
+ demo_backend_lib::init_products();
+}
+
+/// Serialise mutable state to stable memory before an upgrade.
+#[ic_cdk::pre_upgrade]
+fn pre_upgrade() {
+ let stable = demo_backend_lib::take_stable_state();
+ ic_cdk::storage::stable_save((stable,)).expect("pre_upgrade: stable_save failed");
+}
+
+/// Restore state from stable memory after an upgrade.
+#[ic_cdk::post_upgrade]
+fn post_upgrade() {
+ let (stable,): (demo_backend_lib::StableState,) =
+ ic_cdk::storage::stable_restore().expect("post_upgrade: stable_restore failed");
+ demo_backend_lib::restore_stable_state(stable);
+}
+
+#[ic_cdk::query]
+fn list_products() -> Vec {
+ demo_backend_lib::list_products()
+}
+
+#[ic_cdk::query]
+fn get_product(id: u32) -> Option {
+ demo_backend_lib::get_product(id)
+}
+
+#[ic_cdk::query]
+fn get_cart() -> Cart {
+ demo_backend_lib::get_cart(msg_caller())
+}
+
+#[ic_cdk::update]
+fn add_to_cart(item: CartItem) -> AddToCartResult {
+ demo_backend_lib::add_to_cart(msg_caller(), item)
+}
+
+#[ic_cdk::update]
+fn remove_from_cart(product_id: u32) -> Cart {
+ demo_backend_lib::remove_from_cart(msg_caller(), product_id)
+}
+
+#[ic_cdk::update]
+fn checkout() -> CheckoutResult {
+ demo_backend_lib::checkout(msg_caller())
+}