From b92f450564d03532a34d350befc1ba0a74127354 Mon Sep 17 00:00:00 2001 From: tommylauren Date: Wed, 8 Apr 2026 21:02:10 -0400 Subject: [PATCH] feat: WASM bindings for cedar-policy-mcp-schema-generator Add `cedar-policy-mcp-schema-generator-wasm`, a thin wasm-bindgen wrapper around the existing Rust SchemaGenerator. Enables JavaScript and TypeScript environments (Node.js, browsers) to generate Cedar schemas from MCP tool descriptions with identical behavior to the Rust implementation. Motivated by @lianah's recommendation in #63 to use WASM bindings instead of a TypeScript reimplementation. Changes to cedar-policy-mcp-schema-generator: - Add SchemaGenerator::from_cedarschema_str() and from_cedarschema_str_with_config() convenience constructors - Add SchemaGenerator::get_schema_as_str() for human-readable output - Add SchemaParseError variant to SchemaGeneratorError - 6 new unit tests for the above APIs WASM bindings crate: - Single generateSchema() function exposed via wasm-bindgen - All SchemaGeneratorConfig options exposed (camelCase JS naming) - Returns JSON with schema, schemaJson, error, and isOk fields - Zero direct dependency on cedar-policy-core (all parsing delegated to generator crate's from_cedarschema_str) - 3 Rust unit tests - 8 wasm-bindgen-test integration tests (basic generation, multi-tool, config options, error handling, config defaults) Signed-off-by: tommylauren --- rust/Cargo.lock | 206 ++++- rust/Cargo.toml | 1 + .../Cargo.toml | 29 + .../README.md | 109 +++ .../src/lib.rs | 721 ++++++++++++++++++ .../tests/wasm_integration.rs | 220 ++++++ .../src/generator/err.rs | 7 + .../src/generator/schema.rs | 218 ++++++ 8 files changed, 1482 insertions(+), 29 deletions(-) create mode 100644 rust/cedar-policy-mcp-schema-generator-wasm/Cargo.toml create mode 100644 rust/cedar-policy-mcp-schema-generator-wasm/README.md create mode 100644 rust/cedar-policy-mcp-schema-generator-wasm/src/lib.rs create mode 100644 rust/cedar-policy-mcp-schema-generator-wasm/tests/wasm_integration.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 35c0a78..4a34bca 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -124,6 +124,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -223,6 +234,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.59" @@ -284,6 +301,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "cedar-policy-mcp-schema-generator-wasm" +version = "0.1.0" +dependencies = [ + "cedar-policy-mcp-schema-generator", + "mcp-tools-sdk", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-test", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -516,7 +545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -543,6 +572,30 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -660,9 +713,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -714,10 +767,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -775,6 +830,12 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -868,6 +929,16 @@ dependencies = [ "syn", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -901,6 +972,15 @@ dependencies = [ "serde", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -914,6 +994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -937,6 +1018,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "owo-colors" version = "4.3.0" @@ -973,7 +1060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.12.1", + "indexmap 2.13.1", ] [[package]] @@ -991,6 +1078,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "powerfmt" version = "0.2.0" @@ -1154,7 +1247,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1186,9 +1279,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -1244,7 +1337,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.1", "itoa", "memchr", "serde", @@ -1262,9 +1355,9 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.1", + "indexmap 2.13.1", "schemars 0.9.0", - "schemars 1.2.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -1305,6 +1398,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -1394,7 +1493,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1403,7 +1502,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1413,7 +1512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1621,9 +1720,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -1632,11 +1731,21 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1644,9 +1753,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -1657,13 +1766,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941c102b3f0c15b6d72a53205e09e6646aafcf2991e18412cc331dbac1806bc0" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26bd6570f39bb1440fd8f01b63461faaf2a3f6078a508e4e54efa99363108d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c29582b14d5bf030b02fa232b9b57faf2afc322d2c61964dd80bad02bf76207" + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1681,7 +1829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.12.1", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -1694,7 +1842,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap 2.12.1", + "indexmap 2.13.1", "semver", ] @@ -1704,7 +1852,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1876,7 +2024,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.12.1", + "indexmap 2.13.1", "prettyplease", "syn", "wasm-metadata", @@ -1907,7 +2055,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap 2.12.1", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -1926,7 +2074,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.12.1", + "indexmap 2.13.1", "log", "semver", "serde", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a79c645..caa3f9e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "cedar-policy-mcp-schema-generator", + "cedar-policy-mcp-schema-generator-wasm", "mcp-tools-sdk", ] diff --git a/rust/cedar-policy-mcp-schema-generator-wasm/Cargo.toml b/rust/cedar-policy-mcp-schema-generator-wasm/Cargo.toml new file mode 100644 index 0000000..f848cde --- /dev/null +++ b/rust/cedar-policy-mcp-schema-generator-wasm/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "cedar-policy-mcp-schema-generator-wasm" +description = "WASM bindings for cedar-policy-mcp-schema-generator, exposing SchemaGenerator to JavaScript/TypeScript." +version = "0.1.0" + +edition.workspace = true +rust-version.workspace = true +license.workspace = true +categories.workspace = true +keywords.workspace = true +homepage.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +cedar-policy-mcp-schema-generator = { path = "../cedar-policy-mcp-schema-generator" } +mcp-tools-sdk = { path = "../mcp-tools-sdk" } +wasm-bindgen = "0.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +wasm-bindgen-test = "0.3" +serde_json = "1.0" + +[lints] +workspace = true diff --git a/rust/cedar-policy-mcp-schema-generator-wasm/README.md b/rust/cedar-policy-mcp-schema-generator-wasm/README.md new file mode 100644 index 0000000..be36336 --- /dev/null +++ b/rust/cedar-policy-mcp-schema-generator-wasm/README.md @@ -0,0 +1,109 @@ +# cedar-policy-mcp-schema-generator-wasm + +WASM bindings for [cedar-policy-mcp-schema-generator](../cedar-policy-mcp-schema-generator/), exposing `SchemaGenerator` to JavaScript and TypeScript via `wasm-bindgen`. + +This enables Node.js and browser environments to generate Cedar authorization schemas from MCP tool descriptions with the **exact same behavior** as the Rust implementation, including correct handling of: + +- JSON `number` as `Long` or `Decimal` (configurable) +- `additionalProperties` as Cedar tagged entities +- Namespaced type deduplication for nested objects + +## Usage + +```javascript +const { generateSchema } = require('@cedar-policy/mcp-schema-generator-wasm'); + +const stub = ` +namespace MyServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; +} +`; + +const tools = JSON.stringify([ + { + name: 'read_file', + description: 'Read a file from disk', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + }, +]); + +const result = JSON.parse(generateSchema(stub, tools)); + +if (result.isOk) { + console.log(result.schema); // Human-readable .cedarschema + console.log(result.schemaJson); // JSON for Cedar WASM isAuthorized() +} else { + console.error(result.error); +} +``` + +## API + +### `generateSchema(schemaStub, toolsJson, configJson?)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `schemaStub` | `string` | Cedar schema stub with `@mcp_principal` and `@mcp_resource` annotations | +| `toolsJson` | `string` | MCP tool descriptions as JSON (the `tools` array from `tools/list`) | +| `configJson` | `string?` | Optional configuration as JSON | + +**Returns:** JSON string with fields: + +| Field | Type | Description | +|-------|------|-------------| +| `schema` | `string \| null` | Generated Cedar schema as `.cedarschema` text | +| `schemaJson` | `string \| null` | Generated schema as JSON (for `isAuthorized()`) | +| `error` | `string \| null` | Error message if generation failed | +| `isOk` | `boolean` | Whether generation succeeded | + +### Configuration + +```json +{ + "includeOutputs": false, + "objectsAsRecords": false, + "eraseAnnotations": true, + "flattenNamespaces": false, + "numbersAsDecimal": false +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `includeOutputs` | `false` | Include tool output schemas in actions | +| `objectsAsRecords` | `false` | Use records instead of entities for objects without `additionalProperties` | +| `eraseAnnotations` | `true` | Remove `@mcp_*` annotations from output | +| `flattenNamespaces` | `false` | Flatten all types into a single namespace | +| `numbersAsDecimal` | `false` | Encode JSON `number` as Cedar `Decimal` instead of `Long` | + +## Building + +```bash +# Install wasm-pack +cargo install wasm-pack + +# Build for Node.js +wasm-pack build --target nodejs --scope cedar-policy + +# Build for browsers +wasm-pack build --target web --scope cedar-policy +``` + +## Relationship to the Rust Generator + +This crate is a thin `wasm-bindgen` wrapper around the existing `cedar-policy-mcp-schema-generator` Rust crate. All schema generation logic, type mapping, and edge case handling is delegated to the Rust implementation. The WASM bindings add no independent logic. + +## License + +Apache-2.0 diff --git a/rust/cedar-policy-mcp-schema-generator-wasm/src/lib.rs b/rust/cedar-policy-mcp-schema-generator-wasm/src/lib.rs new file mode 100644 index 0000000..8635900 --- /dev/null +++ b/rust/cedar-policy-mcp-schema-generator-wasm/src/lib.rs @@ -0,0 +1,721 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! WASM bindings for the Cedar MCP Schema Generator. +//! +//! Exposes [`SchemaGenerator`] to JavaScript/TypeScript via `wasm-bindgen`, +//! enabling Node.js and browser environments to generate Cedar schemas from +//! MCP tool descriptions with the exact same behavior as the Rust implementation. +//! +//! This crate is a thin wrapper: all schema generation logic is delegated to +//! [`cedar_policy_mcp_schema_generator`], including schema stub parsing. This +//! avoids a direct dependency on `cedar-policy-core` in the bindings crate. + +use cedar_policy_mcp_schema_generator::{SchemaGenerator, SchemaGeneratorConfig}; +use mcp_tools_sdk::description::ServerDescription; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +/// Configuration options for schema generation, matching the Rust +/// [`SchemaGeneratorConfig`] options. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WasmConfig { + #[serde(default)] + include_outputs: bool, + #[serde(default)] + objects_as_records: bool, + #[serde(default = "default_true")] + erase_annotations: bool, + #[serde(default)] + flatten_namespaces: bool, + #[serde(default)] + numbers_as_decimal: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for WasmConfig { + fn default() -> Self { + Self { + include_outputs: false, + objects_as_records: false, + erase_annotations: true, + flatten_namespaces: false, + numbers_as_decimal: false, + } + } +} + +impl From for SchemaGeneratorConfig { + fn from(c: WasmConfig) -> Self { + SchemaGeneratorConfig::default() + .include_outputs(c.include_outputs) + .objects_as_records(c.objects_as_records) + .erase_annotations(c.erase_annotations) + .flatten_namespaces(c.flatten_namespaces) + .encode_numbers_as_decimal(c.numbers_as_decimal) + } +} + +/// Result returned to JavaScript from schema generation. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WasmSchemaResult { + /// The generated Cedar schema as human-readable `.cedarschema` text. + /// `null` if generation failed. + schema: Option, + /// The generated Cedar schema as JSON (for `isAuthorized()`). + /// `null` if generation failed. + schema_json: Option, + /// Error message, `null` if successful. + error: Option, + /// Whether generation succeeded. + is_ok: bool, +} + +/// Generate a Cedar schema from a schema stub and MCP tool descriptions. +/// +/// # Arguments +/// +/// * `schema_stub` - A Cedar schema stub as a `.cedarschema` string. Must +/// contain entity types annotated with `@mcp_principal` and `@mcp_resource`. +/// * `tools_json` - MCP tool descriptions as a JSON string. This should be +/// the `tools` array from an MCP `tools/list` response. +/// * `config_json` - Optional configuration as a JSON string. If `null` or +/// empty, defaults are used. +/// +/// # Returns +/// +/// A JSON object with `schema` (human-readable), `schemaJson` (for Cedar +/// WASM evaluation), `error`, and `isOk` fields. +#[wasm_bindgen(js_name = "generateSchema")] +pub fn generate_schema( + schema_stub: &str, + tools_json: &str, + // wasm-bindgen requires Option, not Option<&str>, for optional parameters. + config_json: Option, +) -> String { + let config_ref = config_json.as_deref(); + let result = generate_schema_inner(schema_stub, tools_json, config_ref); + drop(config_json); + serde_json::to_string(&result).unwrap_or_else(|e| { + format!( + r#"{{"isOk":false,"error":"Serialization error: {}","schema":null,"schemaJson":null}}"#, + e + ) + }) +} + +/// Convenience constructor for error results. +fn err_result(error: String) -> WasmSchemaResult { + WasmSchemaResult { + schema: None, + schema_json: None, + error: Some(error), + is_ok: false, + } +} + +fn generate_schema_inner( + schema_stub: &str, + tools_json: &str, + config_json: Option<&str>, +) -> WasmSchemaResult { + // Parse config + let config: SchemaGeneratorConfig = match config_json { + Some(json) if !json.is_empty() => { + let Ok(c) = serde_json::from_str::(json) else { + return err_result(format!( + "Invalid config: {}", + serde_json::from_str::(json) + .err() + .map_or_else(|| "unrecognized fields".to_string(), |e| e.to_string()) + )); + }; + c.into() + } + _ => SchemaGeneratorConfig::default(), + }; + + // Parse schema stub and create generator via the generator crate's + // convenience method, avoiding a direct cedar-policy-core dependency. + let Ok(mut generator) = SchemaGenerator::from_cedarschema_str_with_config(schema_stub, config) + else { + return err_result("Schema error: failed to parse schema stub".to_string()); + }; + + // Parse tool descriptions + let Ok(server_desc) = ServerDescription::from_json_str(tools_json) else { + return err_result("Invalid tool descriptions: failed to parse JSON".to_string()); + }; + + if let Err(e) = generator.add_actions_from_server_description(&server_desc) { + return err_result(format!("Error adding tools: {e}")); + } + + // Get the generated schema as a human-readable string + let schema_text = generator.get_schema_as_str(); + + // Convert to JSON for Cedar WASM isAuthorized() + let Ok(json) = serde_json::to_string_pretty(generator.get_schema()) else { + return WasmSchemaResult { + schema: Some(schema_text), + schema_json: None, + error: Some("JSON serialization warning: failed to serialize schema".to_string()), + is_ok: true, + }; + }; + + WasmSchemaResult { + schema: Some(schema_text), + schema_json: Some(json), + error: None, + is_ok: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_schema_basic() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + let tools = r#"[ + { + "name": "read_file", + "description": "Read a file from disk", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"] + } + } + ]"#; + + let result_json = generate_schema(stub, tools, None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + + assert!( + result.is_ok, + "Expected success, got error: {:?}", + result.error + ); + let schema = result.schema.expect("Schema should be present"); + assert!( + schema.contains("read_file"), + "Schema should contain read_file action" + ); + } + + #[test] + fn test_invalid_stub() { + let result_json = generate_schema("not a valid schema", "[]", None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!(!result.is_ok); + assert!(result.error.is_some()); + } + + #[test] + fn test_empty_tools() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + let result_json = generate_schema(stub, "[]", None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + // Empty tools should still produce a valid (minimal) schema + assert!(result.is_ok); + } + + #[test] + fn test_invalid_config_json() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + let result_json = generate_schema(stub, "[]", Some("not valid json".to_string())); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!(!result.is_ok); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Invalid config")); + } + + #[test] + fn test_invalid_tools_json() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + let result_json = generate_schema(stub, "not valid json", None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!(!result.is_ok); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Invalid tool descriptions")); + } + + #[test] + fn test_config_with_options() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + let tools = r#"[ + { + "name": "calculate", + "description": "Perform calculation", + "inputSchema": { + "type": "object", + "properties": { + "value": { "type": "number" } + } + } + } + ]"#; + + let config = r#"{"numbersAsDecimal": true, "includeOutputs": false}"#; + + let result_json = generate_schema(stub, tools, Some(config.to_string())); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!( + result.is_ok, + "Expected success, got error: {:?}", + result.error + ); + // Config options should be accepted and produce a valid schema + assert!(result.schema.is_some()); + assert!(result.schema_json.is_some()); + } + + #[test] + fn test_empty_config_string() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + // Empty string config should use defaults (same as None) + let result_json = generate_schema(stub, "[]", Some(String::new())); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!(result.is_ok); + } + + #[test] + fn test_default_config_values() { + let config = WasmConfig::default(); + assert!(!config.include_outputs); + assert!(!config.objects_as_records); + assert!(config.erase_annotations); + assert!(!config.flatten_namespaces); + assert!(!config.numbers_as_decimal); + } + + #[test] + fn test_wasm_config_to_schema_config() { + let wasm_config = WasmConfig { + include_outputs: true, + objects_as_records: true, + erase_annotations: false, + flatten_namespaces: true, + numbers_as_decimal: true, + }; + let _config: SchemaGeneratorConfig = wasm_config.into(); + // Conversion should not panic + } + + #[test] + fn test_default_true_helper() { + assert!(default_true()); + } + + #[test] + fn test_schema_json_present_on_success() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + let tools = r#"[ + { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string" } + } + } + } + ]"#; + + let result_json = generate_schema(stub, tools, None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!(result.is_ok); + assert!(result.schema.is_some()); + assert!(result.schema_json.is_some()); + assert!(result.error.is_none()); + + // Verify schema_json is valid JSON + let schema_json = result.schema_json.unwrap(); + assert!( + serde_json::from_str::(&schema_json).is_ok(), + "schemaJson should be valid JSON" + ); + } +} + +#[cfg(test)] +mod coverage_tests { + use super::*; + + /// Stub shared across coverage tests. + const STUB: &str = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + #[test] + fn test_multi_tool_with_diverse_types() { + // Exercises add_actions_from_server_description with multiple tools + // and diverse property types (string, integer, boolean) to cover + // deeper code paths in generate_schema_inner. + let tools = r#"[ + { + "name": "search", + "description": "Search for items", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer" }, + "offset": { "type": "integer" } + }, + "required": ["query"] + } + }, + { + "name": "get_item", + "description": "Get a specific item", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "include_metadata": { "type": "boolean" } + }, + "required": ["id"] + } + } + ]"#; + + let result_json = generate_schema(STUB, tools, None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!( + result.is_ok, + "Expected success, got error: {:?}", + result.error + ); + + let schema = result.schema.expect("Schema should be present"); + assert!(schema.contains("search"), "Should contain search action"); + assert!( + schema.contains("get_item"), + "Should contain get_item action" + ); + assert!(schema.contains("Long"), "Integer should map to Long"); + } + + #[test] + fn test_all_config_options_enabled() { + // Exercises the WasmConfig -> SchemaGeneratorConfig conversion + // with all non-default values to ensure full coverage of the + // From impl. + let tools = r#"[ + { + "name": "calc", + "description": "Calculate", + "inputSchema": { + "type": "object", + "properties": { + "value": { "type": "number" }, + "name": { "type": "string" } + } + } + } + ]"#; + + let config = r#"{ + "numbersAsDecimal": true, + "includeOutputs": true, + "objectsAsRecords": true, + "eraseAnnotations": false, + "flattenNamespaces": true + }"#; + + let result_json = generate_schema(STUB, tools, Some(config.to_string())); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!( + result.is_ok, + "Expected success with all config, got error: {:?}", + result.error + ); + assert!(result.schema.is_some()); + assert!(result.schema_json.is_some()); + } + + #[test] + fn test_error_result_fields_complete() { + // Verifies all fields of the WasmSchemaResult on error: + // schema and schema_json should be None, error should explain + // the failure, is_ok should be false. + let result_json = generate_schema("invalid", "[]", None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!(!result.is_ok); + assert!(result.schema.is_none(), "Schema should be None on error"); + assert!( + result.schema_json.is_none(), + "SchemaJson should be None on error" + ); + assert!(result.error.is_some(), "Error should be present"); + assert!( + result + .error + .as_deref() + .unwrap_or("") + .contains("Schema error"), + "Error should indicate schema parsing failure" + ); + } + + #[test] + fn test_tool_with_nested_object() { + // Exercises object type mapping paths in schema generation. + let tools = r#"[ + { + "name": "create_record", + "description": "Create a record", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "metadata": { + "type": "object", + "properties": { + "created_by": { "type": "string" }, + "priority": { "type": "integer" } + } + } + }, + "required": ["name"] + } + } + ]"#; + + let result_json = generate_schema(STUB, tools, None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!( + result.is_ok, + "Expected success, got error: {:?}", + result.error + ); + assert!(result.schema.is_some()); + assert!(result.schema_json.is_some()); + } + + #[test] + fn test_tool_with_array_property() { + // Exercises array type mapping. + let tools = r#"[ + { + "name": "process_batch", + "description": "Process items", + "inputSchema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["items"] + } + } + ]"#; + + let result_json = generate_schema(STUB, tools, None); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!( + result.is_ok, + "Expected success, got error: {:?}", + result.error + ); + let schema = result.schema.expect("Schema"); + assert!( + schema.contains("process_batch"), + "Should contain action name" + ); + } + + #[test] + fn test_config_partial_options() { + // Only some config options set (exercises serde defaults). + let tools = r#"[ + { + "name": "test", + "description": "test", + "inputSchema": { + "type": "object", + "properties": { + "x": { "type": "string" } + } + } + } + ]"#; + + let config = r#"{"objectsAsRecords": true}"#; + let result_json = generate_schema(STUB, tools, Some(config.to_string())); + #[expect(clippy::expect_used, reason = "Test assertion")] + let result: WasmSchemaResult = + serde_json::from_str(&result_json).expect("Should parse result"); + assert!( + result.is_ok, + "Expected success, got error: {:?}", + result.error + ); + } + + #[test] + fn test_generate_schema_inner_directly() { + // Calls generate_schema_inner with various config_json values + // to ensure the match arm coverage. + let result = generate_schema_inner(STUB, "[]", None); + assert!(result.is_ok); + + let result = generate_schema_inner(STUB, "[]", Some("")); + assert!(result.is_ok); + + let result = generate_schema_inner(STUB, "[]", Some("{}")); + assert!(result.is_ok); + } +} diff --git a/rust/cedar-policy-mcp-schema-generator-wasm/tests/wasm_integration.rs b/rust/cedar-policy-mcp-schema-generator-wasm/tests/wasm_integration.rs new file mode 100644 index 0000000..a6aa2a0 --- /dev/null +++ b/rust/cedar-policy-mcp-schema-generator-wasm/tests/wasm_integration.rs @@ -0,0 +1,220 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! WASM integration tests for the Cedar MCP Schema Generator bindings. +//! +//! These tests exercise the `generateSchema` function through wasm-bindgen, +//! verifying correct behavior at the JS/WASM boundary. + +use cedar_policy_mcp_schema_generator_wasm::generate_schema; +use wasm_bindgen_test::*; + +/// Helper: parse the JSON result and return the deserialized fields. +fn parse_result(json: &str) -> serde_json::Value { + serde_json::from_str(json).expect("Result should be valid JSON") +} + +/// Shared schema stub for tests. +const STUB: &str = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } +"#; + +#[wasm_bindgen_test] +fn test_basic_schema_generation() { + let tools = r#"[ + { + "name": "read_file", + "description": "Read a file from disk", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"] + } + } + ]"#; + + let result_json = generate_schema(STUB, tools, None); + let result = parse_result(&result_json); + + assert_eq!(result["isOk"], true); + assert!(result["error"].is_null()); + + let schema = result["schema"].as_str().unwrap(); + assert!( + schema.contains("read_file"), + "Schema should contain read_file action" + ); + assert!( + schema.contains("read_fileInput"), + "Schema should contain input type" + ); + assert!( + schema.contains("String"), + "Schema should contain String type for path" + ); + + // schemaJson should also be present and valid JSON + let schema_json_str = result["schemaJson"].as_str().unwrap(); + let _schema_json: serde_json::Value = + serde_json::from_str(schema_json_str).expect("schemaJson should be valid JSON"); +} + +#[wasm_bindgen_test] +fn test_multi_tool_schema() { + let tools = r#"[ + { + "name": "execute_command", + "description": "Execute a shell command", + "inputSchema": { + "type": "object", + "properties": { + "command": { "type": "string" }, + "timeout": { "type": "integer" } + }, + "required": ["command"] + } + }, + { + "name": "read_file", + "description": "Read a file", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"] + } + } + ]"#; + + let result_json = generate_schema(STUB, tools, None); + let result = parse_result(&result_json); + + assert_eq!(result["isOk"], true); + let schema = result["schema"].as_str().unwrap(); + assert!( + schema.contains("execute_command"), + "Should contain execute_command" + ); + assert!(schema.contains("read_file"), "Should contain read_file"); + assert!( + schema.contains("Long"), + "Integer should map to Long by default" + ); +} + +#[wasm_bindgen_test] +fn test_config_numbers_as_decimal() { + let tools = r#"[ + { + "name": "calculate", + "description": "Calculate something", + "inputSchema": { + "type": "object", + "properties": { + "value": { "type": "number" } + }, + "required": ["value"] + } + } + ]"#; + + let config = r#"{"numbersAsDecimal": true}"#; + let result_json = generate_schema(STUB, tools, Some(config.to_string())); + let result = parse_result(&result_json); + + assert_eq!(result["isOk"], true); + let schema = result["schema"].as_str().unwrap(); + assert!( + schema.contains("Decimal"), + "With numbersAsDecimal, number types should map to Decimal" + ); +} + +#[wasm_bindgen_test] +fn test_invalid_schema_stub_returns_error() { + let result_json = generate_schema("this is not valid cedar schema", "[]", None); + let result = parse_result(&result_json); + + assert_eq!(result["isOk"], false); + assert!(!result["error"].is_null(), "Should have an error message"); + assert!(result["schema"].is_null(), "Schema should be null on error"); +} + +#[wasm_bindgen_test] +fn test_invalid_tools_json_returns_error() { + let result_json = generate_schema(STUB, "not valid json", None); + let result = parse_result(&result_json); + + assert_eq!(result["isOk"], false); + assert!(!result["error"].is_null()); +} + +#[wasm_bindgen_test] +fn test_invalid_config_returns_error() { + let tools = + r#"[{"name":"t","description":"d","inputSchema":{"type":"object","properties":{}}}]"#; + let result_json = generate_schema(STUB, tools, Some("not valid json".to_string())); + let result = parse_result(&result_json); + + assert_eq!(result["isOk"], false); + assert!(result["error"].as_str().unwrap().contains("Invalid config"),); +} + +#[wasm_bindgen_test] +fn test_empty_tools_produces_minimal_schema() { + let result_json = generate_schema(STUB, "[]", None); + let result = parse_result(&result_json); + + assert_eq!(result["isOk"], true); + let schema = result["schema"].as_str().unwrap(); + assert!( + schema.contains("TestServer"), + "Should contain the namespace" + ); + assert!( + schema.contains("call_tool"), + "Should contain the base action" + ); +} + +#[wasm_bindgen_test] +fn test_optional_config_defaults() { + // Passing None for config should use defaults (same as empty config) + let tools = r#"[{"name":"t","description":"d","inputSchema":{"type":"object","properties":{"x":{"type":"string"}}}}]"#; + + let result_none = generate_schema(STUB, tools, None); + let result_empty = generate_schema(STUB, tools, Some("{}".to_string())); + + let r1 = parse_result(&result_none); + let r2 = parse_result(&result_empty); + + assert_eq!(r1["isOk"], true); + assert_eq!(r2["isOk"], true); + // Both should produce the same schema + assert_eq!(r1["schema"], r2["schema"]); +} diff --git a/rust/cedar-policy-mcp-schema-generator/src/generator/err.rs b/rust/cedar-policy-mcp-schema-generator/src/generator/err.rs index 2e76e68..49317a5 100644 --- a/rust/cedar-policy-mcp-schema-generator/src/generator/err.rs +++ b/rust/cedar-policy-mcp-schema-generator/src/generator/err.rs @@ -115,6 +115,13 @@ pub enum SchemaGeneratorError { help("Server Descriptions cannot be merged. Consider pre-merging Server descriptions and using add_actions_from_server_description API.") )] ServerDescriptionMerge, + /// SchemaGenerator failed to parse a Cedar schema string + #[error("Failed to parse Cedar schema: {0}")] + #[diagnostic( + code(schema_generator::schema_parse_error), + help("Ensure the input is a valid .cedarschema string.") + )] + SchemaParseError(String), } impl SchemaGeneratorError { diff --git a/rust/cedar-policy-mcp-schema-generator/src/generator/schema.rs b/rust/cedar-policy-mcp-schema-generator/src/generator/schema.rs index b0bb994..e80e664 100644 --- a/rust/cedar-policy-mcp-schema-generator/src/generator/schema.rs +++ b/rust/cedar-policy-mcp-schema-generator/src/generator/schema.rs @@ -159,6 +159,30 @@ impl SchemaGenerator { Self::new_with_config(schema_stub, SchemaGeneratorConfig::default()) } + /// Create a `SchemaGenerator` from a `.cedarschema` string using default configuration. + /// + /// This is a convenience method that parses the schema stub from a string, + /// avoiding the need for callers to depend on `cedar-policy-core` directly. + pub fn from_cedarschema_str(schema_stub: &str) -> Result { + Self::from_cedarschema_str_with_config(schema_stub, SchemaGeneratorConfig::default()) + } + + /// Create a `SchemaGenerator` from a `.cedarschema` string using specified configuration. + /// + /// This is a convenience method that parses the schema stub from a string, + /// avoiding the need for callers to depend on `cedar-policy-core` directly. + pub fn from_cedarschema_str_with_config( + schema_stub: &str, + config: SchemaGeneratorConfig, + ) -> Result { + use cedar_policy_core::extensions::Extensions; + let extensions = Extensions::all_available(); + let (fragment, _warnings) = + Fragment::::from_cedarschema_str(schema_stub, extensions) + .map_err(|e| SchemaGeneratorError::SchemaParseError(e.to_string()))?; + Self::new_with_config(fragment, config) + } + /// Create a `SchemaGenerator` from a Cedar Schema Fragment using specified configuration pub fn new_with_config( schema_stub: Fragment, @@ -274,6 +298,11 @@ impl SchemaGenerator { &self.fragment } + /// Get the current Cedar Schema as a human-readable `.cedarschema` string. + pub fn get_schema_as_str(&self) -> String { + format!("{}", self.fragment) + } + /// Get a `RequestGenerator` that will convert MCP tool Input/Ouptut /// requests that validate against a tool added to this `SchemaGenerator` /// to Cedar Authorization Requests that validate against the current Schema. @@ -1862,4 +1891,193 @@ namespace Test2 { ); assert_eq!(&schema_stub, schema_generator.get_schema()); } + + // ── Tests for from_cedarschema_str and get_schema_as_str ── + + #[test] + fn test_from_cedarschema_str_basic() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + let generator = SchemaGenerator::from_cedarschema_str(stub); + assert!(generator.is_ok(), "Should parse valid cedarschema string"); + } + + #[test] + fn test_from_cedarschema_str_with_config() { + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + let config = SchemaGeneratorConfig::default().include_outputs(true); + let generator = SchemaGenerator::from_cedarschema_str_with_config(stub, config); + assert!( + generator.is_ok(), + "Should parse valid cedarschema string with config" + ); + } + + #[test] + fn test_from_cedarschema_str_invalid_input() { + let result = SchemaGenerator::from_cedarschema_str("not valid cedar schema"); + assert!(result.is_err(), "Should fail on invalid cedarschema"); + let err = result.unwrap_err(); + assert!( + matches!(err, SchemaGeneratorError::SchemaParseError(_)), + "Error should be SchemaParseError, got: {err:?}" + ); + } + + #[test] + fn test_from_cedarschema_str_matches_fragment_constructor() { + // Verify that from_cedarschema_str produces the same generator + // as manually parsing the fragment and calling new(). + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + let gen_str = SchemaGenerator::from_cedarschema_str(stub).expect("from_cedarschema_str"); + + let extensions = Extensions::all_available(); + let (fragment, _) = + Fragment::::from_cedarschema_str(stub, extensions).expect("parse fragment"); + let gen_frag = SchemaGenerator::new(fragment).expect("new from fragment"); + + // Both should produce identical schema output + assert_eq!(gen_str.get_schema_as_str(), gen_frag.get_schema_as_str()); + } + + #[test] + fn test_get_schema_as_str_contains_namespace() { + let stub = r#" + namespace MyNamespace { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + let generator = SchemaGenerator::from_cedarschema_str(stub).expect("parse"); + let output = generator.get_schema_as_str(); + assert!( + output.contains("MyNamespace"), + "get_schema_as_str should contain the namespace name" + ); + } + + #[test] + fn test_get_schema_as_str_matches_display() { + // get_schema_as_str should produce the same output as Display + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + let generator = SchemaGenerator::from_cedarschema_str(stub).expect("parse"); + let str_output = generator.get_schema_as_str(); + let display_output = format!("{}", generator.get_schema()); + assert_eq!( + str_output, display_output, + "get_schema_as_str and Display should produce identical output" + ); + } +} + +#[cfg(test)] +mod coverage_tests { + use super::*; + + #[test] + fn test_schema_parse_error_display_format() { + // Exercises the SchemaParseError Display impl from err.rs + let result = SchemaGenerator::from_cedarschema_str("this is not valid cedar"); + assert!(result.is_err()); + let err = result.unwrap_err(); + let err_msg = format!("{err}"); + assert!( + err_msg.contains("Failed to parse Cedar schema"), + "Error message should contain expected prefix, got: {err_msg}" + ); + } + + #[test] + fn test_from_cedarschema_str_with_config_error_path() { + // Exercises the error path of from_cedarschema_str_with_config + let config = SchemaGeneratorConfig::default().encode_numbers_as_decimal(true); + let result = SchemaGenerator::from_cedarschema_str_with_config("invalid schema", config); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + SchemaGeneratorError::SchemaParseError(_) + )); + } + + #[test] + fn test_get_schema_as_str_content_validation() { + // Validates the content of get_schema_as_str more thoroughly + let stub = r#" + namespace TestServer { + @mcp_principal + entity User; + @mcp_resource + entity McpServer; + action "call_tool" appliesTo { + principal: [User], + resource: [McpServer] + }; + } + "#; + + let gen = SchemaGenerator::from_cedarschema_str(stub).expect("parse"); + let output = gen.get_schema_as_str(); + assert!(output.contains("TestServer"), "Should contain namespace"); + assert!(output.contains("User"), "Should contain User entity"); + assert!( + output.contains("McpServer"), + "Should contain McpServer entity" + ); + assert!( + output.contains("call_tool"), + "Should contain call_tool action" + ); + // Verify it matches Display for the same fragment + assert_eq!(output, format!("{}", gen.get_schema())); + } }