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
package/src/index.ts ADDED
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
4
+ import { existsSync } from "node:fs";
5
+ import { join, basename } from "node:path";
6
+ import { createServer } from "./server.js";
7
+ import { storeManager } from "./resources/store-manager.js";
8
+ import { startHttpServer } from "./http-server.js";
9
+
10
+ /**
11
+ * A directory "looks like a Cedar workspace" if it has at least one of:
12
+ * - schema.cedarschema (preferred)
13
+ * - schema.json (fallback)
14
+ * - policies/ (per-file policies layout)
15
+ *
16
+ * This is the same convention StoreManager uses to read a loaded root.
17
+ */
18
+ function looksLikeCedarWorkspace(path: string): boolean {
19
+ return (
20
+ existsSync(join(path, "schema.cedarschema")) ||
21
+ existsSync(join(path, "schema.json")) ||
22
+ existsSync(join(path, "policies"))
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Synchronously populate StoreManager with the cwd-fallback store, if the
28
+ * cwd looks like a Cedar workspace. Returns the loaded root descriptor when
29
+ * a store was loaded, or null otherwise.
30
+ *
31
+ * Round 5 dogfood (Scenario E) found that emitting `notifications/resources/list_changed`
32
+ * AFTER an async cwd-fallback (kickoff-11 11a) was insufficient: Claude Code's
33
+ * `listMcpResources` does not honor `list_changed` for cache invalidation, so a
34
+ * client that snapshots `resources/list` once on the initialize response stays
35
+ * stuck on the empty pre-fallback snapshot regardless of any later notification.
36
+ *
37
+ * The fix is structural: populate StoreManager BEFORE the transport accepts
38
+ * any client requests. `process.cwd()` is available at startup; there is no
39
+ * need to wait for the transport. By the time the client can send any
40
+ * request, the store already exists.
41
+ *
42
+ * Security: rejects filesystem-root cwds (`/`) and any cwd whose basename is
43
+ * empty after normalization. Without this guard, the cwd-fallback would push
44
+ * an empty-path root into StoreManager, and the per-store path sandbox
45
+ * (`isPathAllowed`, which uses `startsWith(store.path)`) would return true
46
+ * for every filesystem path. StoreManager.loadFromRoots also refuses
47
+ * empty-path roots as a second layer of defense, but rejecting here keeps
48
+ * the cwd-fallback's intent narrow (workspace-shaped cwds only).
49
+ *
50
+ * Exported so unit tests can exercise it without spawning a stdio process.
51
+ */
52
+ export function populateCwdFallback(cwd: string): { uri: string; name: string } | null {
53
+ if (cwd === "/" || basename(cwd).length === 0) return null;
54
+ if (!looksLikeCedarWorkspace(cwd)) return null;
55
+ const cwdRoot = { uri: `file://${cwd}`, name: basename(cwd) };
56
+ storeManager.loadFromRoots([cwdRoot]);
57
+ return cwdRoot;
58
+ }
59
+
60
+ interface ParsedArgs {
61
+ mode: "stdio" | "http" | "help";
62
+ port?: number;
63
+ host?: string;
64
+ roots: Array<{ name: string; path: string }>;
65
+ error?: string;
66
+ }
67
+
68
+ function parseArgs(argv: string[]): ParsedArgs {
69
+ const args = argv.slice(2);
70
+ const out: ParsedArgs = { mode: "stdio", roots: [] };
71
+
72
+ for (let i = 0; i < args.length; i++) {
73
+ const a = args[i]!;
74
+ if (a === "--help" || a === "-h") {
75
+ return { mode: "help", roots: [] };
76
+ } else if (a === "--http") {
77
+ const portArg = args[i + 1];
78
+ if (!portArg || portArg.startsWith("--")) {
79
+ return { mode: "help", roots: [], error: "--http requires a port number (e.g. --http 3000)" };
80
+ }
81
+ const port = Number(portArg);
82
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
83
+ return { mode: "help", roots: [], error: `--http port must be an integer between 1 and 65535 (got '${portArg}')` };
84
+ }
85
+ out.mode = "http";
86
+ out.port = port;
87
+ i++;
88
+ } else if (a === "--host") {
89
+ const hostArg = args[i + 1];
90
+ if (!hostArg || hostArg.startsWith("--")) {
91
+ return { mode: "help", roots: [], error: "--host requires a hostname (e.g. --host 0.0.0.0)" };
92
+ }
93
+ out.host = hostArg;
94
+ i++;
95
+ } else if (a === "--root") {
96
+ const rootArg = args[i + 1];
97
+ if (!rootArg || rootArg.startsWith("--")) {
98
+ return { mode: "help", roots: [], error: "--root requires a name=path value (e.g. --root production=/etc/cedar/prod)" };
99
+ }
100
+ const eq = rootArg.indexOf("=");
101
+ if (eq <= 0 || eq === rootArg.length - 1) {
102
+ return { mode: "help", roots: [], error: `--root must be name=path (got '${rootArg}')` };
103
+ }
104
+ const name = rootArg.slice(0, eq);
105
+ const path = rootArg.slice(eq + 1);
106
+ out.roots.push({ name, path });
107
+ i++;
108
+ } else {
109
+ return { mode: "help", roots: [], error: `Unknown argument: ${a}` };
110
+ }
111
+ }
112
+
113
+ if (out.mode === "stdio" && out.roots.length > 0) {
114
+ return { mode: "help", roots: [], error: "--root flags are only used with --http mode; in stdio mode roots come from the MCP client" };
115
+ }
116
+
117
+ return out;
118
+ }
119
+
120
+ function printUsage(error?: string): void {
121
+ if (error) {
122
+ // eslint-disable-next-line no-console
123
+ console.error(`Error: ${error}\n`);
124
+ }
125
+ // eslint-disable-next-line no-console
126
+ console.error(
127
+ `cedar-mcp-server — MCP server for Cedar policy language
128
+
129
+ Usage:
130
+ cedar-mcp-server Start in stdio mode (default; for npx/Claude Code)
131
+ cedar-mcp-server --http <port> [options] Start in Streamable HTTP mode for shared team deployment
132
+
133
+ HTTP options:
134
+ --http <port> Listen port (1-65535)
135
+ --host <host> Bind host (default: 127.0.0.1; use 0.0.0.0 for non-localhost; you handle auth via reverse proxy)
136
+ --root <name>=<path> Repeatable; deployer-configured policy store ("production=/etc/cedar/prod")
137
+
138
+ Notes:
139
+ - Stdio mode: roots are negotiated with the MCP client via listRoots(); --root flags are not allowed.
140
+ - HTTP mode: stateful Streamable HTTP transport with session IDs. ALL clients share the same roots and policy stores (deployment model: one server per policy-store set). For per-tenant isolation, run multiple processes.
141
+ - HTTP mode default-binds to localhost with DNS-rebinding protection. Non-localhost binding is on you to secure (reverse proxy + auth).
142
+
143
+ Examples:
144
+ cedar-mcp-server
145
+ cedar-mcp-server --http 3000 --root production=/etc/cedar/production --root staging=/etc/cedar/staging
146
+ cedar-mcp-server --http 3000 --host 0.0.0.0 --root prod=/etc/cedar/prod
147
+ `
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Reconcile StoreManager state with whatever the MCP client advertises via
153
+ * `listRoots()`. Called from `oninitialized` AND on every
154
+ * `notifications/roots/list_changed` from the client.
155
+ *
156
+ * Precedence rules:
157
+ * - Client-advertised roots REPLACE any sync-loaded cwd-fallback. A client
158
+ * that explicitly advertises roots is stating authoritative intent; the
159
+ * cwd-fallback was an "if you didn't tell me anything" default.
160
+ * - Client returns ZERO roots (or doesn't support listRoots): preserve the
161
+ * sync-loaded cwd-fallback. We do NOT call `loadFromRoots([])` here
162
+ * because StoreManager.loadFromRoots clears the store as its first step,
163
+ * which would wipe the cwd-fallback populated synchronously at startup.
164
+ *
165
+ * `sendResourceListChanged` always fires at the end: cache-aware clients use
166
+ * it to refetch when the store membership changes. Idempotent if nothing
167
+ * actually changed.
168
+ */
169
+ async function loadRootsStdio(server: Awaited<ReturnType<typeof createServer>>) {
170
+ let clientRoots: Array<{ uri: string; name?: string }> = [];
171
+ let clientSupportsRoots = true;
172
+ try {
173
+ const result = await server.server.listRoots();
174
+ clientRoots = result.roots;
175
+ } catch {
176
+ clientSupportsRoots = false;
177
+ }
178
+
179
+ // Reconcile StoreManager state from the current (clientRoots, cwd) tuple.
180
+ // Stateless re-derivation: each call to loadRootsStdio computes the right
181
+ // state from scratch rather than mutating the previous state. This matters
182
+ // when a client advertised roots earlier and then retracts them via
183
+ // `roots/list_changed`; without re-derivation the stale advertised roots
184
+ // would leak forward instead of falling back to cwd.
185
+ if (clientRoots.length > 0) {
186
+ storeManager.loadFromRoots(clientRoots);
187
+ console.error(`[cedar-mcp-server] Loaded ${clientRoots.length} root(s) from MCP client: ${clientRoots.map((r) => r.uri).join(", ")} (replaces any sync-loaded cwd-fallback).`);
188
+ } else {
189
+ const fallback = populateCwdFallback(process.cwd());
190
+ if (fallback) {
191
+ console.error(`[cedar-mcp-server] MCP client advertised 0 roots; using cwd-fallback store "${fallback.name}" (re-derived).`);
192
+ } else {
193
+ storeManager.loadFromRoots([]);
194
+ if (clientSupportsRoots) {
195
+ console.error("[cedar-mcp-server] MCP client returned 0 roots and cwd does not look like a Cedar workspace (no schema.cedarschema, schema.json, or policies/ dir). Cedar tools will require inline inputs.");
196
+ } else {
197
+ console.error("[cedar-mcp-server] MCP client does not support roots/list and cwd does not look like a Cedar workspace. Cedar tools will require inline inputs.");
198
+ }
199
+ }
200
+ }
201
+
202
+ // kickoff-11 11a notification: cache-aware clients refetch on this. The
203
+ // synchronous cwd-fallback from runStdio means the FIRST resources/list
204
+ // already sees the populated store (this is the Round 5 fix), so this
205
+ // notification is most useful for the late-arriving-roots case (client
206
+ // sends roots/list_changed mid-session, swapping the store set). McpServer
207
+ // makes it a no-op when not connected, so it's safe to call from any path.
208
+ server.sendResourceListChanged();
209
+ }
210
+
211
+ async function runStdio(): Promise<void> {
212
+ const server = createServer();
213
+
214
+ // Round 5 fix: populate StoreManager BEFORE the transport accepts any
215
+ // client requests. The previous shape (cwd-fallback inside `oninitialized`
216
+ // → kickoff-11 sendResourceListChanged) was contract-correct against the
217
+ // MCP spec but did not hold against Claude Code, which snapshots
218
+ // resources/list once on the initialize response and does not honor
219
+ // list_changed for the `listMcpResources` cache. Synchronous population
220
+ // before connect closes the window entirely.
221
+ //
222
+ // Edge case (kickoff-10 audit Probe C): if process.cwd() is the
223
+ // cedar-mcp-server repo itself, it currently has none of schema.cedarschema,
224
+ // schema.json, or policies/ — looksLikeCedarWorkspace returns false. If a
225
+ // future commit adds a top-level policies/ directory (for examples or
226
+ // similar) the fallback would self-load. Flag in CHANGELOG if that
227
+ // ever happens.
228
+ const loaded = populateCwdFallback(process.cwd());
229
+ if (loaded) {
230
+ console.error(`[cedar-mcp-server] Synchronously auto-loaded cwd as workspace store: "${loaded.name}" (${loaded.uri}). StoreManager populated before transport accepts requests.`);
231
+ }
232
+
233
+ const transport = new StdioServerTransport();
234
+ await server.connect(transport);
235
+
236
+ server.server.oninitialized = async () => {
237
+ await loadRootsStdio(server);
238
+ };
239
+
240
+ server.server.setNotificationHandler(
241
+ RootsListChangedNotificationSchema,
242
+ async () => {
243
+ await loadRootsStdio(server);
244
+ }
245
+ );
246
+
247
+ process.on("SIGINT", async () => {
248
+ await server.close();
249
+ process.exit(0);
250
+ });
251
+
252
+ process.on("SIGTERM", async () => {
253
+ await server.close();
254
+ process.exit(0);
255
+ });
256
+ }
257
+
258
+ async function runHttp(parsed: ParsedArgs): Promise<void> {
259
+ const running = await startHttpServer({
260
+ port: parsed.port!,
261
+ host: parsed.host,
262
+ roots: parsed.roots,
263
+ });
264
+
265
+ const shutdown = async () => {
266
+ try {
267
+ await running.close();
268
+ } finally {
269
+ process.exit(0);
270
+ }
271
+ };
272
+
273
+ process.on("SIGINT", shutdown);
274
+ process.on("SIGTERM", shutdown);
275
+ }
276
+
277
+ async function main(): Promise<void> {
278
+ const parsed = parseArgs(process.argv);
279
+ if (parsed.mode === "help") {
280
+ printUsage(parsed.error);
281
+ process.exit(parsed.error ? 1 : 0);
282
+ }
283
+ if (parsed.mode === "http") {
284
+ await runHttp(parsed);
285
+ } else {
286
+ await runStdio();
287
+ }
288
+ }
289
+
290
+ main().catch((err) => {
291
+ // eslint-disable-next-line no-console
292
+ console.error("Fatal error:", err);
293
+ process.exit(1);
294
+ });
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Shared utilities for walking the PolicyJson AST returned by policyToJson / templateToJson.
3
+ *
4
+ * AST shape proven in spike (2026-05-19):
5
+ * - principal/action/resource: { op: "All" | "==" | "in", entity?, entities?, slot? }
6
+ * - "in" with single entity → entity key (singular)
7
+ * - "in" with multiple → entities key (plural)
8
+ * - conditions: [{ kind: "when"|"unless", body: ExprTree }]
9
+ * - ExprTree: operator-as-key encoding ("==", "&&", "||", "has", ".", "Var", "Value")
10
+ * - Entity literals in scope: { type, id }
11
+ * - Entity literals in conditions: { "Value": { "__entity": { type, id } } }
12
+ *
13
+ * like operator shape (proven in spike 2026-05-20):
14
+ * - { "like": { "left": Expr, "pattern": PatternElem[] } }
15
+ * - PatternElem = "Wildcard" | { Literal: string }
16
+ * - CRITICAL: pattern is CHARACTER-LEVEL — "/api/v1/*" produces one {Literal} per char,
17
+ * not one {Literal: "/api/v1/"} node. Reconstruct by joining all Literal chars,
18
+ * substituting "Wildcard" positions with the desired value.
19
+ * - Negated like: { "!": { "arg": { "like": { ... } } } }
20
+ * - Cedar wildcard matches any char sequence INCLUDING "/". Depth-limiting works via TWO wildcards:
21
+ * "like X/WILDCARD" matches any depth; "like X/WILDCARD/WILDCARD" also matches — negation limits to one segment.
22
+ */
23
+
24
+ import type { PolicyJson, Clause, Expr } from "@cedar-policy/cedar-wasm/nodejs";
25
+
26
+ // ─── Scope description ───────────────────────────────────────────────────────
27
+
28
+ export function describePrincipal(principal: PolicyJson["principal"]): string {
29
+ // Check for slot first — applies to both "==" and "in" ops in templates
30
+ if ("slot" in principal) {
31
+ const slot = (principal as Record<string, unknown>)["slot"] as string;
32
+ return `principal bound to slot ${slot}`;
33
+ }
34
+ switch (principal.op) {
35
+ case "All":
36
+ return "any principal";
37
+ case "==": {
38
+ const e = "entity" in principal ? principal.entity : null;
39
+ if (e && "type" in e) return `exactly ${e.type}::"${e.id}"`;
40
+ return "exactly (unknown)";
41
+ }
42
+ case "in": {
43
+ const entities = resolveInEntities(principal);
44
+ if (entities.length === 1) return `principal in ${formatEntity(entities[0]!)}`;
45
+ return `principal in [${entities.map(formatEntity).join(", ")}]`;
46
+ }
47
+ default:
48
+ return "principal (unknown constraint)";
49
+ }
50
+ }
51
+
52
+ export function describeAction(action: PolicyJson["action"]): string {
53
+ switch (action.op) {
54
+ case "All":
55
+ return "any action";
56
+ case "==": {
57
+ const e = "entity" in action ? action.entity : null;
58
+ if (e && "type" in e) return `action ${formatEntity(e)}`;
59
+ return "exactly (unknown action)";
60
+ }
61
+ case "in": {
62
+ const entities = resolveInEntities(action);
63
+ if (entities.length === 1) return `action in ${formatEntity(entities[0]!)}`;
64
+ return `action in [${entities.map(formatEntity).join(", ")}]`;
65
+ }
66
+ default:
67
+ return "action (unknown constraint)";
68
+ }
69
+ }
70
+
71
+ export function describeResource(resource: PolicyJson["resource"]): string {
72
+ switch (resource.op) {
73
+ case "All":
74
+ return "any resource";
75
+ case "==": {
76
+ const e = "entity" in resource ? resource.entity : null;
77
+ const s = "slot" in resource ? resource.slot : null;
78
+ if (s) return `resource bound to slot ${s}`;
79
+ if (e && "type" in e) return `exactly ${e.type}::"${e.id}"`;
80
+ return "exactly (unknown)";
81
+ }
82
+ case "in": {
83
+ const entities = resolveInEntities(resource);
84
+ if (entities.length === 1) return `resource in ${formatEntity(entities[0]!)}`;
85
+ return `resource in [${entities.map(formatEntity).join(", ")}]`;
86
+ }
87
+ default:
88
+ return "resource (unknown constraint)";
89
+ }
90
+ }
91
+
92
+ // ─── Condition rendering ──────────────────────────────────────────────────────
93
+
94
+ export function describeCondition(clause: Clause): string {
95
+ const kindLabel = clause.kind === "when" ? "WHEN" : "UNLESS";
96
+ const bodyDesc = describeExpr(clause.body);
97
+ return `${kindLabel} ${bodyDesc}`;
98
+ }
99
+
100
+ function describeExpr(expr: Expr): string {
101
+ if (typeof expr !== "object" || expr === null) return String(expr);
102
+
103
+ // Var node: { "Var": "principal" | "action" | "resource" | "context" }
104
+ if ("Var" in expr) return String((expr as Record<string, unknown>)["Var"]);
105
+
106
+ // Value node: { "Value": <cedar-value> }
107
+ if ("Value" in expr) return formatValue((expr as Record<string, unknown>)["Value"]);
108
+
109
+ // Attribute access: { ".": { left, attr } }
110
+ if ("." in expr) {
111
+ const node = (expr as Record<string, unknown>)["."] as { left: Expr; attr: string };
112
+ return `${describeExpr(node.left)}.${node.attr}`;
113
+ }
114
+
115
+ // Equality: { "==": { left, right } }
116
+ if ("==" in expr) {
117
+ const node = (expr as Record<string, unknown>)["=="] as { left: Expr; right: Expr };
118
+ return `${describeExpr(node.left)} == ${describeExpr(node.right)}`;
119
+ }
120
+
121
+ // Inequality
122
+ if ("!=" in expr) {
123
+ const node = (expr as Record<string, unknown>)["!="] as { left: Expr; right: Expr };
124
+ return `${describeExpr(node.left)} != ${describeExpr(node.right)}`;
125
+ }
126
+
127
+ // Logical AND: { "&&": { left, right } }
128
+ if ("&&" in expr) {
129
+ const node = (expr as Record<string, unknown>)["&&"] as { left: Expr; right: Expr };
130
+ return `${describeExpr(node.left)} AND ${describeExpr(node.right)}`;
131
+ }
132
+
133
+ // Logical OR: { "||": { left, right } }
134
+ if ("||" in expr) {
135
+ const node = (expr as Record<string, unknown>)["||"] as { left: Expr; right: Expr };
136
+ return `(${describeExpr(node.left)} OR ${describeExpr(node.right)})`;
137
+ }
138
+
139
+ // Has (optional attribute check): { "has": { left, attr } }
140
+ if ("has" in expr) {
141
+ const node = (expr as Record<string, unknown>)["has"] as { left: Expr; attr: string };
142
+ return `${describeExpr(node.left)} has '${node.attr}'`;
143
+ }
144
+
145
+ // In (membership): { "in": { left, right } }
146
+ if ("in" in expr) {
147
+ const node = (expr as Record<string, unknown>)["in"] as { left: Expr; right: Expr };
148
+ return `${describeExpr(node.left)} in ${describeExpr(node.right)}`;
149
+ }
150
+
151
+ // Set literal: { "Set": Expr[] }
152
+ if ("Set" in expr) {
153
+ const items = (expr as Record<string, unknown>)["Set"] as Expr[];
154
+ return `[${items.map(describeExpr).join(", ")}]`;
155
+ }
156
+
157
+ // Negation: { "!": { arg } }
158
+ if ("!" in expr) {
159
+ const node = (expr as Record<string, unknown>)["!"] as { arg: Expr };
160
+ return `NOT(${describeExpr(node.arg)})`;
161
+ }
162
+
163
+ // like: { "like": { left: Expr, pattern: PatternElem[] } }
164
+ // Reconstruct pattern with * for wildcards so it reads as Cedar syntax
165
+ if ("like" in expr) {
166
+ const node = (expr as Record<string, unknown>)["like"] as { left: Expr; pattern: PatternElem[] };
167
+ const patternStr = patternToString(node.pattern, "*");
168
+ return `${describeExpr(node.left)} like "${patternStr}"`;
169
+ }
170
+
171
+ // contains() call appears as an ExtFuncCall: { "contains": [left, right] }
172
+ // ExtFuncCall is {} & Record<string, Expr[]> — operator is the key, value is args array
173
+ const keys = Object.keys(expr);
174
+ if (keys.length === 1 && Array.isArray((expr as Record<string, unknown>)[keys[0]!])) {
175
+ const fn = keys[0]!;
176
+ const args = (expr as Record<string, unknown>)[fn] as Expr[];
177
+ return `${describeExpr(args[0]!)}.${fn}(${args.slice(1).map(describeExpr).join(", ")})`;
178
+ }
179
+
180
+ return "complex condition";
181
+ }
182
+
183
+ // ─── Pattern detection ────────────────────────────────────────────────────────
184
+
185
+ export function detectPatterns(json: PolicyJson): string[] {
186
+ const patterns: string[] = [];
187
+
188
+ if (json.effect === "forbid") patterns.push("forbid_policy");
189
+
190
+ // Principal scope patterns
191
+ const principalHasSlot = "slot" in json.principal;
192
+ if (principalHasSlot) {
193
+ patterns.push("template_policy", "slot_principal");
194
+ } else if (json.principal.op === "in") {
195
+ patterns.push("role_based_access");
196
+ }
197
+ if (json.principal.op === "All") patterns.push("any_principal");
198
+
199
+ // Action scope patterns
200
+ if (json.action.op === "All") patterns.push("unrestricted_action");
201
+
202
+ // Resource scope patterns
203
+ if (json.resource.op === "All") patterns.push("unrestricted_resource");
204
+ if (json.resource.op === "==" && "slot" in json.resource) {
205
+ if (!patterns.includes("template_policy")) patterns.push("template_policy");
206
+ patterns.push("slot_resource");
207
+ }
208
+
209
+ // Condition patterns
210
+ const allConditionText = json.conditions.map((c) => JSON.stringify(c)).join(" ");
211
+
212
+ if (json.conditions.some((c) => c.kind === "unless")) patterns.push("role_exemption");
213
+ if (allConditionText.includes('"has"')) patterns.push("optional_attribute_guard");
214
+ if (allConditionText.includes("contains")) patterns.push("attribute_containment_check");
215
+
216
+ // Name-based identity: principal.name == "..."
217
+ if (allConditionText.includes('"name"') && allConditionText.includes('"Var":"principal"')) {
218
+ patterns.push("name_based_identity");
219
+ }
220
+
221
+ // Attribute-based conditions (when clause present and non-trivial)
222
+ if (json.conditions.length > 0 && !patterns.includes("role_exemption")) {
223
+ patterns.push("attribute_condition");
224
+ }
225
+
226
+ return [...new Set(patterns)];
227
+ }
228
+
229
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
230
+
231
+ /** Handles the entity vs entities asymmetry from policyToJson */
232
+ function resolveInEntities(
233
+ constraint: Record<string, unknown>
234
+ ): Array<{ type: string; id: string }> {
235
+ if ("entities" in constraint && Array.isArray(constraint["entities"])) {
236
+ return constraint["entities"] as Array<{ type: string; id: string }>;
237
+ }
238
+ if ("entity" in constraint && constraint["entity"]) {
239
+ return [constraint["entity"] as { type: string; id: string }];
240
+ }
241
+ return [];
242
+ }
243
+
244
+ function formatEntity(e: { type: string; id: string } | unknown): string {
245
+ if (e && typeof e === "object" && "type" in e && "id" in e) {
246
+ const entity = e as { type: string; id: string };
247
+ return `${entity.type}::"${entity.id}"`;
248
+ }
249
+ return JSON.stringify(e);
250
+ }
251
+
252
+ // ─── like operator utilities ──────────────────────────────────────────────────
253
+
254
+ export type PatternElem = "Wildcard" | { Literal: string };
255
+
256
+ export interface LikeConstraint {
257
+ variable: "principal" | "resource" | "context";
258
+ attr: string;
259
+ pattern: PatternElem[];
260
+ negated: boolean;
261
+ }
262
+
263
+ /**
264
+ * Reconstructs a string from a PatternElem array.
265
+ * Each Wildcard is replaced by wildcardValue.
266
+ * Pattern is character-level (one Literal node per char) — join them all.
267
+ */
268
+ export function patternToString(pattern: PatternElem[], wildcardValue: string): string {
269
+ return pattern
270
+ .map((e) => (e === "Wildcard" ? wildcardValue : (e as { Literal: string }).Literal))
271
+ .join("");
272
+ }
273
+
274
+ /**
275
+ * Walks condition bodies and extracts all `like` constraints (positive and negated).
276
+ * Returns constraints keyed by variable.attr so callers can decide which to use.
277
+ */
278
+ export function extractLikeConstraints(conditions: PolicyJson["conditions"]): LikeConstraint[] {
279
+ const result: LikeConstraint[] = [];
280
+ for (const clause of conditions) {
281
+ walkLikeExpr(clause.body, false, result);
282
+ }
283
+ return result;
284
+ }
285
+
286
+ function walkLikeExpr(expr: unknown, insideNot: boolean, acc: LikeConstraint[]): void {
287
+ if (typeof expr !== "object" || expr === null) return;
288
+ const e = expr as Record<string, unknown>;
289
+
290
+ if ("&&" in e || "||" in e) {
291
+ const key = "&&" in e ? "&&" : "||";
292
+ const node = e[key] as { left: unknown; right: unknown };
293
+ walkLikeExpr(node.left, insideNot, acc);
294
+ walkLikeExpr(node.right, insideNot, acc);
295
+ return;
296
+ }
297
+
298
+ if ("!" in e) {
299
+ const node = e["!"] as { arg: unknown };
300
+ walkLikeExpr(node.arg, !insideNot, acc);
301
+ return;
302
+ }
303
+
304
+ if ("like" in e) {
305
+ const node = e["like"] as { left: unknown; pattern: PatternElem[] };
306
+ const attrAccess = extractAttrFromLike(node.left);
307
+ if (attrAccess) {
308
+ acc.push({
309
+ variable: attrAccess.variable,
310
+ attr: attrAccess.attr,
311
+ pattern: node.pattern,
312
+ negated: insideNot,
313
+ });
314
+ }
315
+ return;
316
+ }
317
+ }
318
+
319
+ function extractAttrFromLike(
320
+ expr: unknown
321
+ ): { variable: "principal" | "resource" | "context"; attr: string } | null {
322
+ if (typeof expr !== "object" || expr === null) return null;
323
+ const e = expr as Record<string, unknown>;
324
+ if ("." in e) {
325
+ const node = e["."] as { left: unknown; attr: string };
326
+ const v = (node.left as Record<string, unknown>)?.["Var"];
327
+ if (v === "principal" || v === "resource" || v === "context") {
328
+ return { variable: v as "principal" | "resource" | "context", attr: node.attr };
329
+ }
330
+ }
331
+ return null;
332
+ }
333
+
334
+ function formatValue(v: unknown): string {
335
+ if (v === null) return "null";
336
+ if (typeof v === "string") return `"${v}"`;
337
+ if (typeof v === "boolean" || typeof v === "number") return String(v);
338
+ // Entity literal inside condition body: { __entity: { type, id } }
339
+ if (typeof v === "object" && v !== null && "__entity" in v) {
340
+ const entity = (v as Record<string, unknown>)["__entity"] as { type: string; id: string };
341
+ return formatEntity(entity);
342
+ }
343
+ if (Array.isArray(v)) return `[${v.map(formatValue).join(", ")}]`;
344
+ return JSON.stringify(v);
345
+ }
@@ -0,0 +1,3 @@
1
+ ## Adding new MCP Prompts
2
+
3
+ Each prompt is defined as a `PromptDefinition` entry in `src/prompts/index.ts` and appended to the `PROMPT_DEFINITIONS` array. The entry supplies a name, a description, a Zod raw shape (`argsSchema`) for argument validation, and a `handler` that returns a `GetPromptResult` (a `messages` array). Registration in `src/server.ts` is a single loop: `for (const p of PROMPT_DEFINITIONS) server.prompt(p.name, p.description, p.argsSchema, p.handler);`. Prompt text must follow the project brand-voice rules: no em-dashes, no banned phrases, plain factual language.