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,475 @@
1
+ /**
2
+ * Integration smoke test: spawns cedar-mcp-server via stdio and exercises
3
+ * cedar_validate and cedar_authorize through a real MCP client transport.
4
+ *
5
+ * Closes the mock-only test gap from Phase 4. Runs separately from unit tests:
6
+ * npx vitest run test/integration
7
+ *
8
+ * Falls back to library-mode if process spawn proves brittle (see fallback note
9
+ * below). Currently uses real stdio spawn via tsx.
10
+ */
11
+ import { describe, it, expect, afterEach } from "vitest";
12
+ import { basename, join } from "node:path";
13
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
17
+ import { ListRootsRequestSchema, ResourceListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
18
+
19
+ // ─── Dataset 1 fixtures ────────────────────────────────────────────────────────
20
+
21
+ const DOCMGMT_SCHEMA = JSON.stringify({
22
+ DocMgmt: {
23
+ entityTypes: {
24
+ User: {
25
+ memberOfTypes: ["Role"],
26
+ shape: {
27
+ type: "Record",
28
+ attributes: {
29
+ name: { type: "String", required: true },
30
+ email: { type: "String", required: true },
31
+ },
32
+ },
33
+ },
34
+ Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
35
+ Document: {
36
+ memberOfTypes: ["Folder"],
37
+ shape: {
38
+ type: "Record",
39
+ attributes: {
40
+ owner: { type: "String", required: true },
41
+ classification: { type: "String", required: true },
42
+ },
43
+ },
44
+ },
45
+ Folder: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
46
+ },
47
+ actions: {
48
+ read: {
49
+ appliesTo: {
50
+ principalTypes: ["User"],
51
+ resourceTypes: ["Document"],
52
+ context: { type: "Record", attributes: {} },
53
+ },
54
+ memberOf: [],
55
+ },
56
+ write: {
57
+ appliesTo: {
58
+ principalTypes: ["User"],
59
+ resourceTypes: ["Document"],
60
+ context: { type: "Record", attributes: {} },
61
+ },
62
+ memberOf: [],
63
+ },
64
+ delete: {
65
+ appliesTo: {
66
+ principalTypes: ["User"],
67
+ resourceTypes: ["Document"],
68
+ context: { type: "Record", attributes: {} },
69
+ },
70
+ memberOf: [],
71
+ },
72
+ },
73
+ },
74
+ });
75
+
76
+ const ADMIN_POLICY = `permit (
77
+ principal in DocMgmt::Role::"admin",
78
+ action,
79
+ resource
80
+ );`;
81
+
82
+ const EDITOR_POLICY = `permit (
83
+ principal in DocMgmt::Role::"editor",
84
+ action in [DocMgmt::Action::"read", DocMgmt::Action::"write"],
85
+ resource
86
+ );`;
87
+
88
+ // Alice is admin, doc-public is public classification
89
+ const ALICE_ENTITIES = JSON.stringify([
90
+ {
91
+ uid: { type: "DocMgmt::User", id: "alice" },
92
+ attrs: { name: "Alice Smith", email: "alice@example.com" },
93
+ parents: [{ type: "DocMgmt::Role", id: "admin" }],
94
+ },
95
+ { uid: { type: "DocMgmt::Role", id: "admin" }, attrs: {}, parents: [] },
96
+ {
97
+ uid: { type: "DocMgmt::Document", id: "doc-public" },
98
+ attrs: { owner: "alice", classification: "public" },
99
+ parents: [{ type: "DocMgmt::Folder", id: "shared" }],
100
+ },
101
+ { uid: { type: "DocMgmt::Folder", id: "shared" }, attrs: {}, parents: [] },
102
+ ]);
103
+
104
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
105
+
106
+ function makeClient(): { client: Client; transport: StdioClientTransport } {
107
+ const repoRoot = join(import.meta.dirname, "../..");
108
+ const transport = new StdioClientTransport({
109
+ command: "npx",
110
+ args: ["tsx", "src/index.ts"],
111
+ cwd: repoRoot,
112
+ stderr: "pipe",
113
+ });
114
+ const client = new Client(
115
+ { name: "smoke-test-client", version: "1.0.0" },
116
+ { capabilities: {} }
117
+ );
118
+ return { client, transport };
119
+ }
120
+
121
+ function parseToolResult(result: unknown): unknown {
122
+ const r = result as { content?: Array<{ type: string; text?: string }> };
123
+ const textBlock = r.content?.find(b => b.type === "text");
124
+ if (!textBlock?.text) throw new Error("No text content in tool result");
125
+ return JSON.parse(textBlock.text);
126
+ }
127
+
128
+ // ─── Tests ────────────────────────────────────────────────────────────────────
129
+
130
+ describe("integration smoke", () => {
131
+ let client: Client | undefined;
132
+ let transport: StdioClientTransport | undefined;
133
+
134
+ afterEach(async () => {
135
+ try { await client?.close(); } catch { /* ignore */ }
136
+ try { await transport?.close(); } catch { /* ignore */ }
137
+ client = undefined;
138
+ transport = undefined;
139
+ });
140
+
141
+ it("S1 — server lists all 17 tools", async () => {
142
+ const conn = makeClient();
143
+ client = conn.client;
144
+ transport = conn.transport;
145
+ await client.connect(transport);
146
+
147
+ const { tools } = await client.listTools();
148
+ const names = tools.map(t => t.name);
149
+
150
+ expect(names).toContain("cedar_validate");
151
+ expect(names).toContain("cedar_authorize");
152
+ expect(names).toContain("cedar_authorize_batch");
153
+ expect(names).toContain("cedar_format");
154
+ expect(names).toContain("cedar_translate");
155
+ expect(names).toContain("cedar_explain");
156
+ expect(names).toContain("cedar_check_policy_change");
157
+ expect(names).toContain("cedar_generate_sample_request");
158
+ expect(names).toContain("cedar_advise");
159
+ expect(names).toContain("cedar_diff_policy_stores");
160
+ expect(names).toContain("cedar_validate_template");
161
+ expect(names).toContain("cedar_link_template");
162
+ expect(names).toContain("cedar_list_templates");
163
+ expect(names).toContain("cedar_list_template_links");
164
+ expect(names).toContain("cedar_validate_schema");
165
+ expect(names).toContain("cedar_diff_schema");
166
+ expect(names).toContain("cedar_validate_entities");
167
+ expect(names).toHaveLength(17);
168
+ }, 15_000);
169
+
170
+ it("S2 — cedar_validate returns valid:true for correct policy + schema", async () => {
171
+ const conn = makeClient();
172
+ client = conn.client;
173
+ transport = conn.transport;
174
+ await client.connect(transport);
175
+
176
+ const raw = await client.callTool({
177
+ name: "cedar_validate",
178
+ arguments: {
179
+ policies: ADMIN_POLICY + "\n" + EDITOR_POLICY,
180
+ schema: DOCMGMT_SCHEMA,
181
+ },
182
+ });
183
+
184
+ const result = parseToolResult(raw) as { valid: boolean; errors: unknown[]; policy_count: number };
185
+ expect(result.valid).toBe(true);
186
+ expect(result.errors).toHaveLength(0);
187
+ expect(result.policy_count).toBe(2);
188
+ }, 15_000);
189
+
190
+ it("S3 — cedar_authorize returns Allow for alice reading doc-public", async () => {
191
+ const conn = makeClient();
192
+ client = conn.client;
193
+ transport = conn.transport;
194
+ await client.connect(transport);
195
+
196
+ const raw = await client.callTool({
197
+ name: "cedar_authorize",
198
+ arguments: {
199
+ policies: ADMIN_POLICY,
200
+ principal: 'DocMgmt::User::"alice"',
201
+ action: 'DocMgmt::Action::"read"',
202
+ resource: 'DocMgmt::Document::"doc-public"',
203
+ entities: ALICE_ENTITIES,
204
+ schema: DOCMGMT_SCHEMA,
205
+ },
206
+ });
207
+
208
+ const result = parseToolResult(raw) as { decision: string; determining_policies: string[] };
209
+ expect(result.decision).toBe("Allow");
210
+ expect(result.determining_policies).toHaveLength(1);
211
+ }, 15_000);
212
+
213
+ it("S4 — every tool description is non-trivially long and asserts necessity (MUST/ALWAYS/CANNOT/INSUFFICIENT)", async () => {
214
+ const conn = makeClient();
215
+ client = conn.client;
216
+ transport = conn.transport;
217
+ await client.connect(transport);
218
+
219
+ const { tools } = await client.listTools();
220
+ const necessityMarker = /\b(ALWAYS|MUST|CANNOT|INSUFFICIENT|do NOT|Do NOT)\b/;
221
+ const failures: string[] = [];
222
+ for (const tool of tools) {
223
+ const desc = tool.description ?? "";
224
+ if (desc.length <= 100) {
225
+ failures.push(`${tool.name}: description too short (${desc.length} chars)`);
226
+ }
227
+ if (!necessityMarker.test(desc)) {
228
+ failures.push(`${tool.name}: no necessity marker (MUST/ALWAYS/CANNOT/INSUFFICIENT/do NOT) in description`);
229
+ }
230
+ }
231
+ expect(failures, failures.join("\n")).toEqual([]);
232
+ }, 15_000);
233
+
234
+ it("S6 — stdio: client advertising roots populates resources/list with cedar:// URIs (H2 end-to-end)", async () => {
235
+ // Round 3 (2026-05-22) observed `listMcpResources(server: "cedar")` returning empty
236
+ // when Claude Code was used via stdio with no `--root` flag. This test isolates the
237
+ // server-side path: a client that DOES advertise roots via the MCP `roots/list`
238
+ // handler should see the cedar:// resource scheme populated. If this passes, the
239
+ // round-3 empty result is on the client (Claude Code stdio not advertising the
240
+ // workspace as a root), not the server.
241
+
242
+ const sandbox = mkdtempSync(join(tmpdir(), "cedar-stdio-roots-"));
243
+ mkdirSync(join(sandbox, "policies"));
244
+ mkdirSync(join(sandbox, "entities"));
245
+ writeFileSync(join(sandbox, "schema.cedarschema"),
246
+ `namespace DocMgmt {\n entity Role;\n entity User in [Role];\n entity Document;\n action "read" appliesTo { principal: User, resource: Document };\n}\n`);
247
+ writeFileSync(join(sandbox, "policies", "admin.cedar"),
248
+ `permit (principal in DocMgmt::Role::"admin", action, resource);\n`);
249
+ writeFileSync(join(sandbox, "entities", "sample.json"), "[]");
250
+
251
+ const repoRoot = join(import.meta.dirname, "../..");
252
+ const transport = new StdioClientTransport({
253
+ command: "npx",
254
+ args: ["tsx", "src/index.ts"],
255
+ cwd: repoRoot,
256
+ stderr: "pipe",
257
+ });
258
+ // Critical: declare roots capability so the server's `listRoots()` is allowed.
259
+ const rootsClient = new Client(
260
+ { name: "smoke-test-roots-client", version: "1.0.0" },
261
+ { capabilities: { roots: { listChanged: true } } }
262
+ );
263
+ rootsClient.setRequestHandler(ListRootsRequestSchema, async () => ({
264
+ roots: [{ uri: `file://${sandbox}`, name: "stdio-test-store" }],
265
+ }));
266
+ client = rootsClient;
267
+
268
+ try {
269
+ await rootsClient.connect(transport);
270
+
271
+ // The server's `oninitialized` callback fires async after the initialize
272
+ // handshake returns. Trigger it deterministically by sending a
273
+ // notifications/roots/list_changed: index.ts listens for this and
274
+ // re-runs loadRootsStdio, which we can await client-side via the
275
+ // round-trip (the SDK serializes notifications behind subsequent requests).
276
+ await rootsClient.sendRootsListChanged();
277
+
278
+ // Now poll resources/list briefly; the loadRootsStdio handler runs async on
279
+ // the server side after the notification, so allow a short retry window.
280
+ let resources: Array<{ uri: string; name?: string }> = [];
281
+ for (let attempt = 0; attempt < 20; attempt++) {
282
+ const r = await rootsClient.listResources();
283
+ resources = r.resources;
284
+ if (resources.length > 0) break;
285
+ await new Promise((res) => setTimeout(res, 100));
286
+ }
287
+ const uris = resources.map((r) => r.uri);
288
+
289
+ // Per-item resources for our temp store
290
+ expect(uris).toContain("cedar://policies/stdio-test-store/admin");
291
+ expect(uris).toContain("cedar://schema/stdio-test-store");
292
+ expect(uris).toContain("cedar://entities/stdio-test-store/sample");
293
+
294
+ // Index resources
295
+ expect(uris).toContain("cedar://policies/stdio-test-store");
296
+ expect(uris).toContain("cedar://entities/stdio-test-store");
297
+
298
+ expect(uris.length).toBeGreaterThanOrEqual(5);
299
+ } finally {
300
+ try { await rootsClient.close(); } catch { /* ignore */ }
301
+ try { await transport.close(); } catch { /* ignore */ }
302
+ rmSync(sandbox, { recursive: true, force: true });
303
+ }
304
+ }, 30_000);
305
+
306
+ it("S6b — stdio: cwd-fallback notifies resources/list_changed so cache-based clients see the store (Round 4 Scenario E)", async () => {
307
+ // Round 4 (2026-05-22) observed `listMcpResources(server: "cedar")` returning empty
308
+ // when Claude Code stdio launched the server in a Cedar workspace cwd without
309
+ // advertising roots. Root cause is timing: the cwd-fallback path in loadRootsStdio
310
+ // populates StoreManager asynchronously inside `oninitialized`, which runs AFTER
311
+ // the initialize handshake. A client that snapshots `resources/list` once on
312
+ // connect (the standard MCP cache pattern, what Claude Code does) reads it
313
+ // before the store exists, gets empty, and never refetches.
314
+ //
315
+ // The fix: after loadRootsStdio populates StoreManager, the server must emit
316
+ // `notifications/resources/list_changed` so cache-aware clients invalidate and
317
+ // refetch. This test fails until that notification is wired.
318
+
319
+ const sandbox = mkdtempSync(join(tmpdir(), "cedar-stdio-cwd-"));
320
+ mkdirSync(join(sandbox, "policies"));
321
+ mkdirSync(join(sandbox, "entities"));
322
+ writeFileSync(join(sandbox, "schema.cedarschema"),
323
+ `namespace DocMgmt {\n entity Role;\n entity User in [Role];\n entity Document;\n action "read" appliesTo { principal: User, resource: Document };\n}\n`);
324
+ writeFileSync(join(sandbox, "policies", "admin.cedar"),
325
+ `permit (principal in DocMgmt::Role::"admin", action, resource);\n`);
326
+ writeFileSync(join(sandbox, "entities", "sample.json"), "[]");
327
+
328
+ const repoRoot = join(import.meta.dirname, "../..");
329
+ const transport = new StdioClientTransport({
330
+ command: "npx",
331
+ args: ["tsx", join(repoRoot, "src/index.ts")],
332
+ cwd: sandbox, // server's cwd is the workspace, so 10d cwd fallback fires
333
+ stderr: "pipe",
334
+ });
335
+ // Critical: NO roots capability declared. This mirrors a real Claude Code
336
+ // stdio client that does not advertise the workspace as a root. The server
337
+ // must take the cwd-fallback path AND notify list_changed afterwards.
338
+ const noRootsClient = new Client(
339
+ { name: "smoke-test-cwd-client", version: "1.0.0" },
340
+ { capabilities: {} }
341
+ );
342
+ client = noRootsClient;
343
+
344
+ // Track resources/list_changed notifications. Server should emit at least one
345
+ // after the cwd-fallback populates StoreManager.
346
+ let listChangedCount = 0;
347
+ let listChangedReceived: () => void = () => {};
348
+ const listChangedPromise = new Promise<void>((resolve) => { listChangedReceived = resolve; });
349
+ noRootsClient.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
350
+ listChangedCount += 1;
351
+ listChangedReceived();
352
+ });
353
+
354
+ try {
355
+ await noRootsClient.connect(transport);
356
+
357
+ // Snapshot resources immediately. The cwd-fallback runs async in
358
+ // `oninitialized`; this call MAY race ahead of it and return empty.
359
+ // That race is exactly the user-facing bug — empty here is fine as long
360
+ // as the server notifies list_changed and a subsequent fetch returns the
361
+ // populated list.
362
+ await noRootsClient.listResources();
363
+
364
+ // Wait for at least one resources/list_changed notification with a generous
365
+ // timeout. Without the fix this never fires.
366
+ const timeoutMs = 5000;
367
+ await Promise.race([
368
+ listChangedPromise,
369
+ new Promise<void>((_, reject) => setTimeout(() => reject(new Error(
370
+ `Timed out after ${timeoutMs}ms waiting for notifications/resources/list_changed. ` +
371
+ `Server populated StoreManager via cwd-fallback but never told the client to refetch — ` +
372
+ `cache-based clients (e.g. Claude Code) stay stuck on the empty initial snapshot.`,
373
+ )), timeoutMs)),
374
+ ]);
375
+
376
+ expect(listChangedCount).toBeGreaterThanOrEqual(1);
377
+
378
+ // After the notification, a fresh listResources MUST return the populated set.
379
+ const { resources } = await noRootsClient.listResources();
380
+ const uris = resources.map((r) => r.uri);
381
+ const storeName = basename(sandbox);
382
+
383
+ expect(uris).toContain(`cedar://policies/${storeName}/admin`);
384
+ expect(uris).toContain(`cedar://schema/${storeName}`);
385
+ expect(uris).toContain(`cedar://entities/${storeName}/sample`);
386
+ expect(uris).toContain(`cedar://policies/${storeName}`);
387
+ expect(uris).toContain(`cedar://entities/${storeName}`);
388
+ expect(uris.length).toBeGreaterThanOrEqual(5);
389
+ } finally {
390
+ try { await noRootsClient.close(); } catch { /* ignore */ }
391
+ try { await transport.close(); } catch { /* ignore */ }
392
+ rmSync(sandbox, { recursive: true, force: true });
393
+ }
394
+ }, 30_000);
395
+
396
+ it("S6c — stdio: cwd-fallback is loaded synchronously so the VERY FIRST resources/list after initialize is already populated (Round 5 Scenario E fix)", async () => {
397
+ // Round 5 (2026-05-22) ran with kickoff-11 11a's notification path and
398
+ // STILL FAILED Scenario E: Claude Code's `listMcpResources` did not
399
+ // invalidate its cache on `notifications/resources/list_changed`. The
400
+ // spec-correct fix wasn't user-correct. kickoff-12 12a's fix is structural:
401
+ // populate StoreManager synchronously in runStdio BEFORE
402
+ // `await server.connect(transport)`, so by the time the client can send
403
+ // ANY request, the store already exists. This test exercises the new
404
+ // contract directly: NO polling, NO waiting on notifications, the very
405
+ // first listResources after initialize must return the populated store.
406
+
407
+ const sandbox = mkdtempSync(join(tmpdir(), "cedar-stdio-sync-"));
408
+ mkdirSync(join(sandbox, "policies"));
409
+ mkdirSync(join(sandbox, "entities"));
410
+ writeFileSync(join(sandbox, "schema.cedarschema"),
411
+ `namespace DocMgmt {\n entity Role;\n entity User in [Role];\n entity Document;\n action "read" appliesTo { principal: User, resource: Document };\n}\n`);
412
+ writeFileSync(join(sandbox, "policies", "admin.cedar"),
413
+ `permit (principal in DocMgmt::Role::"admin", action, resource);\n`);
414
+ writeFileSync(join(sandbox, "entities", "sample.json"), "[]");
415
+
416
+ const repoRoot = join(import.meta.dirname, "../..");
417
+ const transport = new StdioClientTransport({
418
+ command: "npx",
419
+ args: ["tsx", join(repoRoot, "src/index.ts")],
420
+ cwd: sandbox,
421
+ stderr: "pipe",
422
+ });
423
+ // No roots capability declared. Mirrors Claude Code stdio.
424
+ const noRootsClient = new Client(
425
+ { name: "smoke-test-sync-client", version: "1.0.0" },
426
+ { capabilities: {} }
427
+ );
428
+ client = noRootsClient;
429
+
430
+ try {
431
+ await noRootsClient.connect(transport);
432
+
433
+ // First request after connect. No polling. No notification wait. If
434
+ // the store is empty here, the sync cwd-fallback did not fire before
435
+ // the transport accepted this request — that is the Round 5 bug.
436
+ const { resources } = await noRootsClient.listResources();
437
+ const uris = resources.map((r) => r.uri);
438
+ const storeName = basename(sandbox);
439
+
440
+ expect(uris).toContain(`cedar://policies/${storeName}/admin`);
441
+ expect(uris).toContain(`cedar://schema/${storeName}`);
442
+ expect(uris).toContain(`cedar://entities/${storeName}/sample`);
443
+ expect(uris).toContain(`cedar://policies/${storeName}`);
444
+ expect(uris).toContain(`cedar://entities/${storeName}`);
445
+ expect(uris.length).toBeGreaterThanOrEqual(5);
446
+ } finally {
447
+ try { await noRootsClient.close(); } catch { /* ignore */ }
448
+ try { await transport.close(); } catch { /* ignore */ }
449
+ rmSync(sandbox, { recursive: true, force: true });
450
+ }
451
+ }, 30_000);
452
+
453
+ it("S5 — server returns instructions on initialize: routing table + anti-bypass directive, under 2KB", async () => {
454
+ const conn = makeClient();
455
+ client = conn.client;
456
+ transport = conn.transport;
457
+ await client.connect(transport);
458
+
459
+ const instructions = client.getInstructions();
460
+ expect(instructions, "server instructions are missing — client received none on initialize").toBeDefined();
461
+ const text = instructions!;
462
+ // Stay under the Claude Code 2KB truncation budget with headroom
463
+ expect(text.length).toBeLessThan(2048);
464
+ expect(text.length).toBeGreaterThan(500);
465
+ // Critical guidance must be front-loaded (within the first ~400 chars)
466
+ expect(text.slice(0, 600)).toMatch(/MUST call the appropriate cedar_\* tool/);
467
+ // Routing table includes cedar_advise as the first-call directive for change planning
468
+ expect(text).toMatch(/cedar_advise FIRST/);
469
+ // Anti-bypass directive present
470
+ expect(text).toMatch(/Do NOT use Read or Bash to inspect Cedar policy semantics/);
471
+ // 10d: workspace auto-discovery directive present
472
+ expect(text).toMatch(/Workspace auto-discovery/);
473
+ expect(text).toMatch(/retry with the field omitted/);
474
+ }, 15_000);
475
+ });
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { PROMPT_DEFINITIONS } from "../../src/prompts/index.js";
3
+
4
+ // Minimal stub for the extra argument the SDK passes to handlers.
5
+ // Prompts in this module do not use it, but the signature requires it.
6
+ const EXTRA = {} as Parameters<(typeof PROMPT_DEFINITIONS)[number]["handler"]>[1];
7
+
8
+ const byName = (name: string) => {
9
+ const def = PROMPT_DEFINITIONS.find((p) => p.name === name);
10
+ if (!def) throw new Error(`Prompt "${name}" not found in PROMPT_DEFINITIONS`);
11
+ return def;
12
+ };
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // cedar-review-policy-diff
16
+ // ---------------------------------------------------------------------------
17
+ describe("cedar-review-policy-diff", () => {
18
+ const prompt = byName("cedar-review-policy-diff");
19
+
20
+ it("returns non-empty messages array with required args", () => {
21
+ const result = prompt.handler(
22
+ { blue_store: "blue", green_store: "green" } as Parameters<typeof prompt.handler>[0],
23
+ EXTRA
24
+ );
25
+ expect(result.messages.length).toBeGreaterThan(0);
26
+ });
27
+
28
+ it("assembled text references cedar_diff_policy_stores and both store names", () => {
29
+ const result = prompt.handler(
30
+ { blue_store: "prod", green_store: "staging" } as Parameters<typeof prompt.handler>[0],
31
+ EXTRA
32
+ );
33
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
34
+ expect(text).toContain("cedar_diff_policy_stores");
35
+ expect(text).toContain("prod");
36
+ expect(text).toContain("staging");
37
+ });
38
+
39
+ it("assembled text references cedar://schema URIs for both stores", () => {
40
+ const result = prompt.handler(
41
+ { blue_store: "blue", green_store: "green" } as Parameters<typeof prompt.handler>[0],
42
+ EXTRA
43
+ );
44
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
45
+ expect(text).toContain("cedar://schema/blue");
46
+ expect(text).toContain("cedar://schema/green");
47
+ });
48
+
49
+ it("includes the optional focus note when focus is supplied", () => {
50
+ const result = prompt.handler(
51
+ {
52
+ blue_store: "blue",
53
+ green_store: "green",
54
+ focus: "AVP immutability",
55
+ } as Parameters<typeof prompt.handler>[0],
56
+ EXTRA
57
+ );
58
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
59
+ expect(text).toContain("AVP immutability");
60
+ });
61
+
62
+ it("omits the focus note when focus is not supplied", () => {
63
+ const result = prompt.handler(
64
+ { blue_store: "blue", green_store: "green" } as Parameters<typeof prompt.handler>[0],
65
+ EXTRA
66
+ );
67
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
68
+ expect(text).not.toContain("Focus area for this review:");
69
+ });
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // cedar-explain-denial
74
+ // ---------------------------------------------------------------------------
75
+ describe("cedar-explain-denial", () => {
76
+ const prompt = byName("cedar-explain-denial");
77
+
78
+ it("returns non-empty messages array with required args", () => {
79
+ const result = prompt.handler(
80
+ {
81
+ principal: 'MyApp::User::"alice"',
82
+ action: 'MyApp::Action::"read"',
83
+ resource: 'MyApp::Document::"doc-1"',
84
+ store: "mystore",
85
+ } as Parameters<typeof prompt.handler>[0],
86
+ EXTRA
87
+ );
88
+ expect(result.messages.length).toBeGreaterThan(0);
89
+ });
90
+
91
+ it("assembled text references cedar_authorize and cedar_explain", () => {
92
+ const result = prompt.handler(
93
+ {
94
+ principal: 'MyApp::User::"alice"',
95
+ action: 'MyApp::Action::"read"',
96
+ resource: 'MyApp::Document::"doc-1"',
97
+ store: "mystore",
98
+ } as Parameters<typeof prompt.handler>[0],
99
+ EXTRA
100
+ );
101
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
102
+ expect(text).toContain("cedar_authorize");
103
+ expect(text).toContain("cedar_explain");
104
+ });
105
+
106
+ it("assembled text includes cedar:// URIs for the named store", () => {
107
+ const result = prompt.handler(
108
+ {
109
+ principal: 'MyApp::User::"bob"',
110
+ action: 'MyApp::Action::"write"',
111
+ resource: 'MyApp::Document::"doc-2"',
112
+ store: "prod",
113
+ } as Parameters<typeof prompt.handler>[0],
114
+ EXTRA
115
+ );
116
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
117
+ expect(text).toContain("cedar://policies/prod");
118
+ expect(text).toContain("cedar://schema/prod");
119
+ expect(text).toContain("cedar://entities/prod");
120
+ });
121
+
122
+ it("includes principal, action, and resource values in assembled text", () => {
123
+ const principal = 'Acme::User::"carol"';
124
+ const action = 'Acme::Action::"delete"';
125
+ const resource = 'Acme::File::"file-99"';
126
+ const result = prompt.handler(
127
+ { principal, action, resource, store: "acme" } as Parameters<typeof prompt.handler>[0],
128
+ EXTRA
129
+ );
130
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
131
+ expect(text).toContain(principal);
132
+ expect(text).toContain(action);
133
+ expect(text).toContain(resource);
134
+ });
135
+ });
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // cedar-avp-migration-checklist
139
+ // ---------------------------------------------------------------------------
140
+ describe("cedar-avp-migration-checklist", () => {
141
+ const prompt = byName("cedar-avp-migration-checklist");
142
+
143
+ it("returns non-empty messages array with no args (all optional)", () => {
144
+ const result = prompt.handler({} as Parameters<typeof prompt.handler>[0], EXTRA);
145
+ expect(result.messages.length).toBeGreaterThan(0);
146
+ });
147
+
148
+ it("uses placeholder namespace when none is provided", () => {
149
+ const result = prompt.handler({} as Parameters<typeof prompt.handler>[0], EXTRA);
150
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
151
+ expect(text).toContain("<YourNamespace>");
152
+ });
153
+
154
+ it("substitutes the supplied namespace into the checklist", () => {
155
+ const result = prompt.handler(
156
+ { namespace: "MyApp" } as Parameters<typeof prompt.handler>[0],
157
+ EXTRA
158
+ );
159
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
160
+ expect(text).toContain("MyApp");
161
+ expect(text).not.toContain("<YourNamespace>");
162
+ });
163
+
164
+ it("references all expected Cedar tools in the checklist", () => {
165
+ const result = prompt.handler({} as Parameters<typeof prompt.handler>[0], EXTRA);
166
+ const text = result.messages[0].content.type === "text" ? result.messages[0].content.text : "";
167
+ expect(text).toContain("cedar_validate_schema");
168
+ expect(text).toContain("cedar_validate_entities");
169
+ expect(text).toContain("cedar_link_template");
170
+ expect(text).toContain("cedar_diff_schema");
171
+ expect(text).toContain("cedar_diff_policy_stores");
172
+ });
173
+ });