cedar-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.github/workflows/release.yml +42 -0
  4. package/.nvmrc +1 -0
  5. package/CHANGELOG.md +241 -0
  6. package/CONTRIBUTING.md +83 -0
  7. package/LICENSE +182 -0
  8. package/README.md +1635 -0
  9. package/SECURITY.md +37 -0
  10. package/dist/http-server.d.ts +61 -0
  11. package/dist/http-server.d.ts.map +1 -0
  12. package/dist/http-server.js +194 -0
  13. package/dist/http-server.js.map +1 -0
  14. package/dist/index.d.ts +32 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +270 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/parser/policy-ast.d.ts +49 -0
  19. package/dist/parser/policy-ast.d.ts.map +1 -0
  20. package/dist/parser/policy-ast.js +311 -0
  21. package/dist/parser/policy-ast.js.map +1 -0
  22. package/dist/prompts/index.d.ts +38 -0
  23. package/dist/prompts/index.d.ts.map +1 -0
  24. package/dist/prompts/index.js +172 -0
  25. package/dist/prompts/index.js.map +1 -0
  26. package/dist/resources/ref-resolver.d.ts +23 -0
  27. package/dist/resources/ref-resolver.d.ts.map +1 -0
  28. package/dist/resources/ref-resolver.js +128 -0
  29. package/dist/resources/ref-resolver.js.map +1 -0
  30. package/dist/resources/store-manager.d.ts +64 -0
  31. package/dist/resources/store-manager.d.ts.map +1 -0
  32. package/dist/resources/store-manager.js +221 -0
  33. package/dist/resources/store-manager.js.map +1 -0
  34. package/dist/server.d.ts +18 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js +539 -0
  37. package/dist/server.js.map +1 -0
  38. package/dist/tools/advise/avp-rules.d.ts +49 -0
  39. package/dist/tools/advise/avp-rules.d.ts.map +1 -0
  40. package/dist/tools/advise/avp-rules.js +59 -0
  41. package/dist/tools/advise/avp-rules.js.map +1 -0
  42. package/dist/tools/advise/cedar-patterns.d.ts +24 -0
  43. package/dist/tools/advise/cedar-patterns.d.ts.map +1 -0
  44. package/dist/tools/advise/cedar-patterns.js +57 -0
  45. package/dist/tools/advise/cedar-patterns.js.map +1 -0
  46. package/dist/tools/advise/context-builder.d.ts +28 -0
  47. package/dist/tools/advise/context-builder.d.ts.map +1 -0
  48. package/dist/tools/advise/context-builder.js +89 -0
  49. package/dist/tools/advise/context-builder.js.map +1 -0
  50. package/dist/tools/advise/gotchas.d.ts +15 -0
  51. package/dist/tools/advise/gotchas.d.ts.map +1 -0
  52. package/dist/tools/advise/gotchas.js +83 -0
  53. package/dist/tools/advise/gotchas.js.map +1 -0
  54. package/dist/tools/advise.d.ts +96 -0
  55. package/dist/tools/advise.d.ts.map +1 -0
  56. package/dist/tools/advise.js +258 -0
  57. package/dist/tools/advise.js.map +1 -0
  58. package/dist/tools/authorize-batch.d.ts +35 -0
  59. package/dist/tools/authorize-batch.d.ts.map +1 -0
  60. package/dist/tools/authorize-batch.js +262 -0
  61. package/dist/tools/authorize-batch.js.map +1 -0
  62. package/dist/tools/authorize.d.ts +115 -0
  63. package/dist/tools/authorize.d.ts.map +1 -0
  64. package/dist/tools/authorize.js +373 -0
  65. package/dist/tools/authorize.js.map +1 -0
  66. package/dist/tools/check-change.d.ts +19 -0
  67. package/dist/tools/check-change.d.ts.map +1 -0
  68. package/dist/tools/check-change.js +91 -0
  69. package/dist/tools/check-change.js.map +1 -0
  70. package/dist/tools/diff-schema.d.ts +103 -0
  71. package/dist/tools/diff-schema.d.ts.map +1 -0
  72. package/dist/tools/diff-schema.js +379 -0
  73. package/dist/tools/diff-schema.js.map +1 -0
  74. package/dist/tools/diff-stores.d.ts +45 -0
  75. package/dist/tools/diff-stores.d.ts.map +1 -0
  76. package/dist/tools/diff-stores.js +222 -0
  77. package/dist/tools/diff-stores.js.map +1 -0
  78. package/dist/tools/explain.d.ts +80 -0
  79. package/dist/tools/explain.d.ts.map +1 -0
  80. package/dist/tools/explain.js +187 -0
  81. package/dist/tools/explain.js.map +1 -0
  82. package/dist/tools/format.d.ts +11 -0
  83. package/dist/tools/format.d.ts.map +1 -0
  84. package/dist/tools/format.js +20 -0
  85. package/dist/tools/format.js.map +1 -0
  86. package/dist/tools/generate-sample.d.ts +28 -0
  87. package/dist/tools/generate-sample.d.ts.map +1 -0
  88. package/dist/tools/generate-sample.js +568 -0
  89. package/dist/tools/generate-sample.js.map +1 -0
  90. package/dist/tools/link-template.d.ts +17 -0
  91. package/dist/tools/link-template.d.ts.map +1 -0
  92. package/dist/tools/link-template.js +78 -0
  93. package/dist/tools/link-template.js.map +1 -0
  94. package/dist/tools/list-template-links.d.ts +16 -0
  95. package/dist/tools/list-template-links.d.ts.map +1 -0
  96. package/dist/tools/list-template-links.js +22 -0
  97. package/dist/tools/list-template-links.js.map +1 -0
  98. package/dist/tools/list-templates.d.ts +16 -0
  99. package/dist/tools/list-templates.d.ts.map +1 -0
  100. package/dist/tools/list-templates.js +36 -0
  101. package/dist/tools/list-templates.js.map +1 -0
  102. package/dist/tools/translate.d.ts +11 -0
  103. package/dist/tools/translate.d.ts.map +1 -0
  104. package/dist/tools/translate.js +53 -0
  105. package/dist/tools/translate.js.map +1 -0
  106. package/dist/tools/validate-entities.d.ts +19 -0
  107. package/dist/tools/validate-entities.d.ts.map +1 -0
  108. package/dist/tools/validate-entities.js +88 -0
  109. package/dist/tools/validate-entities.js.map +1 -0
  110. package/dist/tools/validate-schema.d.ts +22 -0
  111. package/dist/tools/validate-schema.d.ts.map +1 -0
  112. package/dist/tools/validate-schema.js +89 -0
  113. package/dist/tools/validate-schema.js.map +1 -0
  114. package/dist/tools/validate-template.d.ts +18 -0
  115. package/dist/tools/validate-template.d.ts.map +1 -0
  116. package/dist/tools/validate-template.js +59 -0
  117. package/dist/tools/validate-template.js.map +1 -0
  118. package/dist/tools/validate.d.ts +90 -0
  119. package/dist/tools/validate.d.ts.map +1 -0
  120. package/dist/tools/validate.js +351 -0
  121. package/dist/tools/validate.js.map +1 -0
  122. package/dist/utils/format-detector.d.ts +49 -0
  123. package/dist/utils/format-detector.d.ts.map +1 -0
  124. package/dist/utils/format-detector.js +298 -0
  125. package/dist/utils/format-detector.js.map +1 -0
  126. package/examples/README.md +36 -0
  127. package/examples/abac-multi-tenant/README.md +150 -0
  128. package/examples/abac-multi-tenant/entities/users-and-docs.json +33 -0
  129. package/examples/abac-multi-tenant/policies/member-read-internal.cedar +9 -0
  130. package/examples/abac-multi-tenant/policies/owner-full-access.cedar +9 -0
  131. package/examples/abac-multi-tenant/policies/premium-share-guard.cedar +9 -0
  132. package/examples/abac-multi-tenant/policies/private-doc-guard.cedar +13 -0
  133. package/examples/abac-multi-tenant/run.ts +92 -0
  134. package/examples/abac-multi-tenant/schema.json +60 -0
  135. package/examples/api-gateway-path-routing/README.md +154 -0
  136. package/examples/api-gateway-path-routing/entities/users-and-roles.json +20 -0
  137. package/examples/api-gateway-path-routing/policies/admin-full-access.cedar +6 -0
  138. package/examples/api-gateway-path-routing/policies/developer-projects.cedar +14 -0
  139. package/examples/api-gateway-path-routing/policies/viewer-readonly.cedar +10 -0
  140. package/examples/api-gateway-path-routing/run.ts +108 -0
  141. package/examples/api-gateway-path-routing/schema.json +54 -0
  142. package/examples/rbac-document-management/README.md +167 -0
  143. package/examples/rbac-document-management/entities/users-and-docs.json +43 -0
  144. package/examples/rbac-document-management/policies/admin.cedar +6 -0
  145. package/examples/rbac-document-management/policies/editor.cedar +6 -0
  146. package/examples/rbac-document-management/policies/top-secret-forbid.cedar +13 -0
  147. package/examples/rbac-document-management/policies/viewer.cedar +6 -0
  148. package/examples/rbac-document-management/run.ts +87 -0
  149. package/examples/rbac-document-management/schema.json +57 -0
  150. package/package.json +50 -0
  151. package/src/http-server.ts +239 -0
  152. package/src/index.ts +294 -0
  153. package/src/parser/policy-ast.ts +345 -0
  154. package/src/prompts/README.md +3 -0
  155. package/src/prompts/index.ts +217 -0
  156. package/src/resources/ref-resolver.ts +134 -0
  157. package/src/resources/store-manager.ts +248 -0
  158. package/src/server.ts +711 -0
  159. package/src/tools/advise/avp-rules.ts +70 -0
  160. package/src/tools/advise/cedar-patterns.ts +73 -0
  161. package/src/tools/advise/context-builder.ts +109 -0
  162. package/src/tools/advise/gotchas.ts +92 -0
  163. package/src/tools/advise.ts +366 -0
  164. package/src/tools/authorize-batch.ts +345 -0
  165. package/src/tools/authorize.ts +464 -0
  166. package/src/tools/check-change.ts +119 -0
  167. package/src/tools/diff-schema.ts +510 -0
  168. package/src/tools/diff-stores.ts +298 -0
  169. package/src/tools/explain.ts +278 -0
  170. package/src/tools/format.ts +33 -0
  171. package/src/tools/generate-sample.ts +665 -0
  172. package/src/tools/link-template.ts +109 -0
  173. package/src/tools/list-template-links.ts +41 -0
  174. package/src/tools/list-templates.ts +55 -0
  175. package/src/tools/translate.ts +66 -0
  176. package/src/tools/validate-entities.ts +125 -0
  177. package/src/tools/validate-schema.ts +128 -0
  178. package/src/tools/validate-template.ts +72 -0
  179. package/src/tools/validate.ts +459 -0
  180. package/src/utils/format-detector.ts +356 -0
  181. package/test/fixtures/docmgmt.ts +121 -0
  182. package/test/fixtures/multitenant.ts +163 -0
  183. package/test/index.test.ts +96 -0
  184. package/test/integration/e2e/behavior.test.ts +359 -0
  185. package/test/integration/e2e/edge-cases.test.ts +365 -0
  186. package/test/integration/e2e/failure-modes.test.ts +266 -0
  187. package/test/integration/e2e/protocol.test.ts +252 -0
  188. package/test/integration/http-smoke.test.ts +588 -0
  189. package/test/integration/smoke.test.ts +475 -0
  190. package/test/prompts/prompts.test.ts +173 -0
  191. package/test/property/properties.test.ts +234 -0
  192. package/test/resources/ref-resolver.test.ts +186 -0
  193. package/test/resources/store-manager.test.ts +344 -0
  194. package/test/setup.test.ts +7 -0
  195. package/test/tools/advise/avp-rules.test.ts +76 -0
  196. package/test/tools/advise.test.ts +339 -0
  197. package/test/tools/authorize-batch.test.ts +459 -0
  198. package/test/tools/authorize.test.ts +682 -0
  199. package/test/tools/check-change.test.ts +104 -0
  200. package/test/tools/cross-fixture.test.ts +170 -0
  201. package/test/tools/diff-schema.test.ts +355 -0
  202. package/test/tools/diff-stores.test.ts +291 -0
  203. package/test/tools/explain.test.ts +221 -0
  204. package/test/tools/format.test.ts +33 -0
  205. package/test/tools/generate-sample.test.ts +480 -0
  206. package/test/tools/link-template.test.ts +90 -0
  207. package/test/tools/list-templates.test.ts +151 -0
  208. package/test/tools/translate.test.ts +89 -0
  209. package/test/tools/validate-entities.test.ts +178 -0
  210. package/test/tools/validate-schema.test.ts +86 -0
  211. package/test/tools/validate-template.test.ts +89 -0
  212. package/test/tools/validate.test.ts +331 -0
  213. package/test/utils/format-detector.test.ts +518 -0
  214. package/tsconfig.json +17 -0
  215. package/vitest.config.ts +13 -0
@@ -0,0 +1,365 @@
1
+ /**
2
+ * E2E layer 4: edge cases.
3
+ *
4
+ * Boundary inputs that exercise unusual but VALID parts of the Cedar surface.
5
+ * Failure-mode coverage (inputs the server should reject cleanly) lives in
6
+ * failure-modes.test.ts.
7
+ *
8
+ * Each test states the boundary it probes and the failure case it would catch.
9
+ *
10
+ * Run: npx vitest run test/integration/e2e/edge-cases
11
+ */
12
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
13
+ import { join } from "node:path";
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
16
+
17
+ const repoRoot = join(import.meta.dirname, "../../..");
18
+
19
+ function makeStdioClient(): { client: Client; transport: StdioClientTransport } {
20
+ const transport = new StdioClientTransport({
21
+ command: "npx",
22
+ args: ["tsx", "src/index.ts"],
23
+ cwd: repoRoot,
24
+ stderr: "pipe",
25
+ });
26
+ const client = new Client(
27
+ { name: "e2e-edge", version: "1.0.0" },
28
+ { capabilities: {} }
29
+ );
30
+ return { client, transport };
31
+ }
32
+
33
+ function parseToolResult(result: unknown): unknown {
34
+ const r = result as { content?: Array<{ type: string; text?: string }> };
35
+ const textBlock = r.content?.find((b) => b.type === "text");
36
+ if (!textBlock?.text) throw new Error("No text content in tool result");
37
+ return JSON.parse(textBlock.text);
38
+ }
39
+
40
+ const MINI_SCHEMA = JSON.stringify({
41
+ App: {
42
+ entityTypes: {
43
+ User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
44
+ Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
45
+ },
46
+ actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
47
+ },
48
+ });
49
+
50
+ describe("e2e edge cases", () => {
51
+ let client: Client | undefined;
52
+ let transport: StdioClientTransport | undefined;
53
+
54
+ beforeEach(async () => {
55
+ const conn = makeStdioClient();
56
+ client = conn.client;
57
+ transport = conn.transport;
58
+ await client.connect(transport);
59
+ });
60
+
61
+ afterEach(async () => {
62
+ try { await client?.close(); } catch { /* ignore */ }
63
+ try { await transport?.close(); } catch { /* ignore */ }
64
+ client = undefined;
65
+ transport = undefined;
66
+ });
67
+
68
+ it("EC1 — empty policy set: cedar_validate on whitespace-only text", async () => {
69
+ // Boundary: zero policies, only whitespace. Cedar's parser must accept this
70
+ // as a valid empty policy set (not an error). Failure case: a parser that
71
+ // rejects whitespace-only input would break tools that diff an empty store
72
+ // against a populated one.
73
+ const result = parseToolResult(
74
+ await client!.callTool({ name: "cedar_validate", arguments: { policies: " \n\t ", schema: MINI_SCHEMA } })
75
+ ) as { valid: boolean; policy_count: number };
76
+ expect(result.valid).toBe(true);
77
+ expect(result.policy_count).toBe(0);
78
+ }, 20_000);
79
+
80
+ it("EC2 — single-policy set with no when/unless clauses", async () => {
81
+ // Boundary: a permit with bare scope, no conditions. The simplest possible
82
+ // non-empty policy. Failure case: a parser that requires at least one
83
+ // condition clause would reject this even though Cedar accepts it.
84
+ const policy = `permit (principal, action, resource);`;
85
+ const result = parseToolResult(
86
+ await client!.callTool({ name: "cedar_validate", arguments: { policies: policy, schema: MINI_SCHEMA } })
87
+ ) as { valid: boolean; policy_count: number };
88
+ expect(result.valid).toBe(true);
89
+ expect(result.policy_count).toBe(1);
90
+ }, 20_000);
91
+
92
+ it("EC3 — forbid with unless guard (the inverse-permission pattern)", async () => {
93
+ // Boundary: the classic 'top_secret unless admin' pattern. Tests that
94
+ // forbid + unless composes correctly. Failure case: a generator/validator
95
+ // that drops the unless clause would change the policy semantics.
96
+ const policy = `forbid (principal, action, resource)
97
+ when { resource has classification && resource.classification == "top_secret" }
98
+ unless { principal in App::Role::"admin" };`;
99
+ const schemaWithClassification = JSON.stringify({
100
+ App: {
101
+ entityTypes: {
102
+ User: { memberOfTypes: ["Role"], shape: { type: "Record", attributes: {} } },
103
+ Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
104
+ Doc: { memberOfTypes: [], shape: { type: "Record", attributes: { classification: { type: "String", required: false } } } },
105
+ },
106
+ actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
107
+ },
108
+ });
109
+ const result = parseToolResult(
110
+ await client!.callTool({ name: "cedar_validate", arguments: { policies: policy, schema: schemaWithClassification } })
111
+ ) as { valid: boolean };
112
+ expect(result.valid).toBe(true);
113
+ }, 20_000);
114
+
115
+ it("EC4 — very long entity id (1000 characters) is accepted", async () => {
116
+ // Boundary: entity IDs near the upper end of practical use. Cedar's grammar
117
+ // allows arbitrary string content in entity IDs; large IDs should not
118
+ // crash the validator or the formatter.
119
+ const longId = "x".repeat(1000);
120
+ const policy = `permit (principal == App::User::"${longId}", action, resource);`;
121
+ const result = parseToolResult(
122
+ await client!.callTool({ name: "cedar_validate", arguments: { policies: policy, schema: MINI_SCHEMA } })
123
+ ) as { valid: boolean };
124
+ expect(result.valid).toBe(true);
125
+ }, 20_000);
126
+
127
+ it("EC5 — Unicode in attribute string values (validate + authorize)", async () => {
128
+ // Boundary: non-ASCII string values in entity attributes. Cedar strings are
129
+ // Unicode; the WASM boundary must handle UTF-8 without mangling.
130
+ // Failure case: a transport layer treating the body as Latin-1 would corrupt
131
+ // multi-byte characters.
132
+ const entities = JSON.stringify([
133
+ { uid: { type: "App::User", id: "u1" }, attrs: { name: "Ælfred Ø'Hára 日本語 🦀" }, parents: [] },
134
+ { uid: { type: "App::Doc", id: "d1" }, attrs: {}, parents: [] },
135
+ ]);
136
+ const result = parseToolResult(
137
+ await client!.callTool({
138
+ name: "cedar_authorize",
139
+ arguments: {
140
+ policies: `permit (principal, action, resource) when { principal.name == "Ælfred Ø'Hára 日本語 🦀" };`,
141
+ principal: 'App::User::"u1"',
142
+ action: 'App::Action::"read"',
143
+ resource: 'App::Doc::"d1"',
144
+ entities,
145
+ schema: MINI_SCHEMA,
146
+ },
147
+ })
148
+ ) as { decision: string };
149
+ expect(result.decision).toBe("Allow");
150
+ }, 20_000);
151
+
152
+ it("EC6 — cedar_authorize_batch on an empty requests array", async () => {
153
+ // Boundary: zero requests. Should return total: 0, allowed: 0, denied: 0,
154
+ // errored: 0 with a non-empty summary. Failure case: a divide-by-zero or
155
+ // off-by-one in the summary computation.
156
+ const result = parseToolResult(
157
+ await client!.callTool({
158
+ name: "cedar_authorize_batch",
159
+ arguments: {
160
+ policies: `permit (principal, action, resource);`,
161
+ schema: MINI_SCHEMA,
162
+ requests: "[]",
163
+ },
164
+ })
165
+ ) as { total: number; allowed: number; denied: number; errored: number; summary: string };
166
+ expect(result.total).toBe(0);
167
+ expect(result.allowed).toBe(0);
168
+ expect(result.denied).toBe(0);
169
+ expect(result.errored).toBe(0);
170
+ expect(result.summary.length).toBeGreaterThan(0);
171
+ }, 20_000);
172
+
173
+ it("EC7 — cedar_diff_schema across entirely different namespaces", async () => {
174
+ // Boundary: blue and green share no namespaces. Diff should report blue's
175
+ // namespace as removed and green's as added, with all entity_types and
176
+ // actions classified accordingly. Failure case: a diff that only iterates
177
+ // shared namespaces would miss this entirely.
178
+ const blueSchema = JSON.stringify({ Foo: { entityTypes: { A: { memberOfTypes: [], shape: { type: "Record", attributes: {} } } }, actions: {} } });
179
+ const greenSchema = JSON.stringify({ Bar: { entityTypes: { B: { memberOfTypes: [], shape: { type: "Record", attributes: {} } } }, actions: {} } });
180
+ const result = parseToolResult(
181
+ await client!.callTool({ name: "cedar_diff_schema", arguments: { blue: blueSchema, green: greenSchema } })
182
+ ) as {
183
+ namespaces_added: string[];
184
+ namespaces_removed: string[];
185
+ entity_types: { added: Array<{ namespace: string; name: string }>; removed: Array<{ namespace: string; name: string }> };
186
+ };
187
+ expect(result.namespaces_added).toContain("Bar");
188
+ expect(result.namespaces_removed).toContain("Foo");
189
+ expect(result.entity_types.added.find((e) => e.name === "B")).toBeDefined();
190
+ expect(result.entity_types.removed.find((e) => e.name === "A")).toBeDefined();
191
+ }, 20_000);
192
+
193
+ it("EC8 — template with only ?principal slot (no ?resource slot)", async () => {
194
+ // Boundary: a one-slot template. cedar_validate_template should detect
195
+ // exactly one slot; cedar_link_template should accept just the principal arg.
196
+ // Failure case: a template handler that requires both slots would reject
197
+ // the link or produce malformed output.
198
+ const template = `permit (principal == ?principal, action, resource);`;
199
+ const validateResult = parseToolResult(
200
+ await client!.callTool({ name: "cedar_validate_template", arguments: { template, schema: MINI_SCHEMA } })
201
+ ) as { valid: boolean; slots?: string[]; detected_slots?: string[] };
202
+ expect(validateResult.valid).toBe(true);
203
+
204
+ const linkResult = parseToolResult(
205
+ await client!.callTool({
206
+ name: "cedar_link_template",
207
+ arguments: { template, principal: 'App::User::"alice"', schema: MINI_SCHEMA },
208
+ })
209
+ ) as { linked_policy?: string; policy?: string };
210
+ const linked = linkResult.linked_policy ?? linkResult.policy;
211
+ expect(linked).toBeTruthy();
212
+ expect(linked).toContain('App::User::"alice"');
213
+ // The ?principal slot should be substituted; no remaining placeholder.
214
+ expect(linked).not.toContain("?principal");
215
+ }, 20_000);
216
+
217
+ it("EC9 — cedar_validate_entities accepts a deeply-nested record attribute", async () => {
218
+ // Boundary: a Record attribute containing another Record. Cedar supports
219
+ // arbitrary nesting; the entities validator must walk recursively.
220
+ // Failure case: a non-recursive validator that drops at depth 2.
221
+ const schema = JSON.stringify({
222
+ App: {
223
+ entityTypes: {
224
+ User: {
225
+ memberOfTypes: [],
226
+ shape: {
227
+ type: "Record",
228
+ attributes: {
229
+ profile: {
230
+ type: "Record",
231
+ required: true,
232
+ attributes: {
233
+ name: { type: "String", required: true },
234
+ address: {
235
+ type: "Record",
236
+ required: true,
237
+ attributes: { city: { type: "String", required: true } },
238
+ },
239
+ },
240
+ },
241
+ },
242
+ },
243
+ },
244
+ },
245
+ actions: {},
246
+ },
247
+ });
248
+ const entities = JSON.stringify([
249
+ {
250
+ uid: { type: "App::User", id: "u1" },
251
+ attrs: {
252
+ profile: {
253
+ name: "Alice",
254
+ address: { city: "Wroclaw" },
255
+ },
256
+ },
257
+ parents: [],
258
+ },
259
+ ]);
260
+ const result = parseToolResult(
261
+ await client!.callTool({ name: "cedar_validate_entities", arguments: { entities, schema } })
262
+ ) as { valid: boolean; errors: unknown[] };
263
+ expect(result.valid).toBe(true);
264
+ expect(result.errors).toHaveLength(0);
265
+ }, 20_000);
266
+
267
+ it("EC10 — cedar_authorize_batch with mixed Allow/Deny/Error outcomes in one batch", async () => {
268
+ // Boundary: a batch where SOME requests succeed, SOME deny, and SOME error
269
+ // out due to malformed entities. The batch must process all of them and
270
+ // surface each in its respective category. Failure case: a batch that
271
+ // aborts on the first error, losing visibility of later requests.
272
+ const policy = `permit (principal in App::Role::"admin", action, resource);`;
273
+ const schema = JSON.stringify({
274
+ App: {
275
+ entityTypes: {
276
+ User: { memberOfTypes: ["Role"], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
277
+ Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
278
+ Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
279
+ },
280
+ actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
281
+ },
282
+ });
283
+ const requests = JSON.stringify([
284
+ {
285
+ principal: 'App::User::"alice"',
286
+ action: 'App::Action::"read"',
287
+ resource: 'App::Doc::"d1"',
288
+ entities: JSON.stringify([
289
+ { uid: { type: "App::User", id: "alice" }, attrs: { name: "Alice" }, parents: [{ type: "App::Role", id: "admin" }] },
290
+ { uid: { type: "App::Role", id: "admin" }, attrs: {}, parents: [] },
291
+ { uid: { type: "App::Doc", id: "d1" }, attrs: {}, parents: [] },
292
+ ]),
293
+ },
294
+ {
295
+ principal: 'App::User::"bob"',
296
+ action: 'App::Action::"read"',
297
+ resource: 'App::Doc::"d1"',
298
+ entities: JSON.stringify([
299
+ { uid: { type: "App::User", id: "bob" }, attrs: { name: "Bob" }, parents: [] },
300
+ { uid: { type: "App::Doc", id: "d1" }, attrs: {}, parents: [] },
301
+ ]),
302
+ },
303
+ {
304
+ principal: 'App::User::"carol"',
305
+ action: 'App::Action::"read"',
306
+ resource: 'App::Doc::"d1"',
307
+ entities: "{not valid json", // malformed entities → Error
308
+ },
309
+ ]);
310
+ const result = parseToolResult(
311
+ await client!.callTool({ name: "cedar_authorize_batch", arguments: { policies: policy, schema, requests } })
312
+ ) as {
313
+ total: number;
314
+ allowed: number;
315
+ denied: number;
316
+ errored: number;
317
+ decisions: Array<{ index: number; principal: string; action: string; resource: string; decision: string }>;
318
+ };
319
+
320
+ expect(result.total).toBe(3);
321
+ expect(result.allowed).toBe(1);
322
+ expect(result.denied + result.errored).toBe(2); // bob denies; carol errors
323
+
324
+ // Order invariant: decisions[i] MUST correspond to request[i]. A batch that
325
+ // parallelized and returned results out-of-order would still pass a loose
326
+ // "outcomes match the multiset" check, hiding the bug. Assert position +
327
+ // principal + action + resource per index.
328
+ expect(result.decisions).toHaveLength(3);
329
+ expect(result.decisions[0]!.index).toBe(0);
330
+ expect(result.decisions[0]!.principal).toBe('App::User::"alice"');
331
+ expect(result.decisions[0]!.decision).toBe("Allow");
332
+ expect(result.decisions[1]!.index).toBe(1);
333
+ expect(result.decisions[1]!.principal).toBe('App::User::"bob"');
334
+ expect(result.decisions[1]!.decision).toBe("Deny");
335
+ expect(result.decisions[2]!.index).toBe(2);
336
+ expect(result.decisions[2]!.principal).toBe('App::User::"carol"');
337
+ expect(result.decisions[2]!.decision).toBe("Error");
338
+ }, 30_000);
339
+
340
+ it("EC11 — policy_count handles 100 policies in a single text block", async () => {
341
+ // Boundary: a large policy set passed as one text blob. Cedar's policy set
342
+ // parser must scale linearly with input size, not blow up.
343
+ // Failure case: O(n²) parser that hangs on 100 policies.
344
+ const policies = Array.from({ length: 100 }, (_, i) =>
345
+ `permit (principal in App::Role::"role-${i}", action, resource);`
346
+ ).join("\n\n");
347
+ const result = parseToolResult(
348
+ await client!.callTool({
349
+ name: "cedar_validate",
350
+ arguments: { policies, schema: JSON.stringify({
351
+ App: {
352
+ entityTypes: {
353
+ User: { memberOfTypes: ["Role"], shape: { type: "Record", attributes: {} } },
354
+ Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
355
+ Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
356
+ },
357
+ actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
358
+ },
359
+ })},
360
+ })
361
+ ) as { valid: boolean; policy_count: number };
362
+ expect(result.valid).toBe(true);
363
+ expect(result.policy_count).toBe(100);
364
+ }, 20_000);
365
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * E2E layer 5: failure modes.
3
+ *
4
+ * Inputs the server MUST reject cleanly — with a structured error, never with
5
+ * a crash, hang, or silent success. Edge cases (boundary-but-valid inputs)
6
+ * live in edge-cases.test.ts.
7
+ *
8
+ * Each test states the bad input + the expected clean-error shape.
9
+ *
10
+ * Run: npx vitest run test/integration/e2e/failure-modes
11
+ */
12
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
13
+ import { join } from "node:path";
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
16
+
17
+ const repoRoot = join(import.meta.dirname, "../../..");
18
+
19
+ function makeStdioClient(): { client: Client; transport: StdioClientTransport } {
20
+ const transport = new StdioClientTransport({
21
+ command: "npx",
22
+ args: ["tsx", "src/index.ts"],
23
+ cwd: repoRoot,
24
+ stderr: "pipe",
25
+ });
26
+ const client = new Client(
27
+ { name: "e2e-failure", version: "1.0.0" },
28
+ { capabilities: {} }
29
+ );
30
+ return { client, transport };
31
+ }
32
+
33
+ function parseToolResult(result: unknown): unknown {
34
+ const r = result as { content?: Array<{ type: string; text?: string }> };
35
+ const textBlock = r.content?.find((b) => b.type === "text");
36
+ if (!textBlock?.text) throw new Error("No text content in tool result");
37
+ return JSON.parse(textBlock.text);
38
+ }
39
+
40
+ const MINI_SCHEMA = JSON.stringify({
41
+ App: {
42
+ entityTypes: {
43
+ User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
44
+ Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
45
+ },
46
+ actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
47
+ },
48
+ });
49
+
50
+ describe("e2e failure modes", () => {
51
+ let client: Client | undefined;
52
+ let transport: StdioClientTransport | undefined;
53
+
54
+ beforeEach(async () => {
55
+ const conn = makeStdioClient();
56
+ client = conn.client;
57
+ transport = conn.transport;
58
+ await client.connect(transport);
59
+ });
60
+
61
+ afterEach(async () => {
62
+ try { await client?.close(); } catch { /* ignore */ }
63
+ try { await transport?.close(); } catch { /* ignore */ }
64
+ client = undefined;
65
+ transport = undefined;
66
+ });
67
+
68
+ it("F1 — malformed entities JSON returns parse_error, not a crash", async () => {
69
+ // Bad input: 'not valid {{ json' as the entities string. cedar_validate_entities
70
+ // must catch the JSON.parse failure and return error_kind: 'parse_error'.
71
+ const result = parseToolResult(
72
+ await client!.callTool({
73
+ name: "cedar_validate_entities",
74
+ arguments: { entities: "not valid {{ json", schema: MINI_SCHEMA },
75
+ })
76
+ ) as { valid: boolean; errors: Array<{ error_kind: string; message: string }> };
77
+ expect(result.valid).toBe(false);
78
+ expect(result.errors[0]?.error_kind).toBe("parse_error");
79
+ expect(result.errors[0]?.message.length).toBeGreaterThan(0);
80
+ }, 20_000);
81
+
82
+ it("F2 — invalid Cedar syntax: source-location-tagged error from cedar_validate", async () => {
83
+ // Bad input: 'permit (broken oh no'. The parser must return valid:false with
84
+ // a structured error message (NOT throw). Source location is a bonus but not
85
+ // strictly asserted because exact offsets are SDK-specific.
86
+ const result = parseToolResult(
87
+ await client!.callTool({
88
+ name: "cedar_validate",
89
+ arguments: { policies: "permit (broken oh no", schema: MINI_SCHEMA },
90
+ })
91
+ ) as { valid: boolean; errors: Array<{ message: string }> };
92
+ expect(result.valid).toBe(false);
93
+ expect(result.errors.length).toBeGreaterThan(0);
94
+ expect(result.errors[0].message.length).toBeGreaterThan(0);
95
+ }, 20_000);
96
+
97
+ it("F3 — malformed schema in cedar_validate_schema returns valid:false with source location", async () => {
98
+ // Bad input: a cedarschema text with a missing colon. Validator must return
99
+ // valid:false with at least one error.
100
+ const result = parseToolResult(
101
+ await client!.callTool({
102
+ name: "cedar_validate_schema",
103
+ arguments: { schema: "namespace App { entity User { name String } }" },
104
+ })
105
+ ) as { valid: boolean; errors: Array<{ message: string; source_location?: unknown }> };
106
+ expect(result.valid).toBe(false);
107
+ expect(result.errors.length).toBeGreaterThan(0);
108
+ }, 20_000);
109
+
110
+ it("F4 — path traversal in policy id (cedar://policies/x/../escape) is rejected", async () => {
111
+ // Bad input: a cedar:// URI containing '..'. The resource handler must
112
+ // reject (no filesystem escape). The error body must surface the rejection
113
+ // structurally, not via a 500 or a successful read of an unintended file.
114
+ const result = await client!.readResource({ uri: "cedar://policies/staging/..%2Fescape" });
115
+ expect(result.contents.length).toBeGreaterThan(0);
116
+ const body = result.contents[0]!;
117
+ const text = typeof body.text === "string" ? body.text : "";
118
+ const parsed = JSON.parse(text);
119
+ expect(parsed.error).toBeDefined();
120
+ }, 20_000);
121
+
122
+ it("F5 — cedar:// URI to a non-existent store returns a clean structured error", async () => {
123
+ // Bad input: a syntactically-valid cedar:// URI pointing at a store that's
124
+ // not configured (no MCP roots). resource read must return a JSON error
125
+ // body, not a transport-level fault.
126
+ const result = await client!.readResource({ uri: "cedar://policies/this-store-does-not-exist/admin" });
127
+ const text = typeof result.contents[0]?.text === "string" ? result.contents[0]!.text! : "";
128
+ const parsed = JSON.parse(text);
129
+ expect(parsed.error).toBeDefined();
130
+ expect(typeof parsed.error).toBe("string");
131
+ expect(parsed.error.length).toBeGreaterThan(0);
132
+ }, 20_000);
133
+
134
+ it("F6 — cedar_authorize with neither policies nor policy_ref returns 'one is required' error", async () => {
135
+ // Bad input: omit both inline policies AND policy_ref. The server wrapper
136
+ // must catch this before passing nothing to the handler.
137
+ const result = await client!.callTool({
138
+ name: "cedar_authorize",
139
+ arguments: {
140
+ // intentionally omitting policies AND policy_ref
141
+ principal: 'App::User::"alice"',
142
+ action: 'App::Action::"read"',
143
+ resource: 'App::Doc::"d1"',
144
+ entities: "[]",
145
+ },
146
+ }) as { content: Array<{ type: string; text?: string }>; isError?: boolean };
147
+ const textBlock = result.content.find((b) => b.type === "text");
148
+ const parsed = JSON.parse(textBlock!.text!);
149
+ expect(parsed.error).toBeDefined();
150
+ expect(parsed.error).toMatch(/polic.*required|required.*polic/i);
151
+ }, 20_000);
152
+
153
+ it("F7 — cedar_diff_schema with a malformed blue schema sets schema_diff.error", async () => {
154
+ // Bad input: blue is unparseable Cedar text. The diff must surface this via
155
+ // the top-level error field on SchemaDiff — not abort the call or return
156
+ // misleading "no changes" for two unparseable inputs.
157
+ const result = parseToolResult(
158
+ await client!.callTool({
159
+ name: "cedar_diff_schema",
160
+ arguments: { blue: "this is not a schema", green: MINI_SCHEMA },
161
+ })
162
+ ) as { error?: string; risk_level: string };
163
+ expect(result.error).toBeDefined();
164
+ expect(result.error!.length).toBeGreaterThan(0);
165
+ }, 20_000);
166
+
167
+ it("F8 — cedar_authorize_batch with non-array requests JSON returns clear error", async () => {
168
+ // Bad input: requests is a JSON OBJECT, not an array. The handler must catch
169
+ // the type mismatch at parse time and return a structured error (not
170
+ // attempt to iterate undefined or throw at the WASM boundary).
171
+ const result = parseToolResult(
172
+ await client!.callTool({
173
+ name: "cedar_authorize_batch",
174
+ arguments: {
175
+ policies: `permit (principal, action, resource);`,
176
+ schema: MINI_SCHEMA,
177
+ requests: JSON.stringify({ not: "an array" }),
178
+ },
179
+ })
180
+ ) as { total?: number; error?: string; summary?: string };
181
+ // The handler may report this as either total:0+errored:0 with a clear
182
+ // summary, or as a top-level error field. Accept either shape; the key
183
+ // invariant is "no crash, structured response, easy to debug".
184
+ const surfaced =
185
+ (typeof result.error === "string" && result.error.length > 0) ||
186
+ (typeof result.summary === "string" && /array|object|not.*valid/i.test(result.summary));
187
+ expect(surfaced).toBe(true);
188
+ }, 20_000);
189
+
190
+ it("F9 — entity with type-incompatible attribute is flagged by cedar_validate_entities", async () => {
191
+ // Bad input: User.name is required String, entity has it as a number.
192
+ // cedar_validate_entities must classify this as type_mismatch with the
193
+ // attribute name captured.
194
+ const result = parseToolResult(
195
+ await client!.callTool({
196
+ name: "cedar_validate_entities",
197
+ arguments: {
198
+ entities: JSON.stringify([
199
+ { uid: { type: "App::User", id: "alice" }, attrs: { name: 42 }, parents: [] },
200
+ ]),
201
+ schema: MINI_SCHEMA,
202
+ },
203
+ })
204
+ ) as { valid: boolean; errors: Array<{ error_kind: string; attribute?: string }> };
205
+ expect(result.valid).toBe(false);
206
+ expect(result.errors[0].error_kind).toBe("type_mismatch");
207
+ expect(result.errors[0].attribute).toBe("name");
208
+ }, 20_000);
209
+
210
+ it("F10 — sequential connect/disconnect/reconnect doesn't accumulate state", async () => {
211
+ // Close the auto-connected client, open a brand-new one, verify it works.
212
+ // Failure case: a stdio process that doesn't clean up between sessions
213
+ // would either hang the second connect or surface stale state.
214
+ await client!.close();
215
+ await transport!.close();
216
+
217
+ const conn2 = makeStdioClient();
218
+ client = conn2.client;
219
+ transport = conn2.transport;
220
+ await client.connect(transport);
221
+
222
+ const result = parseToolResult(
223
+ await client.callTool({
224
+ name: "cedar_validate",
225
+ arguments: { policies: `permit (principal, action, resource);`, schema: MINI_SCHEMA },
226
+ })
227
+ ) as { valid: boolean };
228
+ expect(result.valid).toBe(true);
229
+ }, 30_000);
230
+
231
+ it("F11 — in-flight requests get their own correlated responses (stdio ID routing)", async () => {
232
+ // What this actually tests: stdio MCP is wire-serial (JSON-RPC over stdin/stdout
233
+ // is fundamentally one message at a time), but the server can have multiple
234
+ // in-flight Promises waiting on WASM calls. The MCP transport correlates each
235
+ // response to its request ID. Failure case: a server that mismanages its
236
+ // pending-response queue could resolve one caller's promise with another's
237
+ // result. We launch 8 requests via Promise.all and assert each is identified
238
+ // by its own role-${i} policy text in the result.
239
+ //
240
+ // True transport-level concurrency (multiple TCP connections / Mcp-Session-Id
241
+ // routing) lives in http-smoke.test.ts H6 — see that test for the parallel-
242
+ // sessions case.
243
+ const calls = Array.from({ length: 8 }, (_, i) =>
244
+ client!.callTool({
245
+ name: "cedar_explain",
246
+ arguments: {
247
+ policy: `permit (principal in App::Role::"role-${i}", action, resource);`,
248
+ },
249
+ })
250
+ );
251
+ const results = await Promise.all(calls);
252
+ for (let i = 0; i < results.length; i++) {
253
+ const parsed = parseToolResult(results[i]!) as {
254
+ policies?: Array<{ summary?: string; cedar_text?: string }>;
255
+ cedar_text?: string;
256
+ summary?: string;
257
+ };
258
+ // cedar_explain returns either a single result or { policies: [...] }.
259
+ // The role-${i} string from the source policy must round-trip into the
260
+ // result body — if response routing swapped two callers, the i-th result
261
+ // would contain role-${j} (j ≠ i).
262
+ const body = JSON.stringify(parsed);
263
+ expect(body, `call ${i} response`).toContain(`role-${i}`);
264
+ }
265
+ }, 60_000);
266
+ });