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..b18ce84 --- /dev/null +++ b/rust/cedar-policy-mcp-schema-generator-wasm/src/lib.rs @@ -0,0 +1,735 @@ +/* + * 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 + ) + }) +} + +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() => match serde_json::from_str::(json) { + Ok(c) => c.into(), + Err(e) => { + return WasmSchemaResult { + schema: None, + schema_json: None, + error: Some(format!("Invalid config: {e}")), + is_ok: false, + }; + } + }, + _ => SchemaGeneratorConfig::default(), + }; + + // Parse schema stub and create generator via the generator crate's + // convenience method, avoiding a direct cedar-policy-core dependency. + let mut generator = match SchemaGenerator::from_cedarschema_str_with_config(schema_stub, config) + { + Ok(g) => g, + Err(e) => { + return WasmSchemaResult { + schema: None, + schema_json: None, + error: Some(format!("Schema error: {e}")), + is_ok: false, + }; + } + }; + + // Parse tool descriptions + let server_desc = match ServerDescription::from_json_str(tools_json) { + Ok(desc) => desc, + Err(e) => { + return WasmSchemaResult { + schema: None, + schema_json: None, + error: Some(format!("Invalid tool descriptions: {e}")), + is_ok: false, + }; + } + }; + + if let Err(e) = generator.add_actions_from_server_description(&server_desc) { + return WasmSchemaResult { + schema: None, + schema_json: None, + error: Some(format!("Error adding tools: {e}")), + is_ok: false, + }; + } + + // 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 schema_json = match serde_json::to_string_pretty(generator.get_schema()) { + Ok(json) => Some(json), + Err(e) => { + return WasmSchemaResult { + schema: Some(schema_text), + schema_json: None, + error: Some(format!("JSON serialization warning: {e}")), + is_ok: true, + }; + } + }; + + WasmSchemaResult { + schema: Some(schema_text), + schema_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())); + } }