From 19d207256129baddbf19ac07228fc1c256cdfca6 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 10:38:30 -0400 Subject: [PATCH 01/12] feat(webmcp): add ic-webmcp-codegen crate for Candid-to-WebMCP manifest generation Implements Phase 1 of the WebMCP integration: a Rust codegen crate that parses .did files and generates WebMCP tool manifests (webmcp.json) and browser registration scripts (webmcp.js) for AI agent discovery. --- .claude/instructions/webmcp.md | 27 ++ Cargo.lock | 13 + Cargo.toml | 1 + rs/webmcp/IMPLEMENTATION_PLAN.md | 491 ++++++++++++++++++++ rs/webmcp/codegen/Cargo.toml | 17 + rs/webmcp/codegen/src/config.rs | 45 ++ rs/webmcp/codegen/src/did_parser.rs | 127 +++++ rs/webmcp/codegen/src/js_emitter.rs | 48 ++ rs/webmcp/codegen/src/lib.rs | 20 + rs/webmcp/codegen/src/manifest.rs | 191 ++++++++ rs/webmcp/codegen/src/schema_mapper.rs | 240 ++++++++++ rs/webmcp/codegen/tests/integration_test.rs | 185 ++++++++ 12 files changed, 1405 insertions(+) create mode 100644 .claude/instructions/webmcp.md create mode 100644 rs/webmcp/IMPLEMENTATION_PLAN.md create mode 100644 rs/webmcp/codegen/Cargo.toml create mode 100644 rs/webmcp/codegen/src/config.rs create mode 100644 rs/webmcp/codegen/src/did_parser.rs create mode 100644 rs/webmcp/codegen/src/js_emitter.rs create mode 100644 rs/webmcp/codegen/src/lib.rs create mode 100644 rs/webmcp/codegen/src/manifest.rs create mode 100644 rs/webmcp/codegen/src/schema_mapper.rs create mode 100644 rs/webmcp/codegen/tests/integration_test.rs diff --git a/.claude/instructions/webmcp.md b/.claude/instructions/webmcp.md new file mode 100644 index 000000000000..bd5df1398c5e --- /dev/null +++ b/.claude/instructions/webmcp.md @@ -0,0 +1,27 @@ +# WebMCP Implementation Context + +## What This Branch Is For +Branch `ianblenke/webmcp` implements WebMCP (Web Model Context Protocol) support for the Internet Computer. WebMCP is a W3C standard (Chrome 146+) that lets websites expose structured tools to AI agents via `navigator.modelContext`. + +## Implementation Plan +See `rs/webmcp/IMPLEMENTATION_PLAN.md` for the full architecture, deliverables, and phased approach. + +## Why IC Is Ideal for WebMCP +- **Candid interfaces** already define structured tool schemas (`.did` files) +- **Certified queries** provide cryptographically verified responses (BLS threshold signatures) +- **Internet Identity** enables scoped agent authentication via delegation chains +- **HTTP Gateway** already translates HTTP → canister calls + +## Key Deliverables +1. `rs/webmcp/codegen/` — Rust: `.did` → `webmcp.json` + `webmcp.js` +2. `packages/ic-webmcp/` — TypeScript: bridge `navigator.modelContext` ↔ `@dfinity/agent` +3. Asset canister middleware — auto-serve `/.well-known/webmcp.json` +4. `dfx.json` integration — config-driven auto-generation + +## Start Here +Begin with Phase 1: `rs/webmcp/codegen/` — the Candid-to-JSON-Schema mapper. Use the `candid_parser` crate to parse `.did` files. Test with `rs/ledger_suite/icp/ledger.did` as a fixture. + +## WebMCP References +- Chrome blog: https://developer.chrome.com/blog/webmcp-epp +- W3C spec: https://webmcp.link/ +- Technical guide: https://dev.to/czmilo/chrome-webmcp-the-complete-2026-guide-to-ai-agent-protocol-1ae9 diff --git a/Cargo.lock b/Cargo.lock index 79236a23eca8..384240ee78f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15714,6 +15714,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ic-webmcp-codegen" +version = "0.1.0" +dependencies = [ + "anyhow", + "candid", + "candid_parser", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "ic-xnet-hyper" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index a8348181414f..a0583627786c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -529,6 +529,7 @@ members = [ "rs/utils/thread", "rs/utils/validate_eq", "rs/utils/validate_eq_derive", + "rs/webmcp/codegen", "rs/validator", "rs/validator/http_request_arbitrary", "rs/validator/http_request_test_utils", diff --git a/rs/webmcp/IMPLEMENTATION_PLAN.md b/rs/webmcp/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000000..c9a552ca1adf --- /dev/null +++ b/rs/webmcp/IMPLEMENTATION_PLAN.md @@ -0,0 +1,491 @@ +# WebMCP for the Internet Computer — Implementation Plan + +## Overview + +WebMCP (Web Model Context Protocol) is a W3C standard that lets websites expose structured, callable tools to AI agents via browser APIs. The IC is uniquely suited for WebMCP because Candid interfaces already define structured tool schemas, certified queries provide verifiable responses, and Internet Identity enables scoped agent authentication. + +This plan covers building **4 deliverables**: + +1. **`ic-webmcp-codegen`** — Rust build tool: `.did` → `webmcp.json` + `webmcp.js` +2. **`@dfinity/webmcp`** — TypeScript npm package: bridge `navigator.modelContext` ↔ `@dfinity/agent` +3. **Asset canister middleware** — Auto-serve `/.well-known/webmcp.json` +4. **`dfx` integration** — Config in `dfx.json`, auto-generation on build + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ AI Agent (Chrome 146+ with WebMCP) │ +│ ┌───────────────────────────────────────────┐ │ +│ │ navigator.modelContext │ │ +│ │ → discovers tools from webmcp.json │ │ +│ │ → calls execute() with typed params │ │ +│ └───────────────┬───────────────────────────┘ │ +└──────────────────┼──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ @dfinity/webmcp (browser JS) │ +│ ┌───────────────────────────────────────────┐ │ +│ │ 1. Fetches /.well-known/webmcp.json │ │ +│ │ 2. Registers tools via navigator API │ │ +│ │ 3. Maps execute() → agent.call/query() │ │ +│ │ 4. Handles II delegation for auth │ │ +│ │ 5. Returns certified responses w/ proofs │ │ +│ └───────────────┬───────────────────────────┘ │ +└──────────────────┼──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ IC Boundary Node (HTTP Gateway) │ +│ HTTP POST → Canister update/query call │ +└──────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Backend Canister │ +│ ┌───────────────────────────────────────────┐ │ +│ │ Candid interface (.did) │ │ +│ │ service : { │ │ +│ │ transfer : (TransferArg) → (Result); │ │ +│ │ balance_of : (Account) → (nat) query; │ │ +│ │ } │ │ +│ └───────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────┐ │ +│ │ Asset canister serves: │ │ +│ │ /.well-known/webmcp.json (manifest) │ │ +│ │ /webmcp.js (registration script) │ │ +│ └───────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## Deliverable 1: `ic-webmcp-codegen` (Rust) + +**Location**: `rs/webmcp/codegen/` + +### Purpose +Parse `.did` files and generate: +- `webmcp.json` — tool manifest for discovery +- `webmcp.js` — browser script that registers tools via `navigator.modelContext` + +### Key Files + +``` +rs/webmcp/codegen/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # Public API +│ ├── did_parser.rs # Parse .did → internal representation +│ ├── schema_mapper.rs # Candid types → JSON Schema +│ ├── manifest.rs # Generate webmcp.json +│ ├── js_emitter.rs # Generate webmcp.js +│ └── config.rs # Read webmcp config from dfx.json +└── tests/ + ├── icrc1_ledger.did # Test fixture + └── codegen_tests.rs +``` + +### Candid → JSON Schema Mapping + +| Candid Type | JSON Schema | +|---|---| +| `nat` | `{ "type": "string", "pattern": "^[0-9]+$" }` | +| `int` | `{ "type": "string", "pattern": "^-?[0-9]+$" }` | +| `nat8/16/32` | `{ "type": "integer", "minimum": 0 }` | +| `text` | `{ "type": "string" }` | +| `bool` | `{ "type": "boolean" }` | +| `blob` | `{ "type": "string", "contentEncoding": "base64" }` | +| `principal` | `{ "type": "string", "pattern": "^[a-z0-9-]+$" }` | +| `opt T` | `{ "oneOf": [schema(T), { "type": "null" }] }` | +| `vec T` | `{ "type": "array", "items": schema(T) }` | +| `record { a: T; b: U }` | `{ "type": "object", "properties": { "a": schema(T), "b": schema(U) } }` | +| `variant { A; B: T }` | `{ "oneOf": [{ "const": "A" }, { "type": "object", "properties": { "B": schema(T) } }] }` | + +### Generated `webmcp.json` Format + +```json +{ + "schema_version": "1.0", + "canister": { + "id": "ryjl3-tyaaa-aaaaa-aaaba-cai", + "name": "ICP Ledger", + "description": "ICP token ledger implementing ICRC-1/2/3" + }, + "tools": [ + { + "name": "icrc1_balance_of", + "description": "Get the token balance of an account", + "canister_method": "icrc1_balance_of", + "method_type": "query", + "certified": true, + "inputSchema": { + "type": "object", + "properties": { + "owner": { "type": "string", "description": "Principal ID" }, + "subaccount": { "type": ["string", "null"], "contentEncoding": "base64" } + }, + "required": ["owner"] + }, + "outputSchema": { + "type": "string", + "description": "Balance in e8s", + "pattern": "^[0-9]+$" + } + }, + { + "name": "icrc1_transfer", + "description": "Transfer tokens to another account", + "canister_method": "icrc1_transfer", + "method_type": "update", + "requires_auth": true, + "inputSchema": { + "type": "object", + "properties": { + "to": { + "type": "object", + "properties": { + "owner": { "type": "string" }, + "subaccount": { "type": ["string", "null"] } + }, + "required": ["owner"] + }, + "amount": { "type": "string", "pattern": "^[0-9]+$" }, + "memo": { "type": ["string", "null"], "contentEncoding": "base64" }, + "fee": { "type": ["string", "null"] }, + "created_at_time": { "type": ["integer", "null"] } + }, + "required": ["to", "amount"] + } + } + ], + "authentication": { + "type": "internet-identity", + "delegation_targets": ["ryjl3-tyaaa-aaaaa-aaaba-cai"], + "recommended_scope": { + "icrc1_transfer": { + "max_ttl_seconds": 3600, + "description": "Authorize agent to transfer tokens on your behalf" + } + } + } +} +``` + +### Generated `webmcp.js` Skeleton + +```javascript +import { ICWebMCP } from '@dfinity/webmcp'; + +const webmcp = new ICWebMCP({ + manifestUrl: '/.well-known/webmcp.json', + // Auto-detected from manifest, but overridable: + // canisterId: 'ryjl3-tyaaa-aaaaa-aaaba-cai', + // host: 'https://icp-api.io', +}); + +// Auto-registers all tools from manifest +await webmcp.registerAll(); +``` + +--- + +## Deliverable 2: `@dfinity/webmcp` (TypeScript) + +**Location**: `packages/ic-webmcp/` + +### Purpose +Browser-side library that: +1. Fetches `webmcp.json` manifest +2. Creates `@dfinity/agent` instances +3. Registers tools with `navigator.modelContext` +4. Maps tool calls → canister calls +5. Handles Internet Identity delegation +6. Wraps certified query responses with proofs + +### Key Files + +``` +packages/ic-webmcp/ +├── package.json +├── tsconfig.json +├── src/ +│ ├── index.ts # Main exports +│ ├── ic-webmcp.ts # Core ICWebMCP class +│ ├── manifest.ts # Fetch & parse webmcp.json +│ ├── tool-registry.ts # Register tools with navigator.modelContext +│ ├── agent-bridge.ts # Map tool execute() → agent.call/query +│ ├── auth.ts # Internet Identity scoped delegation +│ ├── certified-response.ts # Wrap certified query proofs +│ ├── candid-json.ts # Convert JSON params ↔ Candid values +│ └── types.ts # TypeScript interfaces +├── tests/ +│ ├── manifest.test.ts +│ ├── tool-registry.test.ts +│ ├── agent-bridge.test.ts +│ └── candid-json.test.ts +└── README.md +``` + +### Core Class API + +```typescript +interface ICWebMCPConfig { + manifestUrl?: string; // default: '/.well-known/webmcp.json' + canisterId?: string; // override from manifest + host?: string; // default: 'https://icp-api.io' + identity?: Identity; // pre-existing identity + onAuthRequired?: () => Promise; // callback for II login +} + +class ICWebMCP { + constructor(config: ICWebMCPConfig); + + // Fetch manifest and register all tools + async registerAll(): Promise; + + // Register a single tool by name + async registerTool(toolName: string): Promise; + + // Unregister all tools (cleanup) + async unregisterAll(): Promise; + + // Get the underlying agent + getAgent(): HttpAgent; + + // Set identity (after II login) + setIdentity(identity: Identity): void; + + // Create scoped delegation for agent auth + async createAgentDelegation(opts: { + methods?: string[]; + maxTtlSeconds?: number; + constraints?: Record; + }): Promise; +} +``` + +### Tool Registration Flow + +```typescript +// Inside tool-registry.ts +async function registerCanisterTool( + tool: WebMCPToolDefinition, + agent: HttpAgent, + canisterId: Principal, +) { + const { name, description, inputSchema, canister_method, method_type } = tool; + + navigator.modelContext.registerTool({ + name, + description, + inputSchema, + execute: async (params: Record) => { + // Convert JSON params to Candid + const candidArgs = jsonToCandid(params, tool.candidTypes); + + if (method_type === 'query') { + const result = await agent.query(canisterId, { + methodName: canister_method, + arg: candidArgs, + }); + return candidToJson(result); + } else { + // Check auth + if (tool.requires_auth && agent.isAnonymous()) { + throw new Error(`Tool "${name}" requires authentication. Please connect Internet Identity.`); + } + const result = await agent.call(canisterId, { + methodName: canister_method, + arg: candidArgs, + }); + return candidToJson(result); + } + }, + }); +} +``` + +### Certified Response Wrapper + +```typescript +// Inside certified-response.ts +interface CertifiedToolResponse { + value: T; + certified: true; + certificate: ArrayBuffer; // BLS threshold signature + tree: ArrayBuffer; // Merkle witness + timestamp_nanos: bigint; + subnet_id: string; + // Human-readable verification status + verification: 'verified' | 'unverified'; +} + +async function executeCertifiedQuery( + agent: HttpAgent, + canisterId: Principal, + method: string, + args: ArrayBuffer, +): Promise> { + const response = await agent.readState(canisterId, { + paths: [/* request_status path */], + }); + + // Verify certificate against IC root key + const verified = await verifyCertificate(response.certificate); + + return { + value: candidToJson(response.reply.arg), + certified: true, + certificate: response.certificate, + tree: response.tree, + timestamp_nanos: response.timestamp, + subnet_id: response.subnetId, + verification: verified ? 'verified' : 'unverified', + }; +} +``` + +--- + +## Deliverable 3: Asset Canister Middleware + +**Location**: `rs/webmcp/asset-middleware/` + +### Purpose +Extend the IC asset canister to auto-serve WebMCP manifest at `/.well-known/webmcp.json`. + +### Approach +Add an optional `webmcp` section to asset canister configuration. When present: +- Serve `/.well-known/webmcp.json` with correct CORS headers +- Inject ` + * + * Or import directly: + * import {{ initWebMCP }} from './webmcp.js'; + * await initWebMCP(); + */ + +import {{ ICWebMCP }} from 'https://esm.sh/@dfinity/webmcp'; + +export async function initWebMCP(options = {{}}) {{ + const webmcp = new ICWebMCP({{ + manifestUrl: options.manifestUrl || '/.well-known/webmcp.json', + canisterId: options.canisterId || {canister_id}, + host: options.host || 'https://icp-api.io', + ...options, + }}); + + await webmcp.registerAll(); + + return webmcp; +}} + +// Auto-initialize if loaded as a script tag +if (document.currentScript) {{ + initWebMCP().catch(console.error); +}} +"#, + name = manifest.canister.name, + canister_id = manifest + .canister + .id + .as_deref() + .map(|id| format!("'{}'", id)) + .unwrap_or_else(|| "undefined".to_string()), + ) +} diff --git a/rs/webmcp/codegen/src/lib.rs b/rs/webmcp/codegen/src/lib.rs new file mode 100644 index 000000000000..4b769ca804a6 --- /dev/null +++ b/rs/webmcp/codegen/src/lib.rs @@ -0,0 +1,20 @@ +//! # ic-webmcp-codegen +//! +//! Generate WebMCP (Web Model Context Protocol) tool manifests from +//! Internet Computer Candid interface definitions. +//! +//! WebMCP enables AI agents to discover and call structured tools on websites. +//! This crate bridges IC's Candid interfaces to WebMCP's JSON Schema format, +//! auto-generating: +//! - `webmcp.json` — tool manifest for agent discovery +//! - `webmcp.js` — browser script for tool registration + +pub mod config; +pub mod did_parser; +pub mod js_emitter; +pub mod manifest; +pub mod schema_mapper; + +pub use config::Config; +pub use did_parser::ParsedInterface; +pub use manifest::{WebMCPManifest, generate_manifest}; diff --git a/rs/webmcp/codegen/src/manifest.rs b/rs/webmcp/codegen/src/manifest.rs new file mode 100644 index 000000000000..d59ba726c153 --- /dev/null +++ b/rs/webmcp/codegen/src/manifest.rs @@ -0,0 +1,191 @@ +//! Generate WebMCP manifest (webmcp.json) from parsed Candid interfaces. + +use crate::config::Config; +use crate::did_parser::{CanisterMethod, parse_did_file}; +use crate::schema_mapper::candid_to_json_schema; +use anyhow::Result; +use candid::TypeEnv; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +/// Top-level WebMCP manifest. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebMCPManifest { + pub schema_version: String, + pub canister: CanisterInfo, + pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub authentication: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CanisterInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebMCPTool { + pub name: String, + pub description: String, + pub canister_method: String, + pub method_type: String, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub certified: bool, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub requires_auth: bool, + #[serde(rename = "inputSchema")] + pub input_schema: JsonValue, + #[serde(rename = "outputSchema", skip_serializing_if = "Option::is_none")] + pub output_schema: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthenticationInfo { + #[serde(rename = "type")] + pub auth_type: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub delegation_targets: Vec, +} + +/// Generate a WebMCP manifest from configuration. +pub fn generate_manifest(config: &Config) -> Result { + let parsed = parse_did_file(&config.did_file)?; + + let tools: Vec = parsed + .methods + .iter() + .filter(|m| { + config + .expose_methods + .as_ref() + .is_none_or(|exposed| exposed.contains(&m.name)) + }) + .map(|m| method_to_tool(m, config, &parsed.env)) + .collect(); + + let has_auth_tools = tools.iter().any(|t| t.requires_auth); + + let authentication = if has_auth_tools { + Some(AuthenticationInfo { + auth_type: "internet-identity".to_string(), + delegation_targets: config.canister_id.iter().cloned().collect(), + }) + } else { + None + }; + + Ok(WebMCPManifest { + schema_version: "1.0".to_string(), + canister: CanisterInfo { + id: config.canister_id.clone(), + name: config + .name + .clone() + .unwrap_or_else(|| "IC Canister".to_string()), + description: config + .description + .clone() + .unwrap_or_else(|| "Internet Computer canister".to_string()), + }, + tools, + authentication, + }) +} + +fn method_to_tool(method: &CanisterMethod, config: &Config, env: &TypeEnv) -> WebMCPTool { + let description = config + .method_descriptions + .get(&method.name) + .cloned() + .unwrap_or_else(|| format!("Call {}", method.name)); + + let input_schema = build_input_schema(method, config, env); + let output_schema = build_output_schema(method, env); + + WebMCPTool { + name: method.name.clone(), + description, + canister_method: method.name.clone(), + method_type: if method.is_query { + "query".to_string() + } else { + "update".to_string() + }, + certified: config.certified_queries.contains(&method.name), + requires_auth: config.require_auth.contains(&method.name), + input_schema, + output_schema, + } +} + +fn build_input_schema(method: &CanisterMethod, config: &Config, env: &TypeEnv) -> JsonValue { + if method.args.is_empty() { + return serde_json::json!({ "type": "object", "properties": {} }); + } + + // If single argument, use its schema directly (flattening records) + if method.args.len() == 1 { + let schema = candid_to_json_schema(&method.args[0], env); + if schema.get("type") == Some(&serde_json::json!("object")) { + return enrich_param_descriptions(schema, &method.name, config); + } + } + + // Multiple args → wrap in object with positional names + let mut properties = serde_json::Map::new(); + for (i, ty) in method.args.iter().enumerate() { + let arg_name = format!("arg{}", i); + let mut schema = candid_to_json_schema(ty, env); + // Add param description if available + let key = format!("{}.{}", method.name, arg_name); + if let Some(desc) = config.param_descriptions.get(&key) { + schema["description"] = serde_json::json!(desc); + } + properties.insert(arg_name, schema); + } + + serde_json::json!({ + "type": "object", + "properties": serde_json::Value::Object(properties) + }) +} + +/// Enrich a flattened record schema with param_descriptions from config. +fn enrich_param_descriptions( + mut schema: JsonValue, + method_name: &str, + config: &Config, +) -> JsonValue { + if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) { + for (field_name, field_schema) in props.iter_mut() { + let key = format!("{}.{}", method_name, field_name); + if let Some(desc) = config.param_descriptions.get(&key) { + field_schema["description"] = serde_json::json!(desc); + } + } + } + schema +} + +fn build_output_schema(method: &CanisterMethod, env: &TypeEnv) -> Option { + if method.rets.is_empty() { + return None; + } + if method.rets.len() == 1 { + return Some(candid_to_json_schema(&method.rets[0], env)); + } + // Multiple return values → tuple as array + let items: Vec = method + .rets + .iter() + .map(|t| candid_to_json_schema(t, env)) + .collect(); + Some(serde_json::json!({ + "type": "array", + "prefixItems": items, + "items": false + })) +} diff --git a/rs/webmcp/codegen/src/schema_mapper.rs b/rs/webmcp/codegen/src/schema_mapper.rs new file mode 100644 index 000000000000..62bc4e6b4cfa --- /dev/null +++ b/rs/webmcp/codegen/src/schema_mapper.rs @@ -0,0 +1,240 @@ +//! Maps Candid types to JSON Schema for WebMCP tool definitions. + +use candid::TypeEnv; +use candid::types::{Type, TypeInner}; +use serde_json::{Value as JsonValue, json}; + +/// Convert a Candid type to a JSON Schema value. +/// +/// The `env` is used to resolve `Var` references (type aliases defined in the .did file). +pub fn candid_to_json_schema(ty: &Type, env: &TypeEnv) -> JsonValue { + match ty.as_ref() { + TypeInner::Bool => json!({ "type": "boolean" }), + TypeInner::Nat => { + json!({ "type": "string", "pattern": "^[0-9]+$", "description": "Natural number" }) + } + TypeInner::Int => { + json!({ "type": "string", "pattern": "^-?[0-9]+$", "description": "Integer" }) + } + TypeInner::Nat8 => json!({ "type": "integer", "minimum": 0, "maximum": 255 }), + TypeInner::Nat16 => json!({ "type": "integer", "minimum": 0, "maximum": 65535 }), + TypeInner::Nat32 => { + json!({ "type": "integer", "minimum": 0, "maximum": 4_294_967_295_u64 }) + } + TypeInner::Nat64 => { + json!({ "type": "string", "pattern": "^[0-9]+$", "description": "64-bit natural number" }) + } + TypeInner::Int8 => json!({ "type": "integer", "minimum": -128, "maximum": 127 }), + TypeInner::Int16 => json!({ "type": "integer", "minimum": -32768, "maximum": 32767 }), + TypeInner::Int32 => { + json!({ "type": "integer", "minimum": -2_147_483_648_i64, "maximum": 2_147_483_647 }) + } + TypeInner::Int64 => { + json!({ "type": "string", "pattern": "^-?[0-9]+$", "description": "64-bit integer" }) + } + TypeInner::Float32 | TypeInner::Float64 => json!({ "type": "number" }), + TypeInner::Text => json!({ "type": "string" }), + TypeInner::Null => json!({ "type": "null" }), + TypeInner::Principal => json!({ + "type": "string", + "description": "IC Principal ID", + "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)*$" + }), + TypeInner::Vec(inner) => { + if matches!(inner.as_ref(), TypeInner::Nat8) { + // blob = vec nat8 → base64 string + json!({ + "type": "string", + "contentEncoding": "base64", + "description": "Binary data (base64-encoded)" + }) + } else { + json!({ + "type": "array", + "items": candid_to_json_schema(inner, env) + }) + } + } + TypeInner::Opt(inner) => { + let inner_schema = candid_to_json_schema(inner, env); + json!({ + "oneOf": [inner_schema, { "type": "null" }] + }) + } + TypeInner::Record(fields) => { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for field in fields { + let field_name = field.id.to_string(); + let field_schema = candid_to_json_schema(&field.ty, env); + // All record fields are required unless they're opt + if !matches!(field.ty.as_ref(), TypeInner::Opt(_)) { + required.push(JsonValue::String(field_name.clone())); + } + properties.insert(field_name, field_schema); + } + + let mut schema = json!({ + "type": "object", + "properties": JsonValue::Object(properties) + }); + if !required.is_empty() { + schema["required"] = JsonValue::Array(required); + } + schema + } + TypeInner::Variant(variants) => { + let one_of: Vec = variants + .iter() + .map(|v| { + let name = v.id.to_string(); + if matches!(v.ty.as_ref(), TypeInner::Null) { + // Unit variant + json!({ "const": name }) + } else { + // Variant with payload + let payload = candid_to_json_schema(&v.ty, env); + json!({ + "type": "object", + "properties": { name.clone(): payload }, + "required": [name], + "additionalProperties": false + }) + } + }) + .collect(); + json!({ "oneOf": one_of }) + } + TypeInner::Var(name) => { + // Resolve type alias from the environment + if let Ok(resolved) = env.rec_find_type(name) { + candid_to_json_schema(resolved, env) + } else { + // Unresolvable — emit opaque schema + json!({ "description": format!("Unresolved type: {}", name) }) + } + } + // Reserved, Empty, Unknown, Knot, Func, Service, Class, Future + _ => json!({}), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_env() -> TypeEnv { + TypeEnv::new() + } + + #[test] + fn test_nat_schema() { + let ty: Type = TypeInner::Nat.into(); + let schema = candid_to_json_schema(&ty, &empty_env()); + assert_eq!(schema["type"], "string"); + assert!(schema["pattern"].as_str().unwrap().contains("[0-9]")); + } + + #[test] + fn test_text_schema() { + let ty: Type = TypeInner::Text.into(); + let schema = candid_to_json_schema(&ty, &empty_env()); + assert_eq!(schema["type"], "string"); + } + + #[test] + fn test_bool_schema() { + let ty: Type = TypeInner::Bool.into(); + let schema = candid_to_json_schema(&ty, &empty_env()); + assert_eq!(schema["type"], "boolean"); + } + + #[test] + fn test_principal_schema() { + let ty: Type = TypeInner::Principal.into(); + let schema = candid_to_json_schema(&ty, &empty_env()); + assert_eq!(schema["type"], "string"); + assert!( + schema["description"] + .as_str() + .unwrap() + .contains("Principal") + ); + } + + #[test] + fn test_blob_schema() { + let ty: Type = TypeInner::Vec(TypeInner::Nat8.into()).into(); + let schema = candid_to_json_schema(&ty, &empty_env()); + assert_eq!(schema["type"], "string"); + assert_eq!(schema["contentEncoding"], "base64"); + } + + #[test] + fn test_vec_schema() { + let ty: Type = TypeInner::Vec(TypeInner::Text.into()).into(); + let schema = candid_to_json_schema(&ty, &empty_env()); + assert_eq!(schema["type"], "array"); + assert_eq!(schema["items"]["type"], "string"); + } + + #[test] + fn test_opt_schema() { + let ty: Type = TypeInner::Opt(TypeInner::Text.into()).into(); + let schema = candid_to_json_schema(&ty, &empty_env()); + assert!(schema["oneOf"].is_array()); + assert_eq!(schema["oneOf"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_record_schema() { + use candid::types::Field; + use candid::types::internal::Label; + use std::rc::Rc; + + let ty: Type = TypeInner::Record(vec![ + Field { + id: Rc::new(Label::Named("owner".to_string())), + ty: TypeInner::Principal.into(), + }, + Field { + id: Rc::new(Label::Named("subaccount".to_string())), + ty: TypeInner::Opt(TypeInner::Vec(TypeInner::Nat8.into()).into()).into(), + }, + ]) + .into(); + + let schema = candid_to_json_schema(&ty, &empty_env()); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"]["owner"].is_object()); + assert!(schema["properties"]["subaccount"].is_object()); + // owner is required, subaccount (opt) is not + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&serde_json::json!("owner"))); + assert!(!required.contains(&serde_json::json!("subaccount"))); + } + + #[test] + fn test_variant_schema() { + use candid::types::Field; + use candid::types::internal::Label; + use std::rc::Rc; + + let ty: Type = TypeInner::Variant(vec![ + Field { + id: Rc::new(Label::Named("Ok".to_string())), + ty: TypeInner::Nat.into(), + }, + Field { + id: Rc::new(Label::Named("Err".to_string())), + ty: TypeInner::Null.into(), + }, + ]) + .into(); + + let schema = candid_to_json_schema(&ty, &empty_env()); + let one_of = schema["oneOf"].as_array().unwrap(); + assert_eq!(one_of.len(), 2); + } +} diff --git a/rs/webmcp/codegen/tests/integration_test.rs b/rs/webmcp/codegen/tests/integration_test.rs new file mode 100644 index 000000000000..ae776137a825 --- /dev/null +++ b/rs/webmcp/codegen/tests/integration_test.rs @@ -0,0 +1,185 @@ +use ic_webmcp_codegen::did_parser::parse_did_file; +use ic_webmcp_codegen::schema_mapper::candid_to_json_schema; +use ic_webmcp_codegen::{Config, generate_manifest}; +use std::path::PathBuf; + +fn ledger_did_path() -> PathBuf { + // CARGO_MANIFEST_DIR = .../rs/webmcp/codegen + // repo root = .../ (3 levels up) + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .parent() + .unwrap() // rs/webmcp + .parent() + .unwrap() // rs + .parent() + .unwrap() // 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" + ); +} From cec524a82238248ed1c908b00da76eeb3d8e2daf Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 10:49:33 -0400 Subject: [PATCH 02/12] feat(webmcp): add CLI binary, fix recursive type handling, expand test coverage Add a clap-based CLI for generating webmcp.json/webmcp.js from .did files. Fix stack overflow on recursive Candid types (e.g., ICRC-1 Value) by tracking visited type names during schema generation. Add integration tests covering 6 complex .did files (ICRC-1, NNS Governance, SNS Swap, CMC, Management Canister, ckBTC Minter). --- Cargo.lock | 1 + rs/webmcp/codegen/Cargo.toml | 1 + rs/webmcp/codegen/src/main.rs | 91 +++++++++++++++++++++ rs/webmcp/codegen/src/schema_mapper.rs | 69 ++++++++++++---- rs/webmcp/codegen/tests/integration_test.rs | 82 ++++++++++++++++++- 5 files changed, 228 insertions(+), 16 deletions(-) create mode 100644 rs/webmcp/codegen/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 384240ee78f0..d2dbf59a4a16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15721,6 +15721,7 @@ dependencies = [ "anyhow", "candid", "candid_parser", + "clap 4.6.0", "pretty_assertions", "serde", "serde_json", diff --git a/rs/webmcp/codegen/Cargo.toml b/rs/webmcp/codegen/Cargo.toml index d7cef8ccfa74..8f20b5af6ed4 100644 --- a/rs/webmcp/codegen/Cargo.toml +++ b/rs/webmcp/codegen/Cargo.toml @@ -11,6 +11,7 @@ candid = { workspace = true } serde = { workspace = true } serde_json = "1" anyhow = "1" +clap = { workspace = true } [dev-dependencies] pretty_assertions = "1" diff --git a/rs/webmcp/codegen/src/main.rs b/rs/webmcp/codegen/src/main.rs new file mode 100644 index 000000000000..1695cbbc4b7c --- /dev/null +++ b/rs/webmcp/codegen/src/main.rs @@ -0,0 +1,91 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use ic_webmcp_codegen::{Config, generate_manifest}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Generate WebMCP tool manifests from Internet Computer Candid interfaces. +/// +/// Parses a .did file and outputs: +/// - webmcp.json: tool manifest for AI agent discovery +/// - webmcp.js: browser script for tool registration +#[derive(Parser)] +#[command(name = "ic-webmcp-codegen", version)] +struct Cli { + /// Path to the Candid .did file + #[arg(long, short = 'd')] + did: PathBuf, + + /// Output path for webmcp.json manifest + #[arg(long, default_value = "webmcp.json")] + out_manifest: PathBuf, + + /// Output path for webmcp.js registration script + #[arg(long, default_value = "webmcp.js")] + out_js: PathBuf, + + /// Canister ID to embed in the manifest + #[arg(long)] + canister_id: Option, + + /// Human-readable canister name + #[arg(long)] + name: Option, + + /// Description for AI agents + #[arg(long)] + description: Option, + + /// Methods to expose (comma-separated). If omitted, all methods are exposed. + #[arg(long, value_delimiter = ',')] + expose: Option>, + + /// Methods that require authentication (comma-separated) + #[arg(long, value_delimiter = ',')] + require_auth: Option>, + + /// Query methods that support certified responses (comma-separated) + #[arg(long, value_delimiter = ',')] + certified: Option>, + + /// Skip generating webmcp.js + #[arg(long)] + no_js: bool, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let config = Config { + did_file: cli.did, + canister_id: cli.canister_id, + name: cli.name, + description: cli.description, + expose_methods: cli.expose, + require_auth: cli.require_auth.unwrap_or_default(), + certified_queries: cli.certified.unwrap_or_default(), + method_descriptions: BTreeMap::new(), + param_descriptions: BTreeMap::new(), + }; + + let manifest = generate_manifest(&config).with_context(|| { + format!( + "Failed to generate manifest from {}", + config.did_file.display() + ) + })?; + + let json = serde_json::to_string_pretty(&manifest).context("Failed to serialize manifest")?; + std::fs::write(&cli.out_manifest, &json) + .with_context(|| format!("Failed to write {}", cli.out_manifest.display()))?; + eprintln!("Wrote {}", cli.out_manifest.display()); + + if !cli.no_js { + let js = ic_webmcp_codegen::js_emitter::emit_js(&manifest); + std::fs::write(&cli.out_js, &js) + .with_context(|| format!("Failed to write {}", cli.out_js.display()))?; + eprintln!("Wrote {}", cli.out_js.display()); + } + + Ok(()) +} diff --git a/rs/webmcp/codegen/src/schema_mapper.rs b/rs/webmcp/codegen/src/schema_mapper.rs index 62bc4e6b4cfa..02d7be234f98 100644 --- a/rs/webmcp/codegen/src/schema_mapper.rs +++ b/rs/webmcp/codegen/src/schema_mapper.rs @@ -3,11 +3,21 @@ use candid::TypeEnv; use candid::types::{Type, TypeInner}; use serde_json::{Value as JsonValue, json}; +use std::collections::HashSet; /// Convert a Candid type to a JSON Schema value. /// /// The `env` is used to resolve `Var` references (type aliases defined in the .did file). pub fn candid_to_json_schema(ty: &Type, env: &TypeEnv) -> JsonValue { + let mut visited = HashSet::new(); + candid_to_json_schema_inner(ty, env, &mut visited) +} + +fn candid_to_json_schema_inner( + ty: &Type, + env: &TypeEnv, + visited: &mut HashSet, +) -> JsonValue { match ty.as_ref() { TypeInner::Bool => json!({ "type": "boolean" }), TypeInner::Nat => { @@ -25,7 +35,9 @@ pub fn candid_to_json_schema(ty: &Type, env: &TypeEnv) -> JsonValue { json!({ "type": "string", "pattern": "^[0-9]+$", "description": "64-bit natural number" }) } TypeInner::Int8 => json!({ "type": "integer", "minimum": -128, "maximum": 127 }), - TypeInner::Int16 => json!({ "type": "integer", "minimum": -32768, "maximum": 32767 }), + TypeInner::Int16 => { + json!({ "type": "integer", "minimum": -32768, "maximum": 32767 }) + } TypeInner::Int32 => { json!({ "type": "integer", "minimum": -2_147_483_648_i64, "maximum": 2_147_483_647 }) } @@ -51,12 +63,12 @@ pub fn candid_to_json_schema(ty: &Type, env: &TypeEnv) -> JsonValue { } else { json!({ "type": "array", - "items": candid_to_json_schema(inner, env) + "items": candid_to_json_schema_inner(inner, env, visited) }) } } TypeInner::Opt(inner) => { - let inner_schema = candid_to_json_schema(inner, env); + let inner_schema = candid_to_json_schema_inner(inner, env, visited); json!({ "oneOf": [inner_schema, { "type": "null" }] }) @@ -67,8 +79,7 @@ pub fn candid_to_json_schema(ty: &Type, env: &TypeEnv) -> JsonValue { for field in fields { let field_name = field.id.to_string(); - let field_schema = candid_to_json_schema(&field.ty, env); - // All record fields are required unless they're opt + let field_schema = candid_to_json_schema_inner(&field.ty, env, visited); if !matches!(field.ty.as_ref(), TypeInner::Opt(_)) { required.push(JsonValue::String(field_name.clone())); } @@ -90,11 +101,9 @@ pub fn candid_to_json_schema(ty: &Type, env: &TypeEnv) -> JsonValue { .map(|v| { let name = v.id.to_string(); if matches!(v.ty.as_ref(), TypeInner::Null) { - // Unit variant json!({ "const": name }) } else { - // Variant with payload - let payload = candid_to_json_schema(&v.ty, env); + let payload = candid_to_json_schema_inner(&v.ty, env, visited); json!({ "type": "object", "properties": { name.clone(): payload }, @@ -107,13 +116,19 @@ pub fn candid_to_json_schema(ty: &Type, env: &TypeEnv) -> JsonValue { json!({ "oneOf": one_of }) } TypeInner::Var(name) => { - // Resolve type alias from the environment - if let Ok(resolved) = env.rec_find_type(name) { - candid_to_json_schema(resolved, env) + // Cycle detection: if we're already resolving this type, emit a ref + if !visited.insert(name.clone()) { + return json!({ + "description": format!("Recursive type: {}", name) + }); + } + let result = if let Ok(resolved) = env.rec_find_type(name) { + candid_to_json_schema_inner(resolved, env, visited) } else { - // Unresolvable — emit opaque schema json!({ "description": format!("Unresolved type: {}", name) }) - } + }; + visited.remove(name); + result } // Reserved, Empty, Unknown, Knot, Func, Service, Class, Future _ => json!({}), @@ -209,7 +224,6 @@ mod tests { assert_eq!(schema["type"], "object"); assert!(schema["properties"]["owner"].is_object()); assert!(schema["properties"]["subaccount"].is_object()); - // owner is required, subaccount (opt) is not let required = schema["required"].as_array().unwrap(); assert!(required.contains(&serde_json::json!("owner"))); assert!(!required.contains(&serde_json::json!("subaccount"))); @@ -237,4 +251,31 @@ mod tests { let one_of = schema["oneOf"].as_array().unwrap(); assert_eq!(one_of.len(), 2); } + + #[test] + fn test_recursive_type_does_not_stack_overflow() { + // Simulate: type Value = variant { Text: text; Array: vec Value } + // This requires a TypeEnv with the recursive definition + let did = r#" + type Value = variant { Text : text; Array : vec Value; Leaf : null }; + service : { get : (text) -> (Value) query } + "#; + let ast = did.parse::().unwrap(); + let mut env = TypeEnv::new(); + let actor = candid_parser::check_prog(&mut env, &ast).unwrap().unwrap(); + + // Get the return type of `get` method + let func = env.get_method(&actor, "get").unwrap(); + let ret_type = &func.rets[0]; + + // This should NOT stack overflow + let schema = candid_to_json_schema(ret_type, &env); + // The recursive occurrence should be replaced with a description + let json_str = serde_json::to_string(&schema).unwrap(); + assert!( + json_str.contains("Recursive type"), + "Expected recursive type marker in: {}", + json_str + ); + } } diff --git a/rs/webmcp/codegen/tests/integration_test.rs b/rs/webmcp/codegen/tests/integration_test.rs index ae776137a825..a1ccf6068215 100644 --- a/rs/webmcp/codegen/tests/integration_test.rs +++ b/rs/webmcp/codegen/tests/integration_test.rs @@ -3,7 +3,7 @@ use ic_webmcp_codegen::schema_mapper::candid_to_json_schema; use ic_webmcp_codegen::{Config, generate_manifest}; use std::path::PathBuf; -fn ledger_did_path() -> PathBuf { +fn repo_root() -> PathBuf { // CARGO_MANIFEST_DIR = .../rs/webmcp/codegen // repo root = .../ (3 levels up) let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -14,7 +14,11 @@ fn ledger_did_path() -> PathBuf { .unwrap() // rs .parent() .unwrap() // repo root - .join("rs/ledger_suite/icp/ledger.did") + .to_path_buf() +} + +fn ledger_did_path() -> PathBuf { + repo_root().join("rs/ledger_suite/icp/ledger.did") } #[test] @@ -183,3 +187,77 @@ fn test_js_emitter() { "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() + ); + } +} From 89474234bc1a897834ad9a8043a9aa9bee857c77 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 12:36:46 -0400 Subject: [PATCH 03/12] feat(webmcp): add @dfinity/webmcp TypeScript browser package Implements Phase 2: browser-side library bridging navigator.modelContext to Internet Computer canisters via @dfinity/agent. Modules: - types.ts: shared interfaces + navigator.modelContext type declarations - manifest.ts: fetch and validate /.well-known/webmcp.json - candid-json.ts: JSON <-> Candid encoding/decoding for all IDL types - agent-bridge.ts: map tool execute() to agent query/call - tool-registry.ts: register/unregister tools with navigator.modelContext - auth.ts: scoped Internet Identity delegation for agent auth - ic-webmcp.ts: ICWebMCP class tying all modules together - index.ts: public API barrel export --- packages/ic-webmcp/.gitignore | 3 + packages/ic-webmcp/package-lock.json | 1783 +++++++++++++++++++++++ packages/ic-webmcp/package.json | 48 + packages/ic-webmcp/src/agent-bridge.ts | 118 ++ packages/ic-webmcp/src/auth.ts | 72 + packages/ic-webmcp/src/candid-json.ts | 180 +++ packages/ic-webmcp/src/ic-webmcp.ts | 206 +++ packages/ic-webmcp/src/index.ts | 22 + packages/ic-webmcp/src/manifest.ts | 49 + packages/ic-webmcp/src/tool-registry.ts | 98 ++ packages/ic-webmcp/src/types.ts | 93 ++ packages/ic-webmcp/tsconfig.json | 19 + 12 files changed, 2691 insertions(+) create mode 100644 packages/ic-webmcp/.gitignore create mode 100644 packages/ic-webmcp/package-lock.json create mode 100644 packages/ic-webmcp/package.json create mode 100644 packages/ic-webmcp/src/agent-bridge.ts create mode 100644 packages/ic-webmcp/src/auth.ts create mode 100644 packages/ic-webmcp/src/candid-json.ts create mode 100644 packages/ic-webmcp/src/ic-webmcp.ts create mode 100644 packages/ic-webmcp/src/index.ts create mode 100644 packages/ic-webmcp/src/manifest.ts create mode 100644 packages/ic-webmcp/src/tool-registry.ts create mode 100644 packages/ic-webmcp/src/types.ts create mode 100644 packages/ic-webmcp/tsconfig.json diff --git a/packages/ic-webmcp/.gitignore b/packages/ic-webmcp/.gitignore new file mode 100644 index 000000000000..f4e2c6d6b88c --- /dev/null +++ b/packages/ic-webmcp/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/packages/ic-webmcp/package-lock.json b/packages/ic-webmcp/package-lock.json new file mode 100644 index 000000000000..69e72e156a29 --- /dev/null +++ b/packages/ic-webmcp/package-lock.json @@ -0,0 +1,1783 @@ +{ + "name": "@dfinity/webmcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dfinity/webmcp", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@dfinity/agent": "^2.0.0", + "@dfinity/candid": "^2.0.0", + "@dfinity/identity": "^2.0.0", + "@dfinity/principal": "^2.0.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "vitest": "^2.0.0" + }, + "peerDependencies": { + "@dfinity/agent": ">=1.0.0", + "@dfinity/candid": ">=1.0.0", + "@dfinity/principal": ">=1.0.0" + } + }, + "node_modules/@dfinity/agent": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@dfinity/agent/-/agent-2.4.1.tgz", + "integrity": "sha512-IczFFOUDGfMTdQ83yiCvGtvHr1IIB80lWBP0ZYRLogs6NVt8t6HYcMlu1sgT+9VivhT7iwX4pktPFxxOkO3COw==", + "deprecated": "This package has been deprecated. Use @icp-sdk/core/agent instead. Migration guide: https://js.icp.build/core/latest/upgrading/v5", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.4.0", + "@noble/hashes": "^1.3.1", + "base64-arraybuffer": "^0.2.0", + "borc": "^2.1.1", + "buffer": "^6.0.3", + "simple-cbor": "^0.4.1" + }, + "peerDependencies": { + "@dfinity/candid": "^2.4.1", + "@dfinity/principal": "^2.4.1" + } + }, + "node_modules/@dfinity/candid": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@dfinity/candid/-/candid-2.4.1.tgz", + "integrity": "sha512-kOaIKfhR2PYN8vD4M0Pc4s/7wb1nKjlTJUw+5E9jh26T03fITIZmaafIuwlX+wmdxwIT9Xoy7PlsxOEpzv203A==", + "deprecated": "This package has been deprecated. Use @icp-sdk/core/candid instead. Migration guide: https://js.icp.build/core/latest/upgrading/v5", + "license": "Apache-2.0", + "peerDependencies": { + "@dfinity/principal": "^2.4.1" + } + }, + "node_modules/@dfinity/identity": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@dfinity/identity/-/identity-2.4.1.tgz", + "integrity": "sha512-CXhTmdtqkA0vE6ue2GaF9ZwD0OQ5OinrGj77Eg0dX0zPZpxJQ+NCjyYNWkaIvsKxmnCaW+5yrCcchN8Sqk8uIA==", + "deprecated": "This package has been deprecated. Use @icp-sdk/core/identity instead. Migration guide: https://js.icp.build/core/latest/upgrading/v5", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.2.0", + "@noble/hashes": "^1.3.1", + "borc": "^2.1.1" + }, + "peerDependencies": { + "@dfinity/agent": "^2.4.1", + "@dfinity/principal": "^2.4.1" + } + }, + "node_modules/@dfinity/principal": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@dfinity/principal/-/principal-2.4.1.tgz", + "integrity": "sha512-Cz6XQVOwq0TXDBClPbcidDd4SqK1lfr1/Kn34ruDD13xVQ4iaP1iCntzS9O97+vGpY/6jwDtKd32Gn5YJ9BQNw==", + "deprecated": "This package has been deprecated. Use @icp-sdk/core/principal instead. Migration guide: https://js.icp.build/core/latest/upgrading/v5", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^1.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-arraybuffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz", + "integrity": "sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/borc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/borc/-/borc-2.1.2.tgz", + "integrity": "sha512-Sy9eoUi4OiKzq7VovMn246iTo17kzuyHJKomCfpWMlI6RpfN1gk95w7d7gH264nApVLg0HZfcpz62/g4VH1Y4w==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0", + "buffer": "^5.5.0", + "commander": "^2.15.0", + "ieee754": "^1.1.13", + "iso-url": "~0.4.7", + "json-text-sequence": "~0.1.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/borc/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delimit-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/delimit-stream/-/delimit-stream-0.1.0.tgz", + "integrity": "sha512-a02fiQ7poS5CnjiJBAsjGLPp5EwVoGHNeu9sziBd9huppRfsAFIpv5zNLv0V1gbop53ilngAf5Kf331AwcoRBQ==", + "license": "BSD-2-Clause" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/iso-url": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/iso-url/-/iso-url-0.4.7.tgz", + "integrity": "sha512-27fFRDnPAMnHGLq36bWTpKET+eiXct3ENlCcdcMdk+mjXrb2kw3mhBUg1B7ewAC0kVzlOPhADzQgz1SE6Tglog==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-text-sequence": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/json-text-sequence/-/json-text-sequence-0.1.1.tgz", + "integrity": "sha512-L3mEegEWHRekSHjc7+sc8eJhba9Clq1PZ8kMkzf8OxElhXc8O4TS5MwcVlj9aEbm5dr81N90WHC5nAz3UO971w==", + "license": "MIT", + "dependencies": { + "delimit-stream": "0.1.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-cbor": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/simple-cbor/-/simple-cbor-0.4.1.tgz", + "integrity": "sha512-rijcxtwx2b4Bje3sqeIqw5EeW7UlOIC4YfOdwqIKacpvRQ/D78bWg/4/0m5e0U91oKvlGh7LlJuZCu07ISCC7w==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/packages/ic-webmcp/package.json b/packages/ic-webmcp/package.json new file mode 100644 index 000000000000..bba81c918e53 --- /dev/null +++ b/packages/ic-webmcp/package.json @@ -0,0 +1,48 @@ +{ + "name": "@dfinity/webmcp", + "version": "0.1.0", + "description": "Bridge WebMCP (Web Model Context Protocol) to Internet Computer canisters via @dfinity/agent", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@dfinity/agent": "^2.0.0", + "@dfinity/candid": "^2.0.0", + "@dfinity/identity": "^2.0.0", + "@dfinity/principal": "^2.0.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "vitest": "^2.0.0" + }, + "peerDependencies": { + "@dfinity/agent": ">=1.0.0", + "@dfinity/candid": ">=1.0.0", + "@dfinity/principal": ">=1.0.0" + }, + "keywords": [ + "webmcp", + "internet-computer", + "dfinity", + "candid", + "ai-agents", + "model-context-protocol" + ] +} diff --git a/packages/ic-webmcp/src/agent-bridge.ts b/packages/ic-webmcp/src/agent-bridge.ts new file mode 100644 index 000000000000..605b95306d68 --- /dev/null +++ b/packages/ic-webmcp/src/agent-bridge.ts @@ -0,0 +1,118 @@ +import { Actor, HttpAgent, type QueryResponseStatus } from "@dfinity/agent"; +import { IDL } from "@dfinity/candid"; +import { Principal } from "@dfinity/principal"; +import { candidToJson } from "./candid-json.js"; +import type { WebMCPToolDefinition, ToolExecuteResult } from "./types.js"; + +/** + * Execute a canister call for a WebMCP tool invocation. + * + * Maps the JSON parameters from a tool call into a Candid-encoded + * canister call via @dfinity/agent, then decodes the response back to JSON. + */ +export async function executeToolCall( + agent: HttpAgent, + canisterId: Principal, + tool: WebMCPToolDefinition, + params: Record, + idlFactory?: IDL.InterfaceFactory, +): Promise { + // If we have an IDL factory (from .did), use Actor for typed calls + if (idlFactory) { + return executeViaActor(agent, canisterId, tool, params, idlFactory); + } + + // Fallback: raw agent call with empty arg encoding + return executeRawCall(agent, canisterId, tool, params); +} + +async function executeViaActor( + agent: HttpAgent, + canisterId: Principal, + tool: WebMCPToolDefinition, + params: Record, + idlFactory: IDL.InterfaceFactory, +): Promise { + const actor = Actor.createActor(idlFactory, { + agent, + canisterId, + }); + + const method = actor[tool.canister_method] as ( + ...args: unknown[] + ) => Promise; + if (typeof method !== "function") { + throw new Error( + `Method "${tool.canister_method}" not found on actor for canister ${canisterId.toText()}`, + ); + } + + // For Actor calls, we pass params as-is — the Actor handles encoding. + // Single-record-arg methods receive the params object directly. + // Multi-arg methods receive positional args. + const args = buildActorArgs(params); + const result = await method(...args); + + return { value: result }; +} + +async function executeRawCall( + agent: HttpAgent, + canisterId: Principal, + tool: WebMCPToolDefinition, + params: Record, +): Promise { + // Without an IDL factory, we encode with an empty type list + // and pass the params as-is. This is a best-effort fallback. + const arg = IDL.encode([], []); + + if (tool.method_type === "query") { + const response = await agent.query(canisterId, { + methodName: tool.canister_method, + arg, + }); + + if (response.status === ("rejected" as unknown as QueryResponseStatus)) { + const rejected = response as { reject_code?: number; reject_message?: string }; + throw new Error( + `Query "${tool.canister_method}" rejected: ${rejected.reject_message ?? "unknown error"}`, + ); + } + + const replied = response as { reply?: { arg: ArrayBuffer } }; + return { + value: replied.reply?.arg + ? candidToJson(replied.reply.arg, []) + : null, + }; + } else { + const { response } = await agent.call(canisterId, { + methodName: tool.canister_method, + arg, + }); + + return { value: response }; + } +} + +/** + * Convert JSON params into positional args for an Actor method call. + * + * If params have positional keys (arg0, arg1, ...), extract them in order. + * Otherwise, pass the entire params object as a single argument (record). + */ +function buildActorArgs(params: Record): unknown[] { + // Check if params use positional arg naming + if ("arg0" in params) { + const args: unknown[] = []; + let i = 0; + while (`arg${i}` in params) { + args.push(params[`arg${i}`]); + i++; + } + return args; + } + + // Single record argument — pass the whole params object + return [params]; +} diff --git a/packages/ic-webmcp/src/auth.ts b/packages/ic-webmcp/src/auth.ts new file mode 100644 index 000000000000..b554346f32da --- /dev/null +++ b/packages/ic-webmcp/src/auth.ts @@ -0,0 +1,72 @@ +import type { SignIdentity } from "@dfinity/agent"; +import { + DelegationChain, + DelegationIdentity, + Ed25519KeyIdentity, +} from "@dfinity/identity"; +import { Principal } from "@dfinity/principal"; +import type { AuthenticationInfo } from "./types.js"; + +export interface CreateDelegationOptions { + /** The user's base identity (from Internet Identity login). Must be a SignIdentity. */ + baseIdentity: SignIdentity; + + /** Canister IDs this delegation is scoped to. */ + targets?: Principal[]; + + /** Maximum time-to-live in seconds. Default: 3600 (1 hour). */ + maxTtlSeconds?: number; +} + +/** + * Create a scoped delegation identity for AI agent use. + * + * This generates a short-lived, canister-scoped delegation from the user's + * Internet Identity, suitable for granting an AI agent limited access to + * specific canister methods. + * + * The delegation chain restricts: + * - Which canisters can be called (via `targets`) + * - How long the delegation is valid (via `maxTtlSeconds`) + * + * @returns A DelegationIdentity that the agent can use for canister calls. + */ +export async function createScopedDelegation( + options: CreateDelegationOptions, +): Promise { + const { baseIdentity, targets = [], maxTtlSeconds = 3600 } = options; + + // Generate an ephemeral key pair for the delegated identity + const sessionKey = Ed25519KeyIdentity.generate(); + + // Create delegation chain from the base identity to the session key + const chain = await DelegationChain.create( + baseIdentity, + sessionKey.getPublicKey(), + new Date(Date.now() + maxTtlSeconds * 1000), + { + targets: targets.length > 0 ? targets : undefined, + }, + ); + + return DelegationIdentity.fromDelegation(sessionKey, chain); +} + +/** + * Build delegation targets from manifest authentication info and canister ID. + */ +export function getDelegationTargets( + canisterId: string, + auth?: AuthenticationInfo, +): Principal[] { + const targets = new Set(); + targets.add(canisterId); + + if (auth?.delegation_targets) { + for (const target of auth.delegation_targets) { + targets.add(target); + } + } + + return Array.from(targets).map((id) => Principal.fromText(id)); +} diff --git a/packages/ic-webmcp/src/candid-json.ts b/packages/ic-webmcp/src/candid-json.ts new file mode 100644 index 000000000000..12f5caba0b5a --- /dev/null +++ b/packages/ic-webmcp/src/candid-json.ts @@ -0,0 +1,180 @@ +import { IDL } from "@dfinity/candid"; +import { Principal } from "@dfinity/principal"; + +/** + * Encode JSON parameters into Candid binary format. + * + * This function takes a JSON object (as received from a WebMCP tool call) + * and the Candid IDL types for the method arguments, then produces the + * binary Candid encoding suitable for an agent call. + * + * For methods with a single record argument, the JSON params map directly + * to the record fields. For methods with multiple positional arguments, + * params are expected as { arg0: ..., arg1: ..., ... }. + */ +export function jsonToCandid( + params: Record, + argTypes: IDL.Type[], +): ArrayBuffer { + if (argTypes.length === 0) { + return IDL.encode([], []); + } + + // Single record argument: params ARE the record fields + if (argTypes.length === 1 && argTypes[0] instanceof IDL.RecordClass) { + const converted = convertValue(params, argTypes[0]); + return IDL.encode(argTypes, [converted]); + } + + // Multiple positional arguments + const args = argTypes.map((type, i) => { + const key = `arg${i}`; + const value = params[key]; + if (value === undefined) { + throw new Error(`Missing argument ${key}`); + } + return convertValue(value, type); + }); + return IDL.encode(argTypes, args); +} + +/** + * Decode a Candid binary response into a JSON-friendly value. + */ +export function candidToJson( + data: ArrayBuffer, + retTypes: IDL.Type[], +): unknown { + const decoded = IDL.decode(retTypes, data); + if (decoded.length === 0) return null; + if (decoded.length === 1) return toJsonValue(decoded[0]); + return decoded.map(toJsonValue); +} + +/** + * Convert a JSON value into the shape expected by @dfinity/candid IDL encoding. + */ +function convertValue(value: unknown, type: IDL.Type): unknown { + if (type instanceof IDL.BoolClass) { + return Boolean(value); + } + if (type instanceof IDL.TextClass) { + return String(value); + } + if (type instanceof IDL.NatClass || type instanceof IDL.IntClass) { + return BigInt(value as string | number); + } + // Fixed-width nat types (Nat8, Nat16, Nat32, Nat64) + if (type instanceof IDL.FixedNatClass) { + const bits = (type as IDL.FixedNatClass & { _bits: number })._bits; + // Nat64 → bigint, smaller → number + return bits >= 64 ? BigInt(value as string | number) : Number(value); + } + // Fixed-width int types (Int8, Int16, Int32, Int64) + if (type instanceof IDL.FixedIntClass) { + const bits = (type as IDL.FixedIntClass & { _bits: number })._bits; + return bits >= 64 ? BigInt(value as string | number) : Number(value); + } + if (type instanceof IDL.FloatClass) { + return Number(value); + } + if (type instanceof IDL.PrincipalClass) { + return Principal.fromText(value as string); + } + if (type instanceof IDL.VecClass) { + // blob (vec nat8) encoded as base64 + const innerType = (type as IDL.VecClass & { _type: IDL.Type }) + ._type; + if (innerType instanceof IDL.FixedNatClass && typeof value === "string") { + const bits = (innerType as IDL.FixedNatClass & { _bits: number })._bits; + if (bits === 8) { + return base64ToUint8Array(value); + } + } + if (!Array.isArray(value)) { + throw new Error(`Expected array for vec type, got ${typeof value}`); + } + return value.map((item) => convertValue(item, innerType)); + } + if (type instanceof IDL.OptClass) { + if (value === null || value === undefined) { + return []; + } + const innerType = (type as IDL.OptClass & { _type: IDL.Type }) + ._type; + return [convertValue(value, innerType)]; + } + if (type instanceof IDL.RecordClass) { + const obj = value as Record; + const fields = ( + type as IDL.RecordClass & { _fields: [string, IDL.Type][] } + )._fields; + const result: Record = {}; + for (const [fieldName, fieldType] of fields) { + if (fieldName in obj) { + result[fieldName] = convertValue(obj[fieldName], fieldType); + } + } + return result; + } + if (type instanceof IDL.VariantClass) { + // Variant: either a string (unit variant) or { Tag: payload } + if (typeof value === "string") { + return { [value]: null }; + } + const obj = value as Record; + const tag = Object.keys(obj)[0]; + // Access _fields via bracket notation to bypass private access check + const fields = (type as unknown as { _fields: [string, IDL.Type][] }) + ._fields; + const fieldType = fields.find( + ([name]: [string, IDL.Type]) => name === tag, + ); + if (!fieldType) { + throw new Error(`Unknown variant tag: ${tag}`); + } + return { [tag]: convertValue(obj[tag], fieldType[1]) }; + } + if (type instanceof IDL.NullClass) { + return null; + } + + // Fallback: pass through + return value; +} + +/** + * Convert a decoded Candid value into a JSON-safe representation. + */ +function toJsonValue(value: unknown): unknown { + if (value === null || value === undefined) return null; + if (typeof value === "bigint") return value.toString(); + if (value instanceof Principal) return value.toText(); + if (value instanceof Uint8Array) return uint8ArrayToBase64(value); + if (Array.isArray(value)) return value.map(toJsonValue); + if (typeof value === "object") { + const result: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + result[k] = toJsonValue(v); + } + return result; + } + return value; +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} diff --git a/packages/ic-webmcp/src/ic-webmcp.ts b/packages/ic-webmcp/src/ic-webmcp.ts new file mode 100644 index 000000000000..c25ad9312222 --- /dev/null +++ b/packages/ic-webmcp/src/ic-webmcp.ts @@ -0,0 +1,206 @@ +import { HttpAgent, type Identity, type SignIdentity } from "@dfinity/agent"; +import type { IDL } from "@dfinity/candid"; +import { Principal } from "@dfinity/principal"; +import { + createScopedDelegation, + getDelegationTargets, +} from "./auth.js"; +import { fetchManifest } from "./manifest.js"; +import { + registerAllTools, + unregisterAllTools, +} from "./tool-registry.js"; +import type { + ICWebMCPConfig, + WebMCPManifest, + WebMCPToolDefinition, +} from "./types.js"; + +/** + * Main entry point for integrating Internet Computer canisters with WebMCP. + * + * Usage: + * ```ts + * const webmcp = new ICWebMCP({ + * manifestUrl: '/.well-known/webmcp.json', + * }); + * await webmcp.registerAll(); + * ``` + */ +export class ICWebMCP { + private config: ICWebMCPConfig; + private agent: HttpAgent | null = null; + private manifest: WebMCPManifest | null = null; + private canisterId: Principal | null = null; + private registeredTools: WebMCPToolDefinition[] = []; + private idlFactory?: IDL.InterfaceFactory; + + constructor(config: ICWebMCPConfig = {}) { + this.config = { + manifestUrl: "/.well-known/webmcp.json", + host: "https://icp-api.io", + ...config, + }; + } + + /** + * Fetch the manifest and register all tools with navigator.modelContext. + */ + async registerAll(): Promise { + await this.ensureInitialized(); + + await registerAllTools( + this.manifest!.tools, + this.agent!, + this.canisterId!, + { + idlFactory: this.idlFactory, + onAuthRequired: this.config.onAuthRequired + ? async () => { + const identity = await this.config.onAuthRequired!(); + this.setIdentity(identity); + } + : undefined, + }, + ); + + this.registeredTools = [...this.manifest!.tools]; + } + + /** + * Register a single tool by name. + */ + async registerTool(toolName: string): Promise { + await this.ensureInitialized(); + + const tool = this.manifest!.tools.find((t) => t.name === toolName); + if (!tool) { + throw new Error( + `Tool "${toolName}" not found in manifest. Available: ${this.manifest!.tools.map((t) => t.name).join(", ")}`, + ); + } + + const { registerTool: regTool } = await import("./tool-registry.js"); + await regTool(tool, this.agent!, this.canisterId!, { + idlFactory: this.idlFactory, + onAuthRequired: this.config.onAuthRequired + ? async () => { + const identity = await this.config.onAuthRequired!(); + this.setIdentity(identity); + } + : undefined, + }); + + this.registeredTools.push(tool); + } + + /** + * Unregister all previously registered tools. + */ + async unregisterAll(): Promise { + await unregisterAllTools(this.registeredTools); + this.registeredTools = []; + } + + /** + * Get the underlying HttpAgent. + */ + getAgent(): HttpAgent { + if (!this.agent) { + throw new Error("ICWebMCP not initialized. Call registerAll() first."); + } + return this.agent; + } + + /** + * Get the loaded manifest. + */ + getManifest(): WebMCPManifest { + if (!this.manifest) { + throw new Error("ICWebMCP not initialized. Call registerAll() first."); + } + return this.manifest; + } + + /** + * Set or update the identity used for canister calls. + */ + setIdentity(identity: Identity): void { + if (this.agent) { + this.agent.replaceIdentity(identity); + } + this.config.identity = identity; + } + + /** + * Provide an IDL factory for typed Actor-based calls. + * If not set, calls use raw agent encoding. + */ + setIdlFactory(factory: IDL.InterfaceFactory): void { + this.idlFactory = factory; + } + + /** + * Create a scoped delegation identity for agent authentication. + * + * Generates a short-lived, canister-scoped delegation from the current + * identity, suitable for granting an AI agent limited access. + */ + async createAgentDelegation(options?: { + maxTtlSeconds?: number; + }): Promise { + if (!this.config.identity) { + throw new Error( + "No identity set. Connect Internet Identity before creating a delegation.", + ); + } + if (!this.canisterId) { + throw new Error("ICWebMCP not initialized."); + } + + const targets = getDelegationTargets( + this.canisterId.toText(), + this.manifest?.authentication, + ); + + return createScopedDelegation({ + baseIdentity: this.config.identity as SignIdentity, + targets, + maxTtlSeconds: options?.maxTtlSeconds ?? 3600, + }); + } + + private async ensureInitialized(): Promise { + if (this.manifest && this.agent && this.canisterId) { + return; + } + + // Fetch manifest + this.manifest = await fetchManifest(this.config.manifestUrl); + + // Resolve canister ID + const canisterIdText = + this.config.canisterId ?? this.manifest.canister.id; + if (!canisterIdText) { + throw new Error( + "No canister ID provided in config or manifest. Set canisterId in ICWebMCPConfig or in webmcp.json.", + ); + } + this.canisterId = Principal.fromText(canisterIdText); + + // Create agent + this.agent = await HttpAgent.create({ + host: this.config.host, + identity: this.config.identity, + }); + + // In development, fetch the root key + if ( + this.config.host && + (this.config.host.includes("localhost") || + this.config.host.includes("127.0.0.1")) + ) { + await this.agent.fetchRootKey(); + } + } +} diff --git a/packages/ic-webmcp/src/index.ts b/packages/ic-webmcp/src/index.ts new file mode 100644 index 000000000000..46e093675f39 --- /dev/null +++ b/packages/ic-webmcp/src/index.ts @@ -0,0 +1,22 @@ +export { ICWebMCP } from "./ic-webmcp.js"; +export { fetchManifest } from "./manifest.js"; +export { jsonToCandid, candidToJson } from "./candid-json.js"; +export { executeToolCall } from "./agent-bridge.js"; +export { + registerTool, + unregisterTool, + registerAllTools, + unregisterAllTools, +} from "./tool-registry.js"; +export { createScopedDelegation, getDelegationTargets } from "./auth.js"; +export type { + ICWebMCPConfig, + WebMCPManifest, + WebMCPToolDefinition, + CanisterInfo, + AuthenticationInfo, + ToolExecuteResult, + JsonSchema, + ModelContextTool, + ModelContextAPI, +} from "./types.js"; diff --git a/packages/ic-webmcp/src/manifest.ts b/packages/ic-webmcp/src/manifest.ts new file mode 100644 index 000000000000..1a2fc30ac659 --- /dev/null +++ b/packages/ic-webmcp/src/manifest.ts @@ -0,0 +1,49 @@ +import type { WebMCPManifest } from "./types.js"; + +const DEFAULT_MANIFEST_URL = "/.well-known/webmcp.json"; + +/** + * Fetch and parse a WebMCP manifest from a URL. + * + * @param url - URL to fetch the manifest from. Defaults to `/.well-known/webmcp.json`. + * @returns The parsed manifest. + * @throws If the fetch fails or the response is not valid JSON. + */ +export async function fetchManifest( + url: string = DEFAULT_MANIFEST_URL, +): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch WebMCP manifest from ${url}: ${response.status} ${response.statusText}`, + ); + } + + const manifest: WebMCPManifest = await response.json(); + validateManifest(manifest); + return manifest; +} + +function validateManifest(manifest: WebMCPManifest): void { + if (!manifest.schema_version) { + throw new Error("WebMCP manifest missing schema_version"); + } + if (!manifest.canister) { + throw new Error("WebMCP manifest missing canister info"); + } + if (!Array.isArray(manifest.tools) || manifest.tools.length === 0) { + throw new Error("WebMCP manifest has no tools defined"); + } + for (const tool of manifest.tools) { + if (!tool.name || !tool.canister_method || !tool.inputSchema) { + throw new Error( + `WebMCP tool "${tool.name ?? "unknown"}" is missing required fields`, + ); + } + if (tool.method_type !== "query" && tool.method_type !== "update") { + throw new Error( + `WebMCP tool "${tool.name}" has invalid method_type: ${tool.method_type}`, + ); + } + } +} diff --git a/packages/ic-webmcp/src/tool-registry.ts b/packages/ic-webmcp/src/tool-registry.ts new file mode 100644 index 000000000000..938c76846109 --- /dev/null +++ b/packages/ic-webmcp/src/tool-registry.ts @@ -0,0 +1,98 @@ +import type { HttpAgent } from "@dfinity/agent"; +import type { IDL } from "@dfinity/candid"; +import { Principal } from "@dfinity/principal"; +import { executeToolCall } from "./agent-bridge.js"; +import type { WebMCPToolDefinition } from "./types.js"; + +/** + * Register a canister tool with `navigator.modelContext`. + * + * This creates the bridge between the browser's WebMCP API and an IC canister + * method: when an AI agent calls the tool, the execute callback translates + * the JSON params into a canister call via @dfinity/agent. + */ +export async function registerTool( + tool: WebMCPToolDefinition, + agent: HttpAgent, + canisterId: Principal, + options?: { + idlFactory?: IDL.InterfaceFactory; + onAuthRequired?: () => Promise; + }, +): Promise { + const modelContext = navigator.modelContext; + if (!modelContext) { + throw new Error( + "navigator.modelContext is not available. WebMCP requires Chrome 146+ with the WebMCP flag enabled.", + ); + } + + await modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: async (params: Record) => { + // Check if auth is required + if (tool.requires_auth) { + const identity = await agent.config?.identity; + const isAnonymous = !identity || identity.getPrincipal().isAnonymous(); + if (isAnonymous) { + if (options?.onAuthRequired) { + await options.onAuthRequired(); + } else { + throw new Error( + `Tool "${tool.name}" requires authentication. Please connect Internet Identity.`, + ); + } + } + } + + const result = await executeToolCall( + agent, + canisterId, + tool, + params, + options?.idlFactory, + ); + + return result.value; + }, + }); +} + +/** + * Unregister a tool from `navigator.modelContext`. + */ +export async function unregisterTool(name: string): Promise { + const modelContext = navigator.modelContext; + if (!modelContext) return; + await modelContext.unregisterTool(name); +} + +/** + * Register all tools from a manifest. + */ +export async function registerAllTools( + tools: WebMCPToolDefinition[], + agent: HttpAgent, + canisterId: Principal, + options?: { + idlFactory?: IDL.InterfaceFactory; + onAuthRequired?: () => Promise; + }, +): Promise { + for (const tool of tools) { + await registerTool(tool, agent, canisterId, options); + } +} + +/** + * Unregister all tools from a manifest. + */ +export async function unregisterAllTools( + tools: WebMCPToolDefinition[], +): Promise { + for (const tool of tools) { + await unregisterTool(tool.name); + } +} diff --git a/packages/ic-webmcp/src/types.ts b/packages/ic-webmcp/src/types.ts new file mode 100644 index 000000000000..53a4dde2456f --- /dev/null +++ b/packages/ic-webmcp/src/types.ts @@ -0,0 +1,93 @@ +import type { Identity } from "@dfinity/agent"; + +// ── WebMCP Manifest (mirrors webmcp.json from codegen) ────────────── + +export interface WebMCPManifest { + schema_version: string; + canister: CanisterInfo; + tools: WebMCPToolDefinition[]; + authentication?: AuthenticationInfo; +} + +export interface CanisterInfo { + id?: string; + name: string; + description: string; +} + +export interface WebMCPToolDefinition { + name: string; + description: string; + canister_method: string; + method_type: "query" | "update"; + certified?: boolean; + requires_auth?: boolean; + inputSchema: JsonSchema; + outputSchema?: JsonSchema; +} + +export interface AuthenticationInfo { + type: string; + delegation_targets?: string[]; + recommended_scope?: Record< + string, + { + max_ttl_seconds?: number; + description?: string; + } + >; +} + +// ── JSON Schema subset ────────────────────────────────────────────── + +export type JsonSchema = Record; + +// ── ICWebMCP Configuration ────────────────────────────────────────── + +export interface ICWebMCPConfig { + /** URL to fetch the manifest from. Default: '/.well-known/webmcp.json' */ + manifestUrl?: string; + + /** Override canister ID (otherwise read from manifest). */ + canisterId?: string; + + /** IC replica host. Default: 'https://icp-api.io' */ + host?: string; + + /** Pre-existing identity to use for calls. */ + identity?: Identity; + + /** Callback invoked when a tool requires authentication. */ + onAuthRequired?: () => Promise; +} + +// ── Tool Execution ────────────────────────────────────────────────── + +export interface ToolExecuteResult { + value: unknown; + certified?: boolean; + certificate?: ArrayBuffer; + tree?: ArrayBuffer; +} + +// ── navigator.modelContext types (Chrome 146+) ────────────────────── +// These represent the browser API surface. Declared here so the +// library compiles without Chrome-specific type defs. + +export interface ModelContextTool { + name: string; + description: string; + inputSchema: JsonSchema; + execute: (params: Record) => Promise; +} + +export interface ModelContextAPI { + registerTool(tool: ModelContextTool): Promise; + unregisterTool(name: string): Promise; +} + +declare global { + interface Navigator { + modelContext?: ModelContextAPI; + } +} diff --git a/packages/ic-webmcp/tsconfig.json b/packages/ic-webmcp/tsconfig.json new file mode 100644 index 000000000000..ba511415e596 --- /dev/null +++ b/packages/ic-webmcp/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From 273307aaac6cf6b47e66c8d9b0a836c3c35a37d1 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 12:52:17 -0400 Subject: [PATCH 04/12] test(webmcp): add Vitest test suite for @dfinity/webmcp TypeScript package 52 tests across 4 modules: manifest fetch/validation, candid-json type roundtrips (opt, blob, record, variant, bigint), tool-registry mock of navigator.modelContext, ICWebMCP class lifecycle. Also fixes opt/variant JSON decoding to be type-aware, correctly unwrapping Candid array representation of opt into null/value. --- packages/ic-webmcp/package-lock.json | 18 ++ packages/ic-webmcp/package.json | 7 +- packages/ic-webmcp/src/candid-json.ts | 50 ++++- .../ic-webmcp/src/tests/candid-json.test.ts | 169 +++++++++++++++ .../ic-webmcp/src/tests/ic-webmcp.test.ts | 203 ++++++++++++++++++ packages/ic-webmcp/src/tests/manifest.test.ts | 99 +++++++++ .../ic-webmcp/src/tests/tool-registry.test.ts | 190 ++++++++++++++++ packages/ic-webmcp/tsconfig.json | 2 +- packages/ic-webmcp/tsconfig.test.json | 8 + packages/ic-webmcp/vitest.config.ts | 11 + 10 files changed, 750 insertions(+), 7 deletions(-) create mode 100644 packages/ic-webmcp/src/tests/candid-json.test.ts create mode 100644 packages/ic-webmcp/src/tests/ic-webmcp.test.ts create mode 100644 packages/ic-webmcp/src/tests/manifest.test.ts create mode 100644 packages/ic-webmcp/src/tests/tool-registry.test.ts create mode 100644 packages/ic-webmcp/tsconfig.test.json create mode 100644 packages/ic-webmcp/vitest.config.ts diff --git a/packages/ic-webmcp/package-lock.json b/packages/ic-webmcp/package-lock.json index 69e72e156a29..b53b794d51db 100644 --- a/packages/ic-webmcp/package-lock.json +++ b/packages/ic-webmcp/package-lock.json @@ -15,6 +15,7 @@ "@dfinity/principal": "^2.0.0" }, "devDependencies": { + "@types/node": "^25.5.0", "typescript": "^5.4.0", "vitest": "^2.0.0" }, @@ -900,6 +901,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1607,6 +1618,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/packages/ic-webmcp/package.json b/packages/ic-webmcp/package.json index bba81c918e53..94f1ce964910 100644 --- a/packages/ic-webmcp/package.json +++ b/packages/ic-webmcp/package.json @@ -23,12 +23,13 @@ "lint": "tsc --noEmit" }, "dependencies": { - "@dfinity/agent": "^2.0.0", - "@dfinity/candid": "^2.0.0", + "@dfinity/agent": ">=1.0.0", + "@dfinity/candid": ">=1.0.0", "@dfinity/identity": "^2.0.0", - "@dfinity/principal": "^2.0.0" + "@dfinity/principal": ">=1.0.0" }, "devDependencies": { + "@types/node": "^25.5.0", "typescript": "^5.4.0", "vitest": "^2.0.0" }, diff --git a/packages/ic-webmcp/src/candid-json.ts b/packages/ic-webmcp/src/candid-json.ts index 12f5caba0b5a..22edd0ca2890 100644 --- a/packages/ic-webmcp/src/candid-json.ts +++ b/packages/ic-webmcp/src/candid-json.ts @@ -47,8 +47,8 @@ export function candidToJson( ): unknown { const decoded = IDL.decode(retTypes, data); if (decoded.length === 0) return null; - if (decoded.length === 1) return toJsonValue(decoded[0]); - return decoded.map(toJsonValue); + if (decoded.length === 1) return toJsonValueTyped(decoded[0], retTypes[0]); + return decoded.map((v, i) => toJsonValueTyped(v, retTypes[i])); } /** @@ -144,7 +144,51 @@ function convertValue(value: unknown, type: IDL.Type): unknown { } /** - * Convert a decoded Candid value into a JSON-safe representation. + * Type-aware converter: uses IDL type to correctly unwrap Opt, Vec, Record, etc. + */ +function toJsonValueTyped(value: unknown, type: IDL.Type): unknown { + // Opt is decoded as [] (none) or [value] (some) + if (type instanceof IDL.OptClass) { + if (!Array.isArray(value) || value.length === 0) return null; + const innerType = (type as IDL.OptClass & { _type: IDL.Type })._type; + return toJsonValueTyped(value[0], innerType); + } + // blob (vec nat8) → base64 + if (type instanceof IDL.VecClass) { + const innerType = (type as IDL.VecClass & { _type: IDL.Type })._type; + if (innerType instanceof IDL.FixedNatClass) { + const bits = (innerType as IDL.FixedNatClass & { _bits: number })._bits; + if (bits === 8 && value instanceof Uint8Array) return uint8ArrayToBase64(value); + } + if (Array.isArray(value)) return value.map((v) => toJsonValueTyped(v, innerType)); + } + // Record: recurse per-field + if (type instanceof IDL.RecordClass) { + const fields = (type as IDL.RecordClass & { _fields: [string, IDL.Type][] })._fields; + const obj = value as Record; + const result: Record = {}; + for (const [name, fieldType] of fields) { + result[name] = toJsonValueTyped(obj[name], fieldType); + } + return result; + } + // Variant: unwrap the single key + if (type instanceof IDL.VariantClass) { + const fields = (type as unknown as { _fields: [string, IDL.Type][] })._fields; + const obj = value as Record; + const tag = Object.keys(obj)[0]; + const fieldType = fields.find(([n]) => n === tag); + if (fieldType) { + const inner = toJsonValueTyped(obj[tag], fieldType[1]); + return inner === null ? tag : { [tag]: inner }; + } + } + // Fallback to untyped + return toJsonValue(value); +} + +/** + * Convert a decoded Candid value into a JSON-safe representation (untyped fallback). */ function toJsonValue(value: unknown): unknown { if (value === null || value === undefined) return null; diff --git a/packages/ic-webmcp/src/tests/candid-json.test.ts b/packages/ic-webmcp/src/tests/candid-json.test.ts new file mode 100644 index 000000000000..3862cddb4d5a --- /dev/null +++ b/packages/ic-webmcp/src/tests/candid-json.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from "vitest"; +import { IDL } from "@dfinity/candid"; +import { Principal } from "@dfinity/principal"; +import { jsonToCandid, candidToJson } from "../candid-json.js"; + +// ── Helpers ───────────────────────────────────────────────────────── + +function roundtrip(value: unknown, type: IDL.Type): unknown { + const encoded = jsonToCandid( + value as Record, + [type], + ); + return candidToJson(encoded, [type]); +} + +function encodeArg(params: Record, types: IDL.Type[]): ArrayBuffer { + return jsonToCandid(params, types); +} + +// ── Primitive types ───────────────────────────────────────────────── + +describe("jsonToCandid / candidToJson — primitives", () => { + it("roundtrips text", () => { + const encoded = encodeArg({ arg0: "hello world" }, [IDL.Text]); + expect(candidToJson(encoded, [IDL.Text])).toBe("hello world"); + }); + + it("roundtrips bool true/false", () => { + const t = encodeArg({ arg0: true }, [IDL.Bool]); + expect(candidToJson(t, [IDL.Bool])).toBe(true); + const f = encodeArg({ arg0: false }, [IDL.Bool]); + expect(candidToJson(f, [IDL.Bool])).toBe(false); + }); + + it("roundtrips nat as string", () => { + const encoded = encodeArg({ arg0: "12345678901234567890" }, [IDL.Nat]); + expect(candidToJson(encoded, [IDL.Nat])).toBe("12345678901234567890"); + }); + + it("roundtrips int as string", () => { + const encoded = encodeArg({ arg0: "-42" }, [IDL.Int]); + expect(candidToJson(encoded, [IDL.Int])).toBe("-42"); + }); + + it("roundtrips nat8", () => { + const encoded = encodeArg({ arg0: 255 }, [IDL.Nat8]); + expect(candidToJson(encoded, [IDL.Nat8])).toBe(255); + }); + + it("roundtrips nat64 as string", () => { + const encoded = encodeArg({ arg0: "18446744073709551615" }, [IDL.Nat64]); + expect(candidToJson(encoded, [IDL.Nat64])).toBe("18446744073709551615"); + }); + + it("roundtrips float64", () => { + const encoded = encodeArg({ arg0: 3.14 }, [IDL.Float64]); + expect(candidToJson(encoded, [IDL.Float64])).toBeCloseTo(3.14); + }); + + it("roundtrips null", () => { + const encoded = encodeArg({ arg0: null }, [IDL.Null]); + expect(candidToJson(encoded, [IDL.Null])).toBeNull(); + }); + + it("roundtrips principal", () => { + const p = "ryjl3-tyaaa-aaaaa-aaaba-cai"; + const encoded = encodeArg({ arg0: p }, [IDL.Principal]); + expect(candidToJson(encoded, [IDL.Principal])).toBe(p); + }); +}); + +// ── Composite types ───────────────────────────────────────────────── + +describe("jsonToCandid / candidToJson — composite types", () => { + it("roundtrips opt (some)", () => { + const encoded = encodeArg({ arg0: "hello" }, [IDL.Opt(IDL.Text)]); + expect(candidToJson(encoded, [IDL.Opt(IDL.Text)])).toBe("hello"); + }); + + it("roundtrips opt (none)", () => { + const encoded = encodeArg({ arg0: null }, [IDL.Opt(IDL.Text)]); + expect(candidToJson(encoded, [IDL.Opt(IDL.Text)])).toBeNull(); + }); + + it("roundtrips vec text", () => { + const encoded = encodeArg({ arg0: ["a", "b", "c"] }, [IDL.Vec(IDL.Text)]); + expect(candidToJson(encoded, [IDL.Vec(IDL.Text)])).toEqual(["a", "b", "c"]); + }); + + it("roundtrips blob as base64", () => { + // Encode a blob via IDL directly, then decode to base64 + const bytes = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" + const encoded = IDL.encode([IDL.Vec(IDL.Nat8)], [bytes]); + const result = candidToJson(encoded, [IDL.Vec(IDL.Nat8)]); + expect(typeof result).toBe("string"); + expect(atob(result as string)).toBe("Hello"); + }); + + it("roundtrips record", () => { + const AccountType = IDL.Record({ + owner: IDL.Principal, + amount: IDL.Nat64, + }); + const encoded = encodeArg( + { owner: "ryjl3-tyaaa-aaaaa-aaaba-cai", amount: "1000000" }, + [AccountType], + ); + const result = candidToJson(encoded, [AccountType]) as Record; + expect(result.owner).toBe("ryjl3-tyaaa-aaaaa-aaaba-cai"); + expect(result.amount).toBe("1000000"); + }); + + it("roundtrips variant (unit)", () => { + const Status = IDL.Variant({ Ok: IDL.Null, Err: IDL.Text }); + const encoded = IDL.encode([Status], [{ Ok: null }]); + // Unit variants (Null payload) are represented as plain strings + expect(candidToJson(encoded, [Status])).toBe("Ok"); + }); + + it("roundtrips variant (with payload)", () => { + const Status = IDL.Variant({ Ok: IDL.Nat, Err: IDL.Text }); + const encoded = IDL.encode([Status], [{ Err: "something failed" }]); + const result = candidToJson(encoded, [Status]) as Record; + expect(result.Err).toBe("something failed"); + }); + + it("handles multiple positional args", () => { + const encoded = encodeArg( + { arg0: "alice", arg1: 42 }, + [IDL.Text, IDL.Nat32], + ); + const decoded = IDL.decode([IDL.Text, IDL.Nat32], encoded); + expect(decoded[0]).toBe("alice"); + expect(decoded[1]).toBe(42); + }); + + it("handles empty arg list", () => { + const encoded = jsonToCandid({}, []); + expect(encoded.byteLength).toBeGreaterThan(0); // DIDL header + }); +}); + +// ── toJsonValue edge cases ────────────────────────────────────────── + +describe("candidToJson — value conversion", () => { + it("converts bigint to string", () => { + const encoded = IDL.encode([IDL.Nat], [BigInt("999999999999999999")]); + expect(candidToJson(encoded, [IDL.Nat])).toBe("999999999999999999"); + }); + + it("converts Principal to text", () => { + const p = Principal.fromText("aaaaa-aa"); + const encoded = IDL.encode([IDL.Principal], [p]); + expect(candidToJson(encoded, [IDL.Principal])).toBe("aaaaa-aa"); + }); + + it("returns null for empty return type list", () => { + const encoded = IDL.encode([], []); + expect(candidToJson(encoded, [])).toBeNull(); + }); + + it("returns array for multiple return values", () => { + const encoded = IDL.encode([IDL.Text, IDL.Nat32], ["hello", 7]); + const result = candidToJson(encoded, [IDL.Text, IDL.Nat32]); + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[])[0]).toBe("hello"); + expect((result as unknown[])[1]).toBe(7); + }); +}); diff --git a/packages/ic-webmcp/src/tests/ic-webmcp.test.ts b/packages/ic-webmcp/src/tests/ic-webmcp.test.ts new file mode 100644 index 000000000000..e5f3cd29a5e7 --- /dev/null +++ b/packages/ic-webmcp/src/tests/ic-webmcp.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { WebMCPManifest } from "../types.js"; + +// vi.mock must be at module top-level so vitest can hoist it +vi.mock("@dfinity/agent", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HttpAgent: { + ...actual.HttpAgent, + create: vi.fn().mockResolvedValue({ + config: { identity: Promise.resolve(null) }, + query: vi.fn().mockResolvedValue({ + status: "replied", + reply: { arg: new ArrayBuffer(0) }, + }), + call: vi.fn().mockResolvedValue({ response: {} }), + replaceIdentity: vi.fn(), + fetchRootKey: vi.fn(), + }), + }, + }; +}); + +// Import after mock is set up +const { ICWebMCP } = await import("../ic-webmcp.js"); + +// ── Shared fixtures ────────────────────────────────────────────────── + +const MANIFEST: WebMCPManifest = { + schema_version: "1.0", + canister: { + id: "ryjl3-tyaaa-aaaaa-aaaba-cai", + name: "Test Canister", + description: "A test", + }, + tools: [ + { + name: "greet", + description: "Say hello", + canister_method: "greet", + method_type: "query", + inputSchema: { type: "object" }, + }, + { + name: "transfer", + description: "Transfer tokens", + canister_method: "transfer", + method_type: "update", + requires_auth: true, + inputSchema: { type: "object" }, + }, + ], + authentication: { + type: "internet-identity", + delegation_targets: ["ryjl3-tyaaa-aaaaa-aaaba-cai"], + }, +}; + +function mockFetchManifest(manifest: WebMCPManifest = MANIFEST) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: () => Promise.resolve(manifest), + }); +} + +function mockModelContext() { + Object.defineProperty(global, "navigator", { + value: { + modelContext: { + registerTool: vi.fn().mockResolvedValue(undefined), + unregisterTool: vi.fn().mockResolvedValue(undefined), + }, + }, + writable: true, + configurable: true, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockFetchManifest(); + mockModelContext(); +}); + +// ── Tests ───────────────────────────────────────────────────────────── + +describe("ICWebMCP", () => { + it("constructs with default config", () => { + const webmcp = new ICWebMCP(); + expect(webmcp).toBeDefined(); + }); + + it("constructs with custom config", () => { + const webmcp = new ICWebMCP({ + manifestUrl: "/custom/webmcp.json", + canisterId: "aaaaa-aa", + host: "http://localhost:8080", + }); + expect(webmcp).toBeDefined(); + }); + + it("registerAll fetches manifest and registers tools", async () => { + const webmcp = new ICWebMCP(); + await webmcp.registerAll(); + + expect(global.fetch).toHaveBeenCalledWith("/.well-known/webmcp.json"); + expect(navigator.modelContext!.registerTool).toHaveBeenCalledTimes(2); + }); + + it("getManifest returns manifest after registerAll", async () => { + const webmcp = new ICWebMCP(); + await webmcp.registerAll(); + const manifest = webmcp.getManifest(); + expect(manifest.canister.name).toBe("Test Canister"); + expect(manifest.tools).toHaveLength(2); + }); + + it("getAgent returns agent after registerAll", async () => { + const webmcp = new ICWebMCP(); + await webmcp.registerAll(); + expect(webmcp.getAgent()).toBeDefined(); + }); + + it("getManifest throws before initialization", () => { + const webmcp = new ICWebMCP(); + expect(() => webmcp.getManifest()).toThrow("not initialized"); + }); + + it("getAgent throws before initialization", () => { + const webmcp = new ICWebMCP(); + expect(() => webmcp.getAgent()).toThrow("not initialized"); + }); + + it("unregisterAll unregisters all registered tools", async () => { + const webmcp = new ICWebMCP(); + await webmcp.registerAll(); + await webmcp.unregisterAll(); + + expect(navigator.modelContext!.unregisterTool).toHaveBeenCalledTimes(2); + expect(navigator.modelContext!.unregisterTool).toHaveBeenCalledWith("greet"); + expect(navigator.modelContext!.unregisterTool).toHaveBeenCalledWith("transfer"); + }); + + it("registerTool registers a single named tool", async () => { + const webmcp = new ICWebMCP(); + await webmcp.registerAll(); + + vi.mocked(navigator.modelContext!.registerTool).mockClear(); + await webmcp.registerTool("greet"); + + expect(navigator.modelContext!.registerTool).toHaveBeenCalledOnce(); + const call = vi.mocked(navigator.modelContext!.registerTool).mock + .calls[0][0] as import("../types.js").ModelContextTool; + expect(call.name).toBe("greet"); + }); + + it("registerTool throws for unknown tool name", async () => { + const webmcp = new ICWebMCP(); + await webmcp.registerAll(); + await expect(webmcp.registerTool("nonexistent")).rejects.toThrow( + "not found in manifest", + ); + }); + + it("setIdentity calls agent.replaceIdentity", async () => { + const webmcp = new ICWebMCP(); + await webmcp.registerAll(); + const agent = webmcp.getAgent(); + + const mockIdentity = { + getPrincipal: vi.fn(), + } as unknown as import("@dfinity/agent").Identity; + webmcp.setIdentity(mockIdentity); + + expect(agent.replaceIdentity).toHaveBeenCalledWith(mockIdentity); + }); + + it("uses canisterId from config over manifest", async () => { + const webmcp = new ICWebMCP({ canisterId: "aaaaa-aa" }); + await webmcp.registerAll(); + expect(webmcp.getManifest()).toBeDefined(); + }); + + it("throws when no canisterId in config or manifest", async () => { + mockFetchManifest({ + ...MANIFEST, + canister: { name: "No ID", description: "test" }, + }); + const webmcp = new ICWebMCP(); + await expect(webmcp.registerAll()).rejects.toThrow("No canister ID"); + }); + + it("does not re-fetch manifest on second registerAll", async () => { + const webmcp = new ICWebMCP(); + await webmcp.registerAll(); + await webmcp.registerAll(); + // fetch only called once — subsequent call reuses existing manifest + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ic-webmcp/src/tests/manifest.test.ts b/packages/ic-webmcp/src/tests/manifest.test.ts new file mode 100644 index 000000000000..64a369bd4481 --- /dev/null +++ b/packages/ic-webmcp/src/tests/manifest.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchManifest } from "../manifest.js"; +import type { WebMCPManifest } from "../types.js"; + +const VALID_MANIFEST: WebMCPManifest = { + schema_version: "1.0", + canister: { + id: "ryjl3-tyaaa-aaaaa-aaaba-cai", + name: "Test Canister", + description: "A test canister", + }, + tools: [ + { + name: "greet", + description: "Say hello", + canister_method: "greet", + method_type: "query", + inputSchema: { type: "object", properties: { name: { type: "string" } } }, + }, + { + name: "set_name", + description: "Set the name", + canister_method: "set_name", + method_type: "update", + requires_auth: true, + inputSchema: { type: "object", properties: { name: { type: "string" } } }, + }, + ], +}; + +function mockFetch(body: unknown, status = 200) { + global.fetch = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + json: () => Promise.resolve(body), + }); +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("fetchManifest", () => { + it("fetches and returns a valid manifest", async () => { + mockFetch(VALID_MANIFEST); + const manifest = await fetchManifest("/.well-known/webmcp.json"); + expect(manifest.schema_version).toBe("1.0"); + expect(manifest.canister.name).toBe("Test Canister"); + expect(manifest.tools).toHaveLength(2); + }); + + it("uses default URL when none provided", async () => { + mockFetch(VALID_MANIFEST); + await fetchManifest(); + expect(global.fetch).toHaveBeenCalledWith("/.well-known/webmcp.json"); + }); + + it("throws on non-ok HTTP response", async () => { + mockFetch({ error: "not found" }, 404); + await expect(fetchManifest()).rejects.toThrow("404"); + }); + + it("throws when schema_version is missing", async () => { + const bad = { ...VALID_MANIFEST, schema_version: undefined }; + mockFetch(bad); + await expect(fetchManifest()).rejects.toThrow("schema_version"); + }); + + it("throws when canister info is missing", async () => { + const bad = { ...VALID_MANIFEST, canister: undefined }; + mockFetch(bad); + await expect(fetchManifest()).rejects.toThrow("canister info"); + }); + + it("throws when tools array is empty", async () => { + const bad = { ...VALID_MANIFEST, tools: [] }; + mockFetch(bad); + await expect(fetchManifest()).rejects.toThrow("no tools"); + }); + + it("throws when a tool has invalid method_type", async () => { + const bad = { + ...VALID_MANIFEST, + tools: [{ ...VALID_MANIFEST.tools[0], method_type: "subscribe" }], + }; + mockFetch(bad); + await expect(fetchManifest()).rejects.toThrow("method_type"); + }); + + it("throws when a tool is missing canister_method", async () => { + const bad = { + ...VALID_MANIFEST, + tools: [{ ...VALID_MANIFEST.tools[0], canister_method: undefined }], + }; + mockFetch(bad); + await expect(fetchManifest()).rejects.toThrow("missing required fields"); + }); +}); diff --git a/packages/ic-webmcp/src/tests/tool-registry.test.ts b/packages/ic-webmcp/src/tests/tool-registry.test.ts new file mode 100644 index 000000000000..2cbd0f24ba95 --- /dev/null +++ b/packages/ic-webmcp/src/tests/tool-registry.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Principal } from "@dfinity/principal"; +import { + registerTool, + unregisterTool, + registerAllTools, + unregisterAllTools, +} from "../tool-registry.js"; +import type { WebMCPToolDefinition, ModelContextTool } from "../types.js"; + +// ── Fixtures ───────────────────────────────────────────────────────── + +const QUERY_TOOL: WebMCPToolDefinition = { + name: "greet", + description: "Say hello", + canister_method: "greet", + method_type: "query", + inputSchema: { type: "object", properties: { name: { type: "string" } } }, +}; + +const AUTH_TOOL: WebMCPToolDefinition = { + name: "transfer", + description: "Transfer tokens", + canister_method: "transfer", + method_type: "update", + requires_auth: true, + inputSchema: { type: "object", properties: { amount: { type: "string" } } }, +}; + +const CANISTER_ID = Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai"); + +function makeAgent(isAnonymous = true) { + const mockPrincipal = { + isAnonymous: () => isAnonymous, + toText: () => (isAnonymous ? "2vxsx-fae" : "aaaaa-aa"), + }; + return { + config: { + identity: Promise.resolve({ + getPrincipal: () => mockPrincipal, + }), + }, + query: vi.fn().mockResolvedValue({ + status: "replied", + reply: { arg: new ArrayBuffer(0) }, + }), + call: vi.fn().mockResolvedValue({ response: {} }), + } as unknown as import("@dfinity/agent").HttpAgent; +} + +function makeModelContext() { + const registered = new Map(); + return { + registerTool: vi.fn(async (tool: { name: string }) => { + registered.set(tool.name, tool); + }), + unregisterTool: vi.fn(async (name: string) => { + registered.delete(name); + }), + _registered: registered, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────── + +describe("registerTool", () => { + it("throws if navigator.modelContext is unavailable", async () => { + Object.defineProperty(global, "navigator", { + value: {}, + writable: true, + configurable: true, + }); + + await expect( + registerTool(QUERY_TOOL, makeAgent(), CANISTER_ID), + ).rejects.toThrow("navigator.modelContext is not available"); + }); + + it("registers a tool with navigator.modelContext", async () => { + const ctx = makeModelContext(); + Object.defineProperty(global, "navigator", { + value: { modelContext: ctx }, + writable: true, + configurable: true, + }); + + await registerTool(QUERY_TOOL, makeAgent(), CANISTER_ID); + + expect(ctx.registerTool).toHaveBeenCalledOnce(); + const call = ctx.registerTool.mock.calls[0][0] as ModelContextTool; + expect(call.name).toBe("greet"); + expect(call.description).toBe("Say hello"); + expect(typeof call.execute).toBe("function"); + }); + + it("calls onAuthRequired when auth tool is called anonymously", async () => { + const ctx = makeModelContext(); + Object.defineProperty(global, "navigator", { + value: { modelContext: ctx }, + writable: true, + configurable: true, + }); + + const onAuthRequired = vi.fn().mockResolvedValue(undefined); + await registerTool(AUTH_TOOL, makeAgent(true), CANISTER_ID, { + onAuthRequired, + }); + + // Trigger the execute callback + const registeredCall = ctx.registerTool.mock.calls[0][0] as ModelContextTool; + await registeredCall.execute({ amount: "100" }); + + expect(onAuthRequired).toHaveBeenCalledOnce(); + }); + + it("throws when auth tool called anonymously with no onAuthRequired", async () => { + const ctx = makeModelContext(); + Object.defineProperty(global, "navigator", { + value: { modelContext: ctx }, + writable: true, + configurable: true, + }); + + await registerTool(AUTH_TOOL, makeAgent(true), CANISTER_ID); + + const registeredCall = ctx.registerTool.mock.calls[0][0] as ModelContextTool; + await expect(registeredCall.execute({ amount: "100" })).rejects.toThrow( + "requires authentication", + ); + }); +}); + +describe("unregisterTool", () => { + it("calls modelContext.unregisterTool", async () => { + const ctx = makeModelContext(); + Object.defineProperty(global, "navigator", { + value: { modelContext: ctx }, + writable: true, + configurable: true, + }); + + await unregisterTool("greet"); + expect(ctx.unregisterTool).toHaveBeenCalledWith("greet"); + }); + + it("is a no-op if modelContext is unavailable", async () => { + Object.defineProperty(global, "navigator", { + value: {}, + writable: true, + configurable: true, + }); + await expect(unregisterTool("greet")).resolves.toBeUndefined(); + }); +}); + +describe("registerAllTools / unregisterAllTools", () => { + it("registers all tools in order", async () => { + const ctx = makeModelContext(); + Object.defineProperty(global, "navigator", { + value: { modelContext: ctx }, + writable: true, + configurable: true, + }); + + const tools = [QUERY_TOOL, AUTH_TOOL]; + await registerAllTools(tools, makeAgent(), CANISTER_ID); + + expect(ctx.registerTool).toHaveBeenCalledTimes(2); + expect(ctx.registerTool.mock.calls[0][0].name).toBe("greet"); + expect(ctx.registerTool.mock.calls[1][0].name).toBe("transfer"); + }); + + it("unregisters all tools", async () => { + const ctx = makeModelContext(); + Object.defineProperty(global, "navigator", { + value: { modelContext: ctx }, + writable: true, + configurable: true, + }); + + await unregisterAllTools([QUERY_TOOL, AUTH_TOOL]); + expect(ctx.unregisterTool).toHaveBeenCalledTimes(2); + expect(ctx.unregisterTool).toHaveBeenCalledWith("greet"); + expect(ctx.unregisterTool).toHaveBeenCalledWith("transfer"); + }); +}); diff --git a/packages/ic-webmcp/tsconfig.json b/packages/ic-webmcp/tsconfig.json index ba511415e596..0329b95ea1e4 100644 --- a/packages/ic-webmcp/tsconfig.json +++ b/packages/ic-webmcp/tsconfig.json @@ -15,5 +15,5 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"] }, "include": ["src"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/tests"] } diff --git a/packages/ic-webmcp/tsconfig.test.json b/packages/ic-webmcp/tsconfig.test.json new file mode 100644 index 000000000000..a01248578b61 --- /dev/null +++ b/packages/ic-webmcp/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vitest/globals", "node"] + }, + "include": ["src"] +} diff --git a/packages/ic-webmcp/vitest.config.ts b/packages/ic-webmcp/vitest.config.ts new file mode 100644 index 000000000000..3e96cee97627 --- /dev/null +++ b/packages/ic-webmcp/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + typecheck: { + tsconfig: "./tsconfig.test.json", + }, + }, +}); From 7f0524407af391ce05d643fb8ccc650f89e689df Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 12:58:19 -0400 Subject: [PATCH 05/12] =?UTF-8?q?feat(webmcp):=20Phase=203=20integration?= =?UTF-8?q?=20=E2=80=94=20dfx.json=20parsing,=20asset=20middleware,=20demo?= =?UTF-8?q?=20dapp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dfx.json config parsing (rs/webmcp/codegen/src/dfx_config.rs): - configs_from_dfx_json(): parse webmcp sections from dfx.json into Configs - load_canister_ids(): load .dfx//canister_ids.json for principal injection - 8 unit tests covering parsing, defaults, multi-canister, ID injection CLI dfx subcommand (main.rs): - ic-webmcp-codegen dfx --dfx-json dfx.json --out-dir .webmcp/ - Auto-discovers canister_ids.json at conventional dfx paths - Generates .webmcp.json + .webmcp.js per enabled canister Asset canister middleware (rs/webmcp/asset-middleware/): - handle_webmcp_request(): HTTP handler producing correct CORS headers - ic_assets_config(): generate .ic-assets.json5 for the asset canister - Handles GET, OPTIONS, query strings, case-insensitive methods - 8 unit tests Demo dapp (rs/webmcp/demo/): - backend.did: e-commerce canister (products, cart, checkout) - dfx.json: full webmcp config with descriptions, auth, certified queries --- Cargo.lock | 8 + Cargo.toml | 1 + rs/webmcp/asset-middleware/Cargo.toml | 10 + rs/webmcp/asset-middleware/src/lib.rs | 279 ++++++++++++++++++++ rs/webmcp/codegen/src/dfx_config.rs | 356 ++++++++++++++++++++++++++ rs/webmcp/codegen/src/lib.rs | 2 + rs/webmcp/codegen/src/main.rs | 156 +++++++++-- rs/webmcp/demo/backend.did | 52 ++++ rs/webmcp/demo/dfx.json | 49 ++++ 9 files changed, 893 insertions(+), 20 deletions(-) create mode 100644 rs/webmcp/asset-middleware/Cargo.toml create mode 100644 rs/webmcp/asset-middleware/src/lib.rs create mode 100644 rs/webmcp/codegen/src/dfx_config.rs create mode 100644 rs/webmcp/demo/backend.did create mode 100644 rs/webmcp/demo/dfx.json diff --git a/Cargo.lock b/Cargo.lock index d2dbf59a4a16..8a1e26a87357 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15714,6 +15714,14 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ic-webmcp-asset-middleware" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "ic-webmcp-codegen" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a0583627786c..ee747ab5e1c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -529,6 +529,7 @@ members = [ "rs/utils/thread", "rs/utils/validate_eq", "rs/utils/validate_eq_derive", + "rs/webmcp/asset-middleware", "rs/webmcp/codegen", "rs/validator", "rs/validator/http_request_arbitrary", diff --git a/rs/webmcp/asset-middleware/Cargo.toml b/rs/webmcp/asset-middleware/Cargo.toml new file mode 100644 index 000000000000..6f2b1807858a --- /dev/null +++ b/rs/webmcp/asset-middleware/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ic-webmcp-asset-middleware" +version = "0.1.0" +edition = "2021" +description = "Middleware helpers for serving WebMCP manifests from IC asset canisters" +license = "Apache-2.0" + +[dependencies] +serde = { workspace = true } +serde_json = "1" diff --git a/rs/webmcp/asset-middleware/src/lib.rs b/rs/webmcp/asset-middleware/src/lib.rs new file mode 100644 index 000000000000..76b7d8d57b8f --- /dev/null +++ b/rs/webmcp/asset-middleware/src/lib.rs @@ -0,0 +1,279 @@ +//! # ic-webmcp-asset-middleware +//! +//! Helpers for serving WebMCP manifests from IC asset canisters with the +//! required CORS and content-type headers for AI agent discovery. +//! +//! ## What browsers need +//! +//! When Chrome's WebMCP implementation fetches `/.well-known/webmcp.json`, +//! it expects: +//! - `Content-Type: application/json` +//! - `Access-Control-Allow-Origin: *` +//! - `Access-Control-Allow-Methods: GET, OPTIONS` +//! - `Access-Control-Allow-Headers: Content-Type` +//! +//! This crate produces the correct header set and can be used in two ways: +//! +//! 1. **Direct HTTP handler** — embed `handle_webmcp_request()` in a canister's +//! `http_request` handler. +//! 2. **Asset canister config** — generate an `.ic-assets.json5` config that +//! tells the asset canister to attach CORS headers to the manifest path. + +/// The canonical path where browsers discover WebMCP manifests. +pub const WEBMCP_MANIFEST_PATH: &str = "/.well-known/webmcp.json"; + +/// The registration script path. +pub const WEBMCP_JS_PATH: &str = "/webmcp.js"; + +/// HTTP headers required for WebMCP manifest discovery. +pub const WEBMCP_CORS_HEADERS: &[(&str, &str)] = &[ + ("Content-Type", "application/json"), + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, OPTIONS"), + ("Access-Control-Allow-Headers", "Content-Type"), + ("Cache-Control", "public, max-age=300"), +]; + +/// HTTP headers for the webmcp.js script. +pub const WEBMCP_JS_HEADERS: &[(&str, &str)] = &[ + ("Content-Type", "application/javascript; charset=utf-8"), + ("Access-Control-Allow-Origin", "*"), + ("Cache-Control", "public, max-age=300"), +]; + +// ── IC HTTP interface types ────────────────────────────────────────── + +/// A minimal HTTP response compatible with the IC asset canister HTTP interface. +#[derive(Debug, Clone)] +pub struct HttpResponse { + pub status_code: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl HttpResponse { + /// Build a 200 OK JSON response with full WebMCP CORS headers. + pub fn webmcp_json(body: &str) -> Self { + HttpResponse { + status_code: 200, + headers: WEBMCP_CORS_HEADERS + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + body: body.as_bytes().to_vec(), + } + } + + /// Build a 200 OK JavaScript response with CORS headers. + pub fn webmcp_js(body: &str) -> Self { + HttpResponse { + status_code: 200, + headers: WEBMCP_JS_HEADERS + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + body: body.as_bytes().to_vec(), + } + } + + /// Build a 200 OK CORS preflight response (for OPTIONS requests). + pub fn options_preflight() -> Self { + HttpResponse { + status_code: 204, + headers: WEBMCP_CORS_HEADERS + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + body: Vec::new(), + } + } + + /// Build a 404 Not Found response. + pub fn not_found() -> Self { + HttpResponse { + status_code: 404, + headers: vec![("Content-Type".to_string(), "text/plain".to_string())], + body: b"Not Found".to_vec(), + } + } +} + +// ── HTTP request handler ───────────────────────────────────────────── + +/// Handle an incoming HTTP request for WebMCP endpoints. +/// +/// Returns `None` if the path is not a WebMCP path (caller should fall +/// through to normal asset serving). +/// +/// # Example +/// +/// ```ignore +/// #[ic_cdk::query] +/// fn http_request(req: HttpRequest) -> HttpResponse { +/// if let Some(response) = ic_webmcp_asset_middleware::handle_webmcp_request( +/// &req.method, +/// &req.url, +/// MANIFEST_JSON, +/// Some(WEBMCP_JS), +/// ) { +/// return response; +/// } +/// // Fall through to normal asset serving +/// serve_asset(&req) +/// } +/// ``` +pub fn handle_webmcp_request( + method: &str, + path: &str, + manifest_json: &str, + webmcp_js: Option<&str>, +) -> Option { + // Strip query string for path matching + let path = path.split('?').next().unwrap_or(path); + + match (method.to_ascii_uppercase().as_str(), path) { + ("OPTIONS", p) if p == WEBMCP_MANIFEST_PATH || p == WEBMCP_JS_PATH => { + Some(HttpResponse::options_preflight()) + } + ("GET", p) if p == WEBMCP_MANIFEST_PATH => Some(HttpResponse::webmcp_json(manifest_json)), + ("GET", p) if p == WEBMCP_JS_PATH => match webmcp_js { + Some(js) => Some(HttpResponse::webmcp_js(js)), + None => Some(HttpResponse::not_found()), + }, + _ => None, + } +} + +// ── .ic-assets.json5 generator ─────────────────────────────────────── + +/// Generate `.ic-assets.json5` configuration for the IC asset canister. +/// +/// The asset canister reads this file to attach HTTP headers to specific +/// paths. This function produces the snippet needed to add CORS headers +/// to the WebMCP manifest and JS paths. +/// +/// The generated config should be placed in the `assets/` directory as +/// `.ic-assets.json5`. +pub fn ic_assets_config() -> String { + let cors_headers: Vec = WEBMCP_CORS_HEADERS + .iter() + .map(|(k, v)| format!(" {{ \"name\": \"{}\", \"value\": \"{}\" }}", k, v)) + .collect(); + + let js_headers: Vec = WEBMCP_JS_HEADERS + .iter() + .map(|(k, v)| format!(" {{ \"name\": \"{}\", \"value\": \"{}\" }}", k, v)) + .collect(); + + format!( + r#"// .ic-assets.json5 — WebMCP CORS configuration +// Auto-generated by ic-webmcp-asset-middleware +// Place this in your assets/ directory alongside your other assets. +[ + {{ + "match": "/.well-known/webmcp.json", + "headers": [ +{} + ], + "allow_raw_access": true + }}, + {{ + "match": "/webmcp.js", + "headers": [ +{} + ], + "allow_raw_access": true + }} +] +"#, + cors_headers.join(",\n"), + js_headers.join(",\n"), + ) +} + +// ── Tests ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_handle_manifest_get() { + let resp = handle_webmcp_request( + "GET", + "/.well-known/webmcp.json", + r#"{"schema_version":"1.0"}"#, + None, + ); + let resp = resp.expect("should handle manifest GET"); + assert_eq!(resp.status_code, 200); + assert_eq!(resp.body, br#"{"schema_version":"1.0"}"#); + assert!( + resp.headers + .iter() + .any(|(k, v)| k == "Access-Control-Allow-Origin" && v == "*") + ); + assert!( + resp.headers + .iter() + .any(|(k, v)| k == "Content-Type" && v == "application/json") + ); + } + + #[test] + fn test_handle_js_get() { + let js = "console.log('webmcp');"; + let resp = handle_webmcp_request("GET", "/webmcp.js", "{}", Some(js)); + let resp = resp.expect("should handle JS GET"); + assert_eq!(resp.status_code, 200); + assert_eq!(std::str::from_utf8(&resp.body).unwrap(), js); + assert!( + resp.headers + .iter() + .any(|(k, _)| k == "Access-Control-Allow-Origin") + ); + } + + #[test] + fn test_handle_js_not_found_when_none() { + let resp = handle_webmcp_request("GET", "/webmcp.js", "{}", None); + let resp = resp.expect("should return 404"); + assert_eq!(resp.status_code, 404); + } + + #[test] + fn test_handle_options_preflight() { + let resp = handle_webmcp_request("OPTIONS", "/.well-known/webmcp.json", "{}", None); + let resp = resp.expect("should handle OPTIONS"); + assert_eq!(resp.status_code, 204); + } + + #[test] + fn test_non_webmcp_path_returns_none() { + let resp = handle_webmcp_request("GET", "/index.html", "{}", None); + assert!(resp.is_none()); + } + + #[test] + fn test_path_with_query_string() { + let resp = handle_webmcp_request("GET", "/.well-known/webmcp.json?v=1", r#"{}"#, None); + assert!(resp.is_some()); + assert_eq!(resp.unwrap().status_code, 200); + } + + #[test] + fn test_case_insensitive_method() { + let resp = handle_webmcp_request("get", "/.well-known/webmcp.json", r#"{}"#, None); + assert!(resp.is_some()); + assert_eq!(resp.unwrap().status_code, 200); + } + + #[test] + fn test_ic_assets_config_contains_required_paths() { + let config = ic_assets_config(); + assert!(config.contains("/.well-known/webmcp.json")); + assert!(config.contains("/webmcp.js")); + assert!(config.contains("Access-Control-Allow-Origin")); + assert!(config.contains("allow_raw_access")); + } +} diff --git a/rs/webmcp/codegen/src/dfx_config.rs b/rs/webmcp/codegen/src/dfx_config.rs new file mode 100644 index 000000000000..07562082cf65 --- /dev/null +++ b/rs/webmcp/codegen/src/dfx_config.rs @@ -0,0 +1,356 @@ +//! Parse the `webmcp` configuration section from `dfx.json`. +//! +//! This module reads the standard `dfx.json` project file and extracts +//! a `Config` for each canister that has a `webmcp` block, allowing +//! manifest generation to be driven entirely from `dfx.json`. +//! +//! ## Expected `dfx.json` shape +//! +//! ```json +//! { +//! "canisters": { +//! "backend": { +//! "type": "rust", +//! "candid": "backend.did", +//! "webmcp": { +//! "enabled": true, +//! "name": "My DApp", +//! "description": "Description for AI agents", +//! "expose_methods": ["get_items", "checkout"], +//! "require_auth": ["checkout"], +//! "certified_queries": ["get_items"], +//! "descriptions": { +//! "get_items": "List available products", +//! "checkout": "Complete purchase" +//! }, +//! "param_descriptions": { +//! "checkout.payment_method": "Payment method: icp or cycles" +//! } +//! } +//! } +//! } +//! } +//! ``` + +use crate::config::Config; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +// ── dfx.json schema ───────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct DfxJson { + canisters: Option>, +} + +#[derive(Debug, Deserialize)] +struct DfxCanister { + /// Path to the .did file (relative to dfx.json) + candid: Option, + /// WebMCP configuration block + webmcp: Option, +} + +/// The `webmcp` section inside a canister definition in `dfx.json`. +#[derive(Debug, Deserialize, Default)] +pub struct DfxWebMCPConfig { + /// Whether WebMCP generation is enabled for this canister. Default: true. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Human-readable name for the canister (shown to agents) + pub name: Option, + + /// Description for AI agents + pub description: Option, + + /// Which methods to expose. If absent, all service methods are exposed. + pub expose_methods: Option>, + + /// Methods that require authentication + #[serde(default)] + pub require_auth: Vec, + + /// Query methods that support certified responses + #[serde(default)] + pub certified_queries: Vec, + + /// Human-readable descriptions per method + #[serde(default)] + pub descriptions: BTreeMap, + + /// Descriptions per parameter (format: "method.param_name") + #[serde(default)] + pub param_descriptions: BTreeMap, +} + +fn default_true() -> bool { + true +} + +// ── Public API ─────────────────────────────────────────────────────── + +/// Parse `dfx.json` and return one `Config` per WebMCP-enabled canister. +/// +/// The `canister_ids` map is optional — if provided it is used to embed +/// canister IDs into the generated manifests. The keys are canister names, +/// the values are principal text strings (as found in `.dfx/local/canister_ids.json` +/// or `canister_ids.json`). +pub fn configs_from_dfx_json( + dfx_json_path: &Path, + canister_ids: Option<&BTreeMap>, +) -> Result> { + let content = std::fs::read_to_string(dfx_json_path) + .with_context(|| format!("Failed to read {}", dfx_json_path.display()))?; + + let dfx: DfxJson = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", dfx_json_path.display()))?; + + let dfx_dir = dfx_json_path.parent().unwrap_or_else(|| Path::new(".")); + + let mut results = Vec::new(); + + for (canister_name, canister) in dfx.canisters.unwrap_or_default() { + let Some(webmcp) = canister.webmcp else { + continue; + }; + if !webmcp.enabled { + continue; + } + + let candid_rel = canister.candid.as_deref().unwrap_or("canister.did"); + let did_file = resolve_path(dfx_dir, candid_rel); + + let canister_id = canister_ids + .and_then(|ids| ids.get(&canister_name)) + .cloned(); + + let config = Config { + did_file, + canister_id, + name: webmcp.name.or_else(|| Some(canister_name.clone())), + description: webmcp.description, + expose_methods: webmcp.expose_methods, + require_auth: webmcp.require_auth, + certified_queries: webmcp.certified_queries, + method_descriptions: webmcp.descriptions, + param_descriptions: webmcp.param_descriptions, + }; + + results.push((canister_name, config)); + } + + Ok(results) +} + +/// Parse a `canister_ids.json` file (as produced by `dfx deploy`) into a name→principal map. +/// +/// Supports both the root-level `canister_ids.json` and `.dfx//canister_ids.json`. +/// The file format is: `{ "canister_name": { "ic": "principal", "local": "principal" } }`. +pub fn load_canister_ids(path: &Path, network: &str) -> Result> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + + let raw: BTreeMap> = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + + let ids = raw + .into_iter() + .filter_map(|(name, nets)| nets.get(network).map(|id| (name, id.clone()))) + .collect(); + + Ok(ids) +} + +fn resolve_path(base: &Path, relative: &str) -> PathBuf { + let p = Path::new(relative); + if p.is_absolute() { + p.to_path_buf() + } else { + base.join(p) + } +} + +// ── Tests ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_temp_json(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f + } + + #[test] + fn test_parse_basic_dfx_json() { + let f = write_temp_json( + r#"{ + "canisters": { + "backend": { + "type": "rust", + "candid": "backend.did", + "webmcp": { + "name": "My App", + "description": "Test app", + "expose_methods": ["greet", "transfer"], + "require_auth": ["transfer"], + "certified_queries": ["greet"], + "descriptions": { + "greet": "Say hello", + "transfer": "Send tokens" + } + } + } + } + }"#, + ); + + let configs = configs_from_dfx_json(f.path(), None).unwrap(); + assert_eq!(configs.len(), 1); + + let (name, config) = &configs[0]; + assert_eq!(name, "backend"); + assert_eq!(config.name.as_deref(), Some("My App")); + assert_eq!(config.description.as_deref(), Some("Test app")); + assert_eq!( + config.expose_methods.as_ref().map(|v| v.as_slice()), + Some(["greet".to_string(), "transfer".to_string()].as_slice()) + ); + assert_eq!(config.require_auth, ["transfer"]); + assert_eq!(config.certified_queries, ["greet"]); + assert_eq!(config.method_descriptions["greet"], "Say hello"); + } + + #[test] + fn test_skips_disabled_canister() { + let f = write_temp_json( + r#"{ + "canisters": { + "backend": { + "candid": "backend.did", + "webmcp": { "enabled": false } + } + } + }"#, + ); + + let configs = configs_from_dfx_json(f.path(), None).unwrap(); + assert!(configs.is_empty()); + } + + #[test] + fn test_skips_canister_without_webmcp_section() { + let f = write_temp_json( + r#"{ + "canisters": { + "frontend": { "type": "assets" }, + "backend": { + "candid": "backend.did", + "webmcp": { "name": "Backend" } + } + } + }"#, + ); + + let configs = configs_from_dfx_json(f.path(), None).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].0, "backend"); + } + + #[test] + fn test_multiple_canisters() { + let f = write_temp_json( + r#"{ + "canisters": { + "ledger": { + "candid": "ledger.did", + "webmcp": { "name": "Ledger" } + }, + "governance": { + "candid": "governance.did", + "webmcp": { "name": "Governance" } + } + } + }"#, + ); + + let configs = configs_from_dfx_json(f.path(), None).unwrap(); + assert_eq!(configs.len(), 2); + } + + #[test] + fn test_canister_ids_injected() { + let f = write_temp_json( + r#"{ + "canisters": { + "backend": { + "candid": "backend.did", + "webmcp": { "name": "Backend" } + } + } + }"#, + ); + + let mut ids = BTreeMap::new(); + ids.insert( + "backend".to_string(), + "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(), + ); + + let configs = configs_from_dfx_json(f.path(), Some(&ids)).unwrap(); + assert_eq!( + configs[0].1.canister_id.as_deref(), + Some("ryjl3-tyaaa-aaaaa-aaaba-cai") + ); + } + + #[test] + fn test_defaults_canister_name_when_no_name() { + let f = write_temp_json( + r#"{ + "canisters": { + "my_service": { + "candid": "service.did", + "webmcp": {} + } + } + }"#, + ); + + let configs = configs_from_dfx_json(f.path(), None).unwrap(); + assert_eq!(configs[0].1.name.as_deref(), Some("my_service")); + } + + #[test] + fn test_load_canister_ids_ic_network() { + let f = write_temp_json( + r#"{ + "backend": { "ic": "ryjl3-tyaaa-aaaaa-aaaba-cai", "local": "bd3sg-teaaa-aaaaa-qaaba-cai" }, + "frontend": { "ic": "qoctq-giaaa-aaaaa-aaaea-cai" } + }"#, + ); + + let ids = load_canister_ids(f.path(), "ic").unwrap(); + assert_eq!(ids["backend"], "ryjl3-tyaaa-aaaaa-aaaba-cai"); + assert_eq!(ids["frontend"], "qoctq-giaaa-aaaaa-aaaea-cai"); + assert!(!ids.contains_key("missing")); + } + + #[test] + fn test_load_canister_ids_local_network() { + let f = write_temp_json( + r#"{ + "backend": { "ic": "ryjl3-tyaaa-aaaaa-aaaba-cai", "local": "bd3sg-teaaa-aaaaa-qaaba-cai" } + }"#, + ); + + let ids = load_canister_ids(f.path(), "local").unwrap(); + assert_eq!(ids["backend"], "bd3sg-teaaa-aaaaa-qaaba-cai"); + } +} diff --git a/rs/webmcp/codegen/src/lib.rs b/rs/webmcp/codegen/src/lib.rs index 4b769ca804a6..2d96b786295e 100644 --- a/rs/webmcp/codegen/src/lib.rs +++ b/rs/webmcp/codegen/src/lib.rs @@ -10,11 +10,13 @@ //! - `webmcp.js` — browser script for tool registration pub mod config; +pub mod dfx_config; pub mod did_parser; pub mod js_emitter; pub mod manifest; pub mod schema_mapper; pub use config::Config; +pub use dfx_config::{configs_from_dfx_json, load_canister_ids}; pub use did_parser::ParsedInterface; pub use manifest::{WebMCPManifest, generate_manifest}; diff --git a/rs/webmcp/codegen/src/main.rs b/rs/webmcp/codegen/src/main.rs index 1695cbbc4b7c..3ab8364e3084 100644 --- a/rs/webmcp/codegen/src/main.rs +++ b/rs/webmcp/codegen/src/main.rs @@ -1,17 +1,30 @@ use anyhow::{Context, Result}; -use clap::Parser; -use ic_webmcp_codegen::{Config, generate_manifest}; +use clap::{Parser, Subcommand}; +use ic_webmcp_codegen::{Config, configs_from_dfx_json, generate_manifest, load_canister_ids}; use std::collections::BTreeMap; use std::path::PathBuf; /// Generate WebMCP tool manifests from Internet Computer Candid interfaces. -/// -/// Parses a .did file and outputs: -/// - webmcp.json: tool manifest for AI agent discovery -/// - webmcp.js: browser script for tool registration #[derive(Parser)] #[command(name = "ic-webmcp-codegen", version)] struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Generate from a single .did file. + Did(DidArgs), + /// Generate from a dfx.json project file (all WebMCP-enabled canisters). + Dfx(DfxArgs), +} + +// ── `did` subcommand ───────────────────────────────────────────────── + +/// Generate WebMCP manifests from a single Candid .did file. +#[derive(Parser)] +struct DidArgs { /// Path to the Candid .did file #[arg(long, short = 'd')] did: PathBuf, @@ -53,17 +66,51 @@ struct Cli { no_js: bool, } +// ── `dfx` subcommand ───────────────────────────────────────────────── + +/// Generate WebMCP manifests for all WebMCP-enabled canisters in a dfx.json. +#[derive(Parser)] +struct DfxArgs { + /// Path to dfx.json (default: ./dfx.json) + #[arg(long, default_value = "dfx.json")] + dfx_json: PathBuf, + + /// Path to canister_ids.json for embedding canister principals + #[arg(long)] + canister_ids: Option, + + /// Network name to look up in canister_ids.json (default: ic) + #[arg(long, default_value = "ic")] + network: String, + + /// Output directory for generated files (default: .webmcp/) + #[arg(long, default_value = ".webmcp")] + out_dir: PathBuf, + + /// Skip generating webmcp.js files + #[arg(long)] + no_js: bool, +} + +// ── Entry point ─────────────────────────────────────────────────────── + fn main() -> Result<()> { let cli = Cli::parse(); + match cli.command { + Command::Did(args) => run_did(args), + Command::Dfx(args) => run_dfx(args), + } +} +fn run_did(args: DidArgs) -> Result<()> { let config = Config { - did_file: cli.did, - canister_id: cli.canister_id, - name: cli.name, - description: cli.description, - expose_methods: cli.expose, - require_auth: cli.require_auth.unwrap_or_default(), - certified_queries: cli.certified.unwrap_or_default(), + did_file: args.did, + canister_id: args.canister_id, + name: args.name, + description: args.description, + expose_methods: args.expose, + require_auth: args.require_auth.unwrap_or_default(), + certified_queries: args.certified.unwrap_or_default(), method_descriptions: BTreeMap::new(), param_descriptions: BTreeMap::new(), }; @@ -76,15 +123,84 @@ fn main() -> Result<()> { })?; let json = serde_json::to_string_pretty(&manifest).context("Failed to serialize manifest")?; - std::fs::write(&cli.out_manifest, &json) - .with_context(|| format!("Failed to write {}", cli.out_manifest.display()))?; - eprintln!("Wrote {}", cli.out_manifest.display()); + std::fs::write(&args.out_manifest, &json) + .with_context(|| format!("Failed to write {}", args.out_manifest.display()))?; + eprintln!("Wrote {}", args.out_manifest.display()); - if !cli.no_js { + if !args.no_js { let js = ic_webmcp_codegen::js_emitter::emit_js(&manifest); - std::fs::write(&cli.out_js, &js) - .with_context(|| format!("Failed to write {}", cli.out_js.display()))?; - eprintln!("Wrote {}", cli.out_js.display()); + std::fs::write(&args.out_js, &js) + .with_context(|| format!("Failed to write {}", args.out_js.display()))?; + eprintln!("Wrote {}", args.out_js.display()); + } + + Ok(()) +} + +fn run_dfx(args: DfxArgs) -> Result<()> { + // Load optional canister IDs + let canister_ids: Option> = match &args.canister_ids { + Some(path) => Some( + load_canister_ids(path, &args.network) + .with_context(|| format!("Failed to load canister IDs from {}", path.display()))?, + ), + None => { + // Try conventional locations automatically + let candidates = [ + args.dfx_json + .parent() + .unwrap_or(std::path::Path::new(".")) + .join("canister_ids.json"), + args.dfx_json + .parent() + .unwrap_or(std::path::Path::new(".")) + .join(format!(".dfx/{}/canister_ids.json", args.network)), + ]; + candidates + .iter() + .find(|p| p.exists()) + .map(|p| load_canister_ids(p, &args.network)) + .transpose() + .unwrap_or_default() + } + }; + + let configs = configs_from_dfx_json(&args.dfx_json, canister_ids.as_ref()) + .with_context(|| format!("Failed to parse {}", args.dfx_json.display()))?; + + if configs.is_empty() { + eprintln!( + "No WebMCP-enabled canisters found in {}. Add a `webmcp` section to a canister.", + args.dfx_json.display() + ); + return Ok(()); + } + + std::fs::create_dir_all(&args.out_dir) + .with_context(|| format!("Failed to create output dir {}", args.out_dir.display()))?; + + for (canister_name, config) in configs { + eprintln!("Generating manifest for canister: {}", canister_name); + + let manifest = generate_manifest(&config).with_context(|| { + format!("Failed to generate manifest for canister {}", canister_name) + })?; + + let json = + serde_json::to_string_pretty(&manifest).context("Failed to serialize manifest")?; + + let manifest_path = args.out_dir.join(format!("{}.webmcp.json", canister_name)); + std::fs::write(&manifest_path, &json) + .with_context(|| format!("Failed to write {}", manifest_path.display()))?; + eprintln!(" Wrote {}", manifest_path.display()); + + if !args.no_js { + let js = ic_webmcp_codegen::js_emitter::emit_js(&manifest); + let js_path = args.out_dir.join(format!("{}.webmcp.js", canister_name)); + std::fs::write(&js_path, &js) + .with_context(|| format!("Failed to write {}", js_path.display()))?; + eprintln!(" Wrote {}", js_path.display()); + } } Ok(()) 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..61ba1ae7694f --- /dev/null +++ b/rs/webmcp/demo/dfx.json @@ -0,0 +1,49 @@ +{ + "canisters": { + "backend": { + "type": "custom", + "candid": "backend.did", + "wasm": "target/wasm32-unknown-unknown/release/demo_backend.wasm", + "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", + "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 +} From e5635e10ac27734840447452bf0073e06155c8fb Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 13:04:59 -0400 Subject: [PATCH 06/12] docs(webmcp): add idiomatic documentation throughout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md files (IC convention for tools and npm packages): - rs/webmcp/README.md: architecture overview, pipeline diagram, all components - rs/webmcp/codegen/README.md: CLI usage, both subcommands, dfx.json config reference, Candid→JSON Schema mapping table, library API examples - packages/ic-webmcp/README.md: quick start, config reference, auth/delegation, full API reference, manifest format, browser requirements Rust inline docs (IC convention for library crates): - codegen/src/lib.rs: expanded //! crate docs with two runnable examples (from .did and from dfx.json), module listing - codegen/src/config.rs: /// on every field of Config, doc example on from_did_file(), explaining fallbacks and agent interaction - asset-middleware/src/lib.rs: expanded //! docs with runnable doctest showing handle_webmcp_request() usage All doctests compile and pass (3 in codegen, 2 in asset-middleware). --- packages/ic-webmcp/README.md | 221 ++++++++++++++++++++++++++ rs/webmcp/README.md | 154 ++++++++++++++++++ rs/webmcp/asset-middleware/src/lib.rs | 39 ++++- rs/webmcp/codegen/README.md | 180 +++++++++++++++++++++ rs/webmcp/codegen/src/config.rs | 73 +++++++-- rs/webmcp/codegen/src/lib.rs | 64 +++++++- 6 files changed, 705 insertions(+), 26 deletions(-) create mode 100644 packages/ic-webmcp/README.md create mode 100644 rs/webmcp/README.md create mode 100644 rs/webmcp/codegen/README.md diff --git a/packages/ic-webmcp/README.md b/packages/ic-webmcp/README.md new file mode 100644 index 000000000000..7f7380a894e8 --- /dev/null +++ b/packages/ic-webmcp/README.md @@ -0,0 +1,221 @@ +# @dfinity/webmcp + +Browser-side library that bridges the [WebMCP](https://webmcp.link/) standard to Internet Computer canisters via [`@dfinity/agent`](https://github.com/dfinity/agent-js). + +WebMCP (Web Model Context Protocol) is a W3C browser API (Chrome 146+) that lets websites expose structured, callable tools to AI agents via `navigator.modelContext`. This package makes any IC canister discoverable and callable by AI agents with a few lines of code. + +## Installation + +```bash +npm install @dfinity/webmcp +``` + +## Quick Start + +```html + + +``` + +The `webmcp.js` script is auto-generated by [`ic-webmcp-codegen`](../../rs/webmcp/codegen) from your canister's `.did` file and handles everything automatically. + +For manual control: + +```typescript +import { ICWebMCP } from "@dfinity/webmcp"; + +const webmcp = new ICWebMCP({ + // Reads /.well-known/webmcp.json by default + manifestUrl: "/.well-known/webmcp.json", + host: "https://icp-api.io", +}); + +// Fetch manifest and register all tools with navigator.modelContext +await webmcp.registerAll(); +``` + +## How It Works + +``` +AI Agent (Chrome 146+) + └── navigator.modelContext.callTool("icrc1_balance_of", { owner: "..." }) + │ + ▼ +@dfinity/webmcp (this package) + ├── Fetches /.well-known/webmcp.json (tool manifest) + ├── Registers tools with navigator.modelContext + ├── Maps tool calls → @dfinity/agent query/call + └── Returns JSON results to the agent + │ + ▼ +IC Canister (via HTTPS boundary node) +``` + +## Configuration + +```typescript +const webmcp = new ICWebMCP({ + // URL to fetch the manifest from. Default: '/.well-known/webmcp.json' + manifestUrl?: string; + + // Override the canister ID from the manifest + canisterId?: string; + + // IC replica host. Default: 'https://icp-api.io' + host?: string; + + // Pre-existing identity (e.g., from Internet Identity login) + identity?: Identity; + + // Called when a tool that requires_auth is invoked anonymously. + // Should trigger II login and return the authenticated identity. + onAuthRequired?: () => Promise; +}); +``` + +## Authentication with Internet Identity + +Tools marked `requires_auth: true` in the manifest need an authenticated identity. Use `onAuthRequired` to trigger an Internet Identity login flow: + +```typescript +import { ICWebMCP } from "@dfinity/webmcp"; +import { AuthClient } from "@dfinity/auth-client"; + +const authClient = await AuthClient.create(); + +const webmcp = new ICWebMCP({ + onAuthRequired: async () => { + await authClient.login({ + identityProvider: "https://identity.ic0.app", + }); + return authClient.getIdentity(); + }, +}); + +await webmcp.registerAll(); +``` + +### Scoped Delegations + +For tighter security, create a short-lived, canister-scoped delegation instead of using the full identity: + +```typescript +import { ICWebMCP, createScopedDelegation } from "@dfinity/webmcp"; + +// After II login, create a delegation restricted to this canister +const webmcp = new ICWebMCP({ identity: iiIdentity }); +await webmcp.registerAll(); + +// Create a 1-hour delegation scoped to the backend canister only +const agentIdentity = await webmcp.createAgentDelegation({ + maxTtlSeconds: 3600, +}); +webmcp.setIdentity(agentIdentity); +``` + +## API Reference + +### `ICWebMCP` + +Main class. Import and instantiate with an optional config object. + +#### `registerAll(): Promise` + +Fetches the manifest and registers all tools with `navigator.modelContext`. + +#### `registerTool(name: string): Promise` + +Register a single tool by name (manifest must already be loaded via `registerAll()`). + +#### `unregisterAll(): Promise` + +Unregister all previously registered tools. Useful for cleanup on logout. + +#### `setIdentity(identity: Identity): void` + +Update the identity used for canister calls (e.g., after II login). + +#### `setIdlFactory(factory: IDL.InterfaceFactory): void` + +Provide a generated IDL factory for typed Actor-based calls. If not set, calls use raw Candid encoding. + +#### `createAgentDelegation(opts?): Promise` + +Create a scoped delegation identity for agent authentication. Requires an authenticated identity to be set first. + +#### `getAgent(): HttpAgent` + +Returns the underlying `@dfinity/agent` HttpAgent (available after `registerAll()`). + +#### `getManifest(): WebMCPManifest` + +Returns the loaded manifest (available after `registerAll()`). + +### Low-level exports + +```typescript +import { + fetchManifest, // Fetch and validate a webmcp.json manifest + jsonToCandid, // Encode JSON params into Candid binary + candidToJson, // Decode Candid binary into JSON + executeToolCall, // Execute a single tool call via agent + registerTool, // Register one tool with navigator.modelContext + unregisterTool, // Unregister one tool + registerAllTools, // Register an array of tools + unregisterAllTools, // Unregister an array of tools + createScopedDelegation, // Create a time-limited, canister-scoped delegation + getDelegationTargets, // Resolve delegation targets from manifest + canister ID +} from "@dfinity/webmcp"; +``` + +## WebMCP Manifest Format + +The manifest (`/.well-known/webmcp.json`) is auto-generated by `ic-webmcp-codegen`. Its structure: + +```json +{ + "schema_version": "1.0", + "canister": { + "id": "ryjl3-tyaaa-aaaaa-aaaba-cai", + "name": "ICP Ledger", + "description": "ICP token ledger implementing ICRC-1/2/3" + }, + "tools": [ + { + "name": "icrc1_balance_of", + "description": "Get the token balance of an account", + "canister_method": "icrc1_balance_of", + "method_type": "query", + "certified": true, + "inputSchema": { + "type": "object", + "properties": { + "owner": { "type": "string", "description": "Principal ID" } + } + } + }, + { + "name": "icrc1_transfer", + "canister_method": "icrc1_transfer", + "method_type": "update", + "requires_auth": true, + "inputSchema": { ... } + } + ], + "authentication": { + "type": "internet-identity", + "delegation_targets": ["ryjl3-tyaaa-aaaaa-aaaba-cai"] + } +} +``` + +## Browser Requirements + +`navigator.modelContext` is available in Chrome 146+ with the WebMCP origin trial or flag enabled. For other browsers or agent frameworks, a polyfill can intercept calls before they reach the browser API. + +## Related + +- [`ic-webmcp-codegen`](../../rs/webmcp/codegen) — Rust CLI that generates `webmcp.json` and `webmcp.js` from `.did` files +- [`ic-webmcp-asset-middleware`](../../rs/webmcp/asset-middleware) — Rust helpers for serving the manifest with correct CORS headers from a canister +- [WebMCP Specification](https://webmcp.link/) +- [`@dfinity/agent`](https://github.com/dfinity/agent-js) diff --git a/rs/webmcp/README.md b/rs/webmcp/README.md new file mode 100644 index 000000000000..c81273fd9fdc --- /dev/null +++ b/rs/webmcp/README.md @@ -0,0 +1,154 @@ +# WebMCP for the Internet Computer + +This directory contains the implementation of [WebMCP](https://webmcp.link/) support for the Internet Computer. + +WebMCP (Web Model Context Protocol) is a W3C browser API (Chrome 146+) that lets websites expose structured, callable tools to AI agents via `navigator.modelContext`. The IC is uniquely suited for WebMCP: + +- **Candid interfaces** already define structured tool schemas (`.did` files) +- **Certified queries** provide cryptographically verified responses +- **Internet Identity** enables scoped agent authentication via delegation chains +- **HTTPS gateway** already translates HTTP → canister calls + +## Architecture + +``` +AI Agent (Chrome 146+) + └── navigator.modelContext.callTool("icrc1_balance_of", { owner: "..." }) + │ + ▼ (tool registration) +@dfinity/webmcp (packages/ic-webmcp/) + ├── Fetches /.well-known/webmcp.json + ├── Maps execute() → @dfinity/agent query/call + └── Handles Internet Identity delegation + │ + ▼ (HTTPS boundary node) +IC Canister + └── Serves /.well-known/webmcp.json ← generated by ic-webmcp-codegen +``` + +## Components + +### [`codegen/`](codegen/) + +Rust CLI tool and library that parses `.did` files and generates: +- `webmcp.json` — tool manifest for agent discovery +- `webmcp.js` — browser registration script + +```bash +# From a single .did file +ic-webmcp-codegen did --did ledger.did --name "ICP Ledger" --out-manifest webmcp.json + +# From a dfx.json project (all WebMCP-enabled canisters) +ic-webmcp-codegen dfx --dfx-json dfx.json --out-dir .webmcp/ +``` + +See [`codegen/README.md`](codegen/README.md) for full documentation. + +### [`asset-middleware/`](asset-middleware/) + +Rust library providing helpers for serving the manifest from any IC canister with the correct CORS headers required by browsers. + +```rust +// In your canister's http_request handler: +if let Some(resp) = handle_webmcp_request(&req.method, &req.url, MANIFEST_JSON, Some(WEBMCP_JS)) { + return resp; +} +``` + +Or generate `.ic-assets.json5` for automatic header injection in asset canisters: +```rust +std::fs::write("assets/.ic-assets.json5", ic_assets_config())?; +``` + +### [`demo/`](demo/) + +A minimal e-commerce demo showing the full pipeline: +- `backend.did` — Candid interface with products, cart, and checkout +- `dfx.json` — `webmcp` configuration section with descriptions, auth, and certified queries + +Run the codegen against the demo: +```bash +ic-webmcp-codegen dfx --dfx-json rs/webmcp/demo/dfx.json --out-dir /tmp/webmcp-demo/ +``` + +### [`../../packages/ic-webmcp/`](../../packages/ic-webmcp/) + +TypeScript npm package (`@dfinity/webmcp`) — the browser-side library that bridges `navigator.modelContext` to `@dfinity/agent`. + +```typescript +import { ICWebMCP } from "@dfinity/webmcp"; + +const webmcp = new ICWebMCP(); +await webmcp.registerAll(); // fetches manifest, registers all tools +``` + +See [`packages/ic-webmcp/README.md`](../../packages/ic-webmcp/README.md) for full documentation. + +## Pipeline Overview + +Given a canister with this in `dfx.json`: + +```json +{ + "canisters": { + "backend": { + "candid": "backend.did", + "webmcp": { + "name": "My App", + "expose_methods": ["get_items", "checkout"], + "require_auth": ["checkout"], + "descriptions": { + "get_items": "List available items", + "checkout": "Complete purchase" + } + } + } + } +} +``` + +Running `ic-webmcp-codegen dfx` produces: + +1. **`backend.webmcp.json`** — served at `/.well-known/webmcp.json`: + +```json +{ + "schema_version": "1.0", + "canister": { "name": "My App" }, + "tools": [ + { + "name": "get_items", + "description": "List available items", + "method_type": "query", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "checkout", + "description": "Complete purchase", + "method_type": "update", + "requires_auth": true, + "inputSchema": { ... } + } + ] +} +``` + +2. **`backend.webmcp.js`** — served at `/webmcp.js`: + +```javascript +import { ICWebMCP } from "https://esm.sh/@dfinity/webmcp"; +export async function initWebMCP(options = {}) { ... } +if (document.currentScript) initWebMCP().catch(console.error); +``` + +AI agents using Chrome 146+ can then discover and call these tools automatically. + +## Testing + +```bash +# Rust unit + integration tests +cargo test -p ic-webmcp-codegen -p ic-webmcp-asset-middleware + +# TypeScript unit tests +cd packages/ic-webmcp && npm test +``` diff --git a/rs/webmcp/asset-middleware/src/lib.rs b/rs/webmcp/asset-middleware/src/lib.rs index 76b7d8d57b8f..27490d6be6a5 100644 --- a/rs/webmcp/asset-middleware/src/lib.rs +++ b/rs/webmcp/asset-middleware/src/lib.rs @@ -1,7 +1,7 @@ //! # ic-webmcp-asset-middleware //! -//! Helpers for serving WebMCP manifests from IC asset canisters with the -//! required CORS and content-type headers for AI agent discovery. +//! Helpers for serving [WebMCP](https://webmcp.link/) manifests from IC canisters +//! with the required CORS and content-type headers for AI agent discovery. //! //! ## What browsers need //! @@ -14,10 +14,37 @@ //! //! This crate produces the correct header set and can be used in two ways: //! -//! 1. **Direct HTTP handler** — embed `handle_webmcp_request()` in a canister's -//! `http_request` handler. -//! 2. **Asset canister config** — generate an `.ic-assets.json5` config that -//! tells the asset canister to attach CORS headers to the manifest path. +//! 1. **Direct HTTP handler** — embed [`handle_webmcp_request`] in a canister's +//! `http_request` endpoint. +//! 2. **Asset canister config** — call [`ic_assets_config`] to generate a +//! `.ic-assets.json5` file that instructs the asset canister to attach CORS +//! headers to the manifest path automatically. +//! +//! ## Usage in a custom canister +//! +//! ``` +//! use ic_webmcp_asset_middleware::handle_webmcp_request; +//! +//! // Typically you would use include_str! to embed files generated by ic-webmcp-codegen: +//! // const MANIFEST_JSON: &str = include_str!("../webmcp.json"); +//! // const WEBMCP_JS: &str = include_str!("../webmcp.js"); +//! let manifest = r#"{"schema_version":"1.0","canister":{"name":"Demo","description":""},"tools":[]}"#; +//! let js = "await import('https://esm.sh/@dfinity/webmcp').then(m => m.initWebMCP());"; +//! +//! // In your http_request handler: +//! let resp = handle_webmcp_request("GET", "/.well-known/webmcp.json", manifest, Some(js)); +//! assert!(resp.is_some()); +//! ``` +//! +//! ## Usage with the asset canister +//! +//! ```no_run +//! use ic_webmcp_asset_middleware::ic_assets_config; +//! use std::fs; +//! +//! // Generate .ic-assets.json5 and place it in your assets/ directory: +//! fs::write("assets/.ic-assets.json5", ic_assets_config()).unwrap(); +//! ``` /// The canonical path where browsers discover WebMCP manifests. pub const WEBMCP_MANIFEST_PATH: &str = "/.well-known/webmcp.json"; diff --git a/rs/webmcp/codegen/README.md b/rs/webmcp/codegen/README.md new file mode 100644 index 000000000000..9f7292609692 --- /dev/null +++ b/rs/webmcp/codegen/README.md @@ -0,0 +1,180 @@ +# ic-webmcp-codegen + +Command-line tool and Rust library that generates [WebMCP](https://webmcp.link/) tool manifests from Internet Computer Candid interface definitions. + +Given a `.did` file (or a `dfx.json` project), it produces: + +- `webmcp.json` — tool manifest for AI agent discovery (served at `/.well-known/webmcp.json`) +- `webmcp.js` — browser registration script (served at `/webmcp.js`) + +## Building + +```bash +# From the IC repository root: +cargo build -p ic-webmcp-codegen --release +``` + +The binary is placed at `target/release/ic-webmcp-codegen`. + +## Usage + +The tool has two subcommands: `did` for a single Candid file, and `dfx` for a full dfx project. + +### `did` — from a single .did file + +```bash +ic-webmcp-codegen did \ + --did path/to/canister.did \ + --canister-id ryjl3-tyaaa-aaaaa-aaaba-cai \ + --name "ICP Ledger" \ + --description "ICP token ledger implementing ICRC-1/2/3" \ + --expose icrc1_balance_of,icrc1_transfer \ + --require-auth icrc1_transfer \ + --certified icrc1_balance_of \ + --out-manifest webmcp.json \ + --out-js webmcp.js +``` + +| Flag | Description | +|---|---| +| `--did` | Path to the Candid `.did` file (required) | +| `--out-manifest` | Output path for `webmcp.json` (default: `webmcp.json`) | +| `--out-js` | Output path for `webmcp.js` (default: `webmcp.js`) | +| `--canister-id` | Canister principal ID to embed in the manifest | +| `--name` | Human-readable name shown to AI agents | +| `--description` | Description for AI agents | +| `--expose` | Comma-separated list of methods to include. Omit to include all. | +| `--require-auth` | Comma-separated methods that require Internet Identity authentication | +| `--certified` | Comma-separated query methods that support certified responses | +| `--no-js` | Skip generating `webmcp.js` | + +### `dfx` — from a dfx.json project + +```bash +ic-webmcp-codegen dfx \ + --dfx-json dfx.json \ + --out-dir .webmcp/ +``` + +Reads the `webmcp` section from each canister in `dfx.json` and generates `.webmcp.json` and `.webmcp.js` for every enabled canister. + +Also auto-discovers `canister_ids.json` or `.dfx//canister_ids.json` to embed canister principals. + +| Flag | Description | +|---|---| +| `--dfx-json` | Path to `dfx.json` (default: `./dfx.json`) | +| `--canister-ids` | Path to `canister_ids.json` (auto-discovered if absent) | +| `--network` | Network to look up in `canister_ids.json` (default: `ic`) | +| `--out-dir` | Output directory (default: `.webmcp/`) | +| `--no-js` | Skip generating `.webmcp.js` files | + +## dfx.json Configuration + +Add a `webmcp` section to any canister in `dfx.json`: + +```json +{ + "canisters": { + "backend": { + "type": "rust", + "candid": "backend.did", + "webmcp": { + "enabled": true, + "name": "My DApp", + "description": "Description for AI agents", + "expose_methods": ["get_items", "add_to_cart", "checkout"], + "require_auth": ["add_to_cart", "checkout"], + "certified_queries": ["get_items"], + "descriptions": { + "get_items": "List available products with prices", + "add_to_cart": "Add a product to the shopping cart", + "checkout": "Complete purchase with current cart contents" + }, + "param_descriptions": { + "add_to_cart.product_id": "The unique product identifier", + "add_to_cart.quantity": "Number of items to add (default 1)" + } + } + } + } +} +``` + +| Field | Type | Description | +|---|---|---| +| `enabled` | bool | Whether to generate for this canister. Default: `true`. | +| `name` | string | Human-readable name. Default: canister name from dfx.json. | +| `description` | string | Description shown to AI agents. | +| `expose_methods` | string[] | Which service methods to expose. Omit to expose all. | +| `require_auth` | string[] | Methods requiring Internet Identity login. | +| `certified_queries` | string[] | Query methods with certified responses. | +| `descriptions` | object | Per-method descriptions (key: method name). | +| `param_descriptions` | object | Per-parameter descriptions (key: `"method.param"`). | + +## Library API + +The crate can also be used as a Rust library: + +```rust +use ic_webmcp_codegen::{Config, generate_manifest}; +use std::collections::BTreeMap; + +let config = Config { + did_file: "ledger.did".into(), + canister_id: Some("ryjl3-tyaaa-aaaaa-aaaba-cai".into()), + name: Some("ICP Ledger".into()), + description: Some("ICP token ledger".into()), + expose_methods: None, // None = all methods + require_auth: vec!["transfer".into()], + certified_queries: vec!["account_balance".into()], + method_descriptions: BTreeMap::new(), + param_descriptions: BTreeMap::new(), +}; + +let manifest = generate_manifest(&config)?; +let json = serde_json::to_string_pretty(&manifest)?; +std::fs::write("webmcp.json", json)?; +``` + +### From dfx.json + +```rust +use ic_webmcp_codegen::{configs_from_dfx_json, generate_manifest}; + +let configs = configs_from_dfx_json("dfx.json".as_ref(), None)?; +for (canister_name, config) in configs { + let manifest = generate_manifest(&config)?; + // ... +} +``` + +## Candid → JSON Schema Mapping + +| Candid Type | JSON Schema | +|---|---| +| `nat` | `{ "type": "string", "pattern": "^[0-9]+$" }` | +| `int` | `{ "type": "string", "pattern": "^-?[0-9]+$" }` | +| `nat8/16/32` | `{ "type": "integer", "minimum": 0, "maximum": N }` | +| `nat64` | `{ "type": "string", "pattern": "^[0-9]+$" }` | +| `text` | `{ "type": "string" }` | +| `bool` | `{ "type": "boolean" }` | +| `blob` | `{ "type": "string", "contentEncoding": "base64" }` | +| `principal` | `{ "type": "string", "pattern": "^[a-z0-9-]+..." }` | +| `opt T` | `{ "oneOf": [schema(T), { "type": "null" }] }` | +| `vec T` | `{ "type": "array", "items": schema(T) }` | +| `record { a: T }` | `{ "type": "object", "properties": { "a": schema(T) } }` | +| `variant { A; B: T }` | `{ "oneOf": [{ "const": "A" }, { "type": "object", "properties": { "B": schema(T) } }] }` | + +Recursive types (e.g., `type Value = variant { Array: vec Value }`) emit `{ "description": "Recursive type: Value" }` at the recursion point to avoid infinite expansion. + +## Testing + +```bash +cargo test -p ic-webmcp-codegen +``` + +## Related + +- [`ic-webmcp-asset-middleware`](../asset-middleware) — Rust helpers for serving the manifest from a canister with correct CORS headers +- [`@dfinity/webmcp`](../../../packages/ic-webmcp) — TypeScript browser library +- [WebMCP Specification](https://webmcp.link/) diff --git a/rs/webmcp/codegen/src/config.rs b/rs/webmcp/codegen/src/config.rs index 043803e40227..bb6b0bce9790 100644 --- a/rs/webmcp/codegen/src/config.rs +++ b/rs/webmcp/codegen/src/config.rs @@ -1,34 +1,83 @@ -//! Configuration for WebMCP generation, read from dfx.json or direct API. +//! Configuration for WebMCP manifest generation. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::PathBuf; -/// WebMCP generation configuration. +/// Configuration controlling what `ic-webmcp-codegen` generates. +/// +/// A `Config` is the single input to [`generate_manifest`](crate::generate_manifest). +/// It can be built programmatically, loaded from a `dfx.json` via +/// [`configs_from_dfx_json`](crate::configs_from_dfx_json), or constructed +/// from CLI flags. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { - /// Path to the .did file + /// Path to the Candid `.did` file to parse. pub did_file: PathBuf, - /// Canister ID (optional, embedded in manifest) + + /// Canister principal ID embedded in the manifest. + /// + /// If `None`, the `id` field is omitted from the manifest's `canister` section. + /// The TypeScript browser library will fall back to a `canisterId` passed in + /// `ICWebMCPConfig` at runtime. pub canister_id: Option, - /// Human-readable canister name + + /// Human-readable name for the canister, shown to AI agents. + /// + /// Defaults to `"IC Canister"` if absent. pub name: Option, - /// Description for AI agents + + /// Description of what the canister does, shown to AI agents. + /// + /// Defaults to `"Internet Computer canister"` if absent. pub description: Option, - /// Which methods to expose (None = all) + + /// Which service methods to expose as WebMCP tools. + /// + /// If `None`, all methods in the service definition are exposed. + /// If `Some(vec![...])`, only the named methods are included. pub expose_methods: Option>, - /// Which methods require authentication + + /// Methods that require Internet Identity authentication before calling. + /// + /// These methods will have `"requires_auth": true` in the manifest, and + /// the browser library will prompt the user to authenticate before executing them. pub require_auth: Vec, - /// Which query methods support certified responses + + /// Query methods that return certified responses. + /// + /// These methods will have `"certified": true` in the manifest, indicating + /// that the browser library should verify the BLS threshold signature on the response. pub certified_queries: Vec, - /// Human-readable descriptions for methods + + /// Human-readable descriptions for individual methods. + /// + /// Key: method name. Value: description shown to the AI agent. + /// Methods without an entry use `"Call "` as a fallback. pub method_descriptions: BTreeMap, - /// Human-readable descriptions for parameters (format: "method.param") + + /// Human-readable descriptions for individual parameters. + /// + /// Key format: `"method_name.param_name"` (e.g., `"transfer.amount"`). + /// Value: description shown to the AI agent when it prepares the argument. pub param_descriptions: BTreeMap, } impl Config { - /// Create a minimal config from just a .did file path. + /// Create a minimal config from just a `.did` file path. + /// + /// All optional fields are left empty. All service methods will be exposed + /// with auto-generated descriptions, no auth requirements, and no certified queries. + /// + /// # Example + /// + /// ``` + /// use ic_webmcp_codegen::Config; + /// + /// let config = Config::from_did_file("ledger.did"); + /// assert!(config.expose_methods.is_none()); // all methods exposed + /// assert!(config.require_auth.is_empty()); + /// ``` pub fn from_did_file(path: impl Into) -> Self { Config { did_file: path.into(), diff --git a/rs/webmcp/codegen/src/lib.rs b/rs/webmcp/codegen/src/lib.rs index 2d96b786295e..9c4220dee88b 100644 --- a/rs/webmcp/codegen/src/lib.rs +++ b/rs/webmcp/codegen/src/lib.rs @@ -1,13 +1,61 @@ //! # ic-webmcp-codegen //! -//! Generate WebMCP (Web Model Context Protocol) tool manifests from -//! Internet Computer Candid interface definitions. -//! -//! WebMCP enables AI agents to discover and call structured tools on websites. -//! This crate bridges IC's Candid interfaces to WebMCP's JSON Schema format, -//! auto-generating: -//! - `webmcp.json` — tool manifest for agent discovery -//! - `webmcp.js` — browser script for tool registration +//! Generate [WebMCP](https://webmcp.link/) tool manifests from Internet Computer +//! Candid interface definitions (`.did` files). +//! +//! WebMCP is a W3C browser API (Chrome 146+) that lets websites expose structured, +//! callable tools to AI agents via `navigator.modelContext`. This crate bridges IC's +//! Candid interfaces to WebMCP's JSON Schema format, producing: +//! +//! - `webmcp.json` — tool manifest for agent discovery (served at `/.well-known/webmcp.json`) +//! - `webmcp.js` — browser script for automatic tool registration +//! +//! ## Quick Start — from a `.did` file +//! +//! ```no_run +//! use ic_webmcp_codegen::{Config, generate_manifest}; +//! use std::collections::BTreeMap; +//! +//! let config = Config { +//! did_file: "ledger.did".into(), +//! canister_id: Some("ryjl3-tyaaa-aaaaa-aaaba-cai".into()), +//! name: Some("ICP Ledger".into()), +//! description: Some("ICP token ledger".into()), +//! expose_methods: None, // None = expose all service methods +//! require_auth: vec!["transfer".into()], +//! certified_queries: vec!["account_balance".into()], +//! method_descriptions: BTreeMap::new(), +//! param_descriptions: BTreeMap::new(), +//! }; +//! +//! let manifest = generate_manifest(&config)?; +//! let json = serde_json::to_string_pretty(&manifest)?; +//! std::fs::write("webmcp.json", json)?; +//! # Ok::<(), anyhow::Error>(()) +//! ``` +//! +//! ## Quick Start — from a `dfx.json` project +//! +//! ```no_run +//! use ic_webmcp_codegen::{configs_from_dfx_json, generate_manifest}; +//! +//! let configs = configs_from_dfx_json("dfx.json".as_ref(), None)?; +//! for (canister_name, config) in configs { +//! let manifest = generate_manifest(&config)?; +//! let json = serde_json::to_string_pretty(&manifest)?; +//! std::fs::write(format!("{canister_name}.webmcp.json"), json)?; +//! } +//! # Ok::<(), anyhow::Error>(()) +//! ``` +//! +//! ## Modules +//! +//! - [`config`] — [`Config`] struct for controlling what is generated +//! - [`dfx_config`] — parse `dfx.json` into one `Config` per WebMCP-enabled canister +//! - [`did_parser`] — parse `.did` files into method definitions +//! - [`schema_mapper`] — map Candid types to JSON Schema +//! - [`manifest`] — generate the [`WebMCPManifest`] from a `Config` +//! - [`js_emitter`] — generate the `webmcp.js` browser registration script pub mod config; pub mod dfx_config; From ac343ccef93632caf17206b529519b3c4d503cdf Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 13:11:36 -0400 Subject: [PATCH 07/12] build(webmcp): add Bazel BUILD files for codegen and asset-middleware crates rs/webmcp/codegen/BUILD.bazel: - rust_library :ic_webmcp_codegen (src/ excluding main.rs) - rust_binary :ic-webmcp-codegen (src/main.rs) - rust_test :unit_tests (inline #[cfg(test)] via crate =) - rust_test :integration_tests (tests/integration_test.rs) with data entries for all 7 .did fixtures from across the IC repo rs/webmcp/asset-middleware/BUILD.bazel: - rust_library :ic_webmcp_asset_middleware - rust_test :unit_tests (inline #[cfg(test)] via crate =) Also updates integration_test.rs to detect Bazel's TEST_SRCDIR env var and resolve runfiles paths correctly, while keeping the existing CARGO_MANIFEST_DIR fallback for cargo test. bazel query //rs/webmcp/... confirms all 6 targets visible and all 7 .did data dependencies resolve. --- rs/webmcp/asset-middleware/BUILD.bazel | 19 +++++++ rs/webmcp/codegen/BUILD.bazel | 62 +++++++++++++++++++++ rs/webmcp/codegen/tests/integration_test.rs | 12 ++-- 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 rs/webmcp/asset-middleware/BUILD.bazel create mode 100644 rs/webmcp/codegen/BUILD.bazel diff --git a/rs/webmcp/asset-middleware/BUILD.bazel b/rs/webmcp/asset-middleware/BUILD.bazel new file mode 100644 index 000000000000..bc54172d909a --- /dev/null +++ b/rs/webmcp/asset-middleware/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "ic_webmcp_asset_middleware", + srcs = glob(["src/**/*.rs"]), + crate_name = "ic_webmcp_asset_middleware", + deps = [ + # Keep sorted. + "@crate_index//:serde", + "@crate_index//:serde_json", + ], +) + +rust_test( + name = "unit_tests", + crate = ":ic_webmcp_asset_middleware", +) diff --git a/rs/webmcp/codegen/BUILD.bazel b/rs/webmcp/codegen/BUILD.bazel new file mode 100644 index 000000000000..bdf0bd610df8 --- /dev/null +++ b/rs/webmcp/codegen/BUILD.bazel @@ -0,0 +1,62 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") + +package(default_visibility = ["//visibility:public"]) + +DEPENDENCIES = [ + # Keep sorted. + "@crate_index//:anyhow", + "@crate_index//:candid", + "@crate_index//:candid_parser", + "@crate_index//:clap", + "@crate_index//:serde", + "@crate_index//:serde_json", +] + +DEV_DEPENDENCIES = [ + # Keep sorted. + "@crate_index//:pretty_assertions", + "@crate_index//:tempfile", +] + +rust_library( + name = "ic_webmcp_codegen", + srcs = glob( + ["src/**/*.rs"], + exclude = ["src/main.rs"], + ), + crate_name = "ic_webmcp_codegen", + deps = DEPENDENCIES, +) + +rust_binary( + name = "ic-webmcp-codegen", + srcs = ["src/main.rs"], + deps = DEPENDENCIES + [":ic_webmcp_codegen"], +) + +rust_test( + name = "unit_tests", + crate = ":ic_webmcp_codegen", + deps = DEV_DEPENDENCIES, +) + +rust_test( + name = "integration_tests", + srcs = ["tests/integration_test.rs"], + data = [ + # ICP ledger + "//rs/ledger_suite/icp:ledger.did", + # ICRC-1 ledger + "//rs/ledger_suite/icrc1/ledger:ledger.did", + # NNS canisters + "//rs/nns/governance:canister/governance.did", + "//rs/nns/cmc:cmc.did", + # SNS + "//rs/sns/swap:canister/swap.did", + # ckBTC + "//rs/bitcoin/ckbtc/minter:ckbtc_minter.did", + # Management canister + "//rs/types/management_canister_types:tests/ic.did", + ], + deps = [":ic_webmcp_codegen"] + DEV_DEPENDENCIES, +) diff --git a/rs/webmcp/codegen/tests/integration_test.rs b/rs/webmcp/codegen/tests/integration_test.rs index a1ccf6068215..e4784b735e23 100644 --- a/rs/webmcp/codegen/tests/integration_test.rs +++ b/rs/webmcp/codegen/tests/integration_test.rs @@ -4,10 +4,14 @@ use ic_webmcp_codegen::{Config, generate_manifest}; use std::path::PathBuf; fn repo_root() -> PathBuf { - // CARGO_MANIFEST_DIR = .../rs/webmcp/codegen - // repo root = .../ (3 levels up) - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest_dir + // Under Bazel, TEST_SRCDIR points to the runfiles tree root. + // Data files are at $TEST_SRCDIR//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() From 783d98b43472d4849f2f6a3bcaf7c27f259cf899 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 13:18:25 -0400 Subject: [PATCH 08/12] feat(webmcp): implement demo e-commerce backend canister Implements the backend canister for rs/webmcp/demo/ matching the Candid interface in backend.did. Per-caller cart state using Principal as key; products seeded at init time. src/lib.rs: - Product, CartItem, Cart, AddToCartResult, CheckoutResult types - Thread-local State (products, carts BTreeMap, order counter) - init_products(): seeds 4 products (1 out-of-stock to demonstrate) - list_products(), get_product(), get_cart(caller), add_to_cart(caller, item), remove_from_cart(caller, id), checkout(caller) - Functions take explicit Principal to avoid ic0 host dependency in tests - 13 unit tests covering all methods, error paths, and per-caller isolation src/main.rs: - #[ic_cdk::init/query/update] entry points passing msg_caller() through Also: - dfx.json updated to type: rust with package: demo-backend - assets/index.html minimal frontend loading webmcp.js - README.md with deploy and test instructions --- Cargo.lock | 9 + Cargo.toml | 1 + rs/webmcp/demo/Cargo.toml | 22 ++ rs/webmcp/demo/README.md | 48 ++++ rs/webmcp/demo/assets/index.html | 36 +++ rs/webmcp/demo/dfx.json | 4 +- rs/webmcp/demo/src/lib.rs | 448 +++++++++++++++++++++++++++++++ rs/webmcp/demo/src/main.rs | 39 +++ 8 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 rs/webmcp/demo/Cargo.toml create mode 100644 rs/webmcp/demo/README.md create mode 100644 rs/webmcp/demo/assets/index.html create mode 100644 rs/webmcp/demo/src/lib.rs create mode 100644 rs/webmcp/demo/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 8a1e26a87357..5ea0fa6b7ef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3698,6 +3698,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "demo-backend" +version = "0.1.0" +dependencies = [ + "candid", + "ic-cdk", + "serde", +] + [[package]] name = "depcheck" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ee747ab5e1c6..0a83b5aa5d34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -531,6 +531,7 @@ members = [ "rs/utils/validate_eq_derive", "rs/webmcp/asset-middleware", "rs/webmcp/codegen", + "rs/webmcp/demo", "rs/validator", "rs/validator/http_request_arbitrary", "rs/validator/http_request_test_utils", diff --git a/rs/webmcp/demo/Cargo.toml b/rs/webmcp/demo/Cargo.toml new file mode 100644 index 000000000000..6f0fb2d26d6a --- /dev/null +++ b/rs/webmcp/demo/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "demo-backend" +version = "0.1.0" +edition = "2021" +description = "WebMCP demo e-commerce canister" +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..7c1ec8588ecc --- /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: +dfx start --background +dfx 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: +dfx deploy frontend +``` + +Open `http://localhost:4943?canisterId=` 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/dfx.json b/rs/webmcp/demo/dfx.json index 61ba1ae7694f..ac6e24c1b0e0 100644 --- a/rs/webmcp/demo/dfx.json +++ b/rs/webmcp/demo/dfx.json @@ -1,9 +1,9 @@ { "canisters": { "backend": { - "type": "custom", + "type": "rust", "candid": "backend.did", - "wasm": "target/wasm32-unknown-unknown/release/demo_backend.wasm", + "package": "demo-backend", "webmcp": { "enabled": true, "name": "WebMCP Demo Shop", diff --git a/rs/webmcp/demo/src/lib.rs b/rs/webmcp/demo/src/lib.rs new file mode 100644 index 000000000000..e27760541433 --- /dev/null +++ b/rs/webmcp/demo/src/lib.rs @@ -0,0 +1,448 @@ +//! 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, + }) +} + +// ── 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..907a6dab9864 --- /dev/null +++ b/rs/webmcp/demo/src/main.rs @@ -0,0 +1,39 @@ +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(); +} + +#[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()) +} From e6b2eafd733c448a6aeabdadca43c977dedaf857 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 1 Apr 2026 13:26:55 -0400 Subject: [PATCH 09/12] feat(webmcp): add demo BUILD.bazel and stable memory upgrade hooks, address Copilot PR review feedback, align with IC repo conventions, fix four security issues identified in branch audit, certified query verification and navigator.modelContext polyfill, certified query verification and navigator.modelContext polyfill, address CI failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILD.bazel (rs/webmcp/demo/): - rust_library :demo_backend_lib (src/lib.rs — testable without IC host) - rust_canister :demo_backend_canister (src/main.rs, wasm32, with .did) - rust_test :unit_tests (inline #[cfg(test)]) - bazel query //rs/webmcp/demo/... confirms wasm artifact + didfile targets Stable memory (pre_upgrade / post_upgrade): - StableState { carts, next_order_id } serialised via Candid to stable memory - pre_upgrade: ic_cdk::storage::stable_save((stable,)) - post_upgrade: ic_cdk::storage::stable_restore() then restore_stable_state() - Products are re-seeded from constants; only mutable state persists - All 13 unit tests still pass, clippy clean manifest.rs — positional args now have required + additionalProperties: false Multi-arg methods generated arg0/arg1/... but omitted required[], making agents think all args were optional. Now marks non-opt args as required and adds additionalProperties: false. js_emitter.rs — fix auto-init for ES module script tags document.currentScript is null for