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,459 @@
1
+ import { validate, checkParsePolicySet, policySetTextToParts } from "@cedar-policy/cedar-wasm/nodejs";
2
+ import type { Schema, DetailedError } from "@cedar-policy/cedar-wasm/nodejs";
3
+ import { storeManager } from "../resources/store-manager.js";
4
+
5
+ export interface ValidateInput {
6
+ policies: string;
7
+ /** Optional. When omitted, validate runs in syntax-only mode (parse-only, no schema typing). */
8
+ schema?: string;
9
+ /**
10
+ * Optional store name to disambiguate workspace auto-discovery (10d) when
11
+ * multiple stores are loaded. The server.ts handler resolves this against
12
+ * the StoreManager and supplies `schema` before calling handleValidate; the
13
+ * field is carried through so handleValidate can surface ambiguity errors
14
+ * when callers invoke it directly without going through the MCP layer.
15
+ */
16
+ store?: string;
17
+ /**
18
+ * 11c opt-in: explicitly select the validation mode rather than letting
19
+ * schema presence decide it.
20
+ *
21
+ * "auto" (default): schema presence picks the mode. With a schema (inline
22
+ * or auto-discovered from a single loaded store), run syntax_and_schema.
23
+ * Without one, run syntax_only.
24
+ * "syntax_only": always parser-only. Skip workspace auto-discovery and
25
+ * ignore any inline schema. Useful when the user explicitly says "I have
26
+ * no schema" or wants a fast syntax sanity check.
27
+ * "syntax_and_schema": require a schema. If neither an inline schema nor
28
+ * one resolvable from a loaded store is available, return a clear error
29
+ * rather than silently dropping to syntax_only.
30
+ */
31
+ validation_mode?: "auto" | "syntax_only" | "syntax_and_schema";
32
+ }
33
+
34
+ export interface ValidateError {
35
+ policy_id: string;
36
+ message: string;
37
+ hint: string | null;
38
+ /** 1-indexed line of the source location, when the WASM error reports one. */
39
+ line?: number;
40
+ /** 1-indexed column of the source location, when the WASM error reports one. */
41
+ column?: number;
42
+ }
43
+
44
+ export interface ValidateResult {
45
+ valid: boolean;
46
+ errors: ValidateError[];
47
+ warnings: ValidateError[];
48
+ policy_count: number;
49
+ /**
50
+ * Discriminator that tells the caller what was actually checked.
51
+ * "syntax_only": parser-only run, no schema supplied. Catches parse errors
52
+ * (typos, malformed scopes, bad operators) but not attribute typing or
53
+ * action applicability.
54
+ * "syntax_and_schema": full parse + type-check against a Cedar schema.
55
+ */
56
+ validation_mode: "syntax_only" | "syntax_and_schema";
57
+ /**
58
+ * 10d workspace auto-discovery: populated when an input was sourced from a
59
+ * loaded MCP root rather than supplied inline. Surfaces to the caller which
60
+ * store ended up satisfying the missing field so the action is traceable.
61
+ */
62
+ auto_discovered?: {
63
+ schema_from?: string;
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Common Cedar typo → suggestion table. Used to populate the `hint` field on
69
+ * parse errors of the form "unexpected token `X`" when X is a known misspelling
70
+ * of a Cedar keyword. Keep small and conservative; better to leave hint null
71
+ * than to over-suggest. Levenshtein over the reserved keyword set is the
72
+ * future generalization if this table proves too narrow.
73
+ */
74
+ const TYPO_HINTS: Record<string, string> = {
75
+ int: "in",
76
+ permint: "permit",
77
+ forbit: "forbid",
78
+ prinipal: "principal",
79
+ prinicpal: "principal",
80
+ prncipal: "principal",
81
+ resorce: "resource",
82
+ resoure: "resource",
83
+ actoin: "action",
84
+ acton: "action",
85
+ unles: "unless",
86
+ wen: "when",
87
+ Like: "like",
88
+ Has: "has",
89
+ Permit: "permit",
90
+ Forbid: "forbid",
91
+ When: "when",
92
+ Unless: "unless",
93
+ };
94
+
95
+ function parseSchema(schemaStr: string): Schema {
96
+ try {
97
+ return JSON.parse(schemaStr);
98
+ } catch {
99
+ // Not JSON — treat as Cedar schema text
100
+ return schemaStr;
101
+ }
102
+ }
103
+
104
+ function countPolicies(policiesText: string): number {
105
+ const parts = policySetTextToParts(policiesText);
106
+ if (parts.type === "failure") return 0;
107
+ return parts.policies.length + parts.policy_templates.length;
108
+ }
109
+
110
+ /**
111
+ * Convert a WASM-reported UTF-8 byte offset into the source text into a
112
+ * 1-indexed line + Unicode-code-point column. Walking the JS string as if
113
+ * the offset were a char index drifts whenever the source contains
114
+ * multi-byte UTF-8 chars (em-dashes in comments, non-ASCII identifiers
115
+ * in string literals). This matters in practice for any Cedar policy
116
+ * with non-ASCII content, including comments, before the error site.
117
+ *
118
+ * Implementation: encode the full source to bytes, slice up to the
119
+ * byte offset, decode back to a string, then count Unicode code points
120
+ * (via for-of, which iterates code points rather than UTF-16 code units).
121
+ */
122
+ function offsetToLineCol(source: string, byteOffset: number): { line: number; column: number } {
123
+ const enc = new TextEncoder();
124
+ const bytes = enc.encode(source);
125
+ if (byteOffset < 0 || byteOffset > bytes.length) {
126
+ return { line: 1, column: 1 };
127
+ }
128
+ const before = new TextDecoder().decode(bytes.slice(0, byteOffset));
129
+ let line = 1;
130
+ let column = 1;
131
+ for (const ch of before) {
132
+ if (ch === "\n") {
133
+ line++;
134
+ column = 1;
135
+ } else {
136
+ column++;
137
+ }
138
+ }
139
+ return { line, column };
140
+ }
141
+
142
+ /**
143
+ * Pull the offending token out of a Cedar parse error message, if present.
144
+ * Cedar emits a few distinct error templates depending on where the token
145
+ * appears in the grammar; this matches the ones common typos produce.
146
+ */
147
+ function extractOffendingToken(message: string): string | null {
148
+ const patterns: RegExp[] = [
149
+ /unexpected token `([^`]+)`/, // operator / keyword in expressions
150
+ /invalid variable in the policy scope: (\S+)/, // mis-typed principal / action / resource
151
+ /invalid policy effect: (\S+)/, // mis-typed permit / forbid
152
+ ];
153
+ for (const re of patterns) {
154
+ const m = message.match(re);
155
+ if (m) return m[1]!;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /** Suggest a hint string for a known typo, or null if none applies. */
161
+ function typoHint(message: string): string | null {
162
+ const token = extractOffendingToken(message);
163
+ if (!token) return null;
164
+ const suggestion = TYPO_HINTS[token];
165
+ return suggestion ? `Did you mean '${suggestion}'?` : null;
166
+ }
167
+
168
+ /** Best-effort source location: prefer error's own sourceLocations[0]; null if none. */
169
+ function locationFor(err: DetailedError, source: string): { line: number; column: number } | null {
170
+ const loc = err.sourceLocations?.[0];
171
+ if (!loc || typeof loc.start !== "number") return null;
172
+ return offsetToLineCol(source, loc.start);
173
+ }
174
+
175
+ /**
176
+ * Result of trying to resolve a schema from inline input or the workspace.
177
+ * Discriminated so callers can wrap the failure cases in whatever result
178
+ * shape they need (ValidateResult vs `{ error }` envelope).
179
+ */
180
+ type SchemaResolution =
181
+ | { kind: "resolved"; schema: string; from?: string }
182
+ | { kind: "none" }
183
+ | { kind: "error"; error: string };
184
+
185
+ /**
186
+ * Resolve a Cedar schema for cedar_validate from, in order:
187
+ * 1. an inline `schema` string (highest priority; `from` is left undefined),
188
+ * 2. an explicit `store` name (read `schema.cedarschema` / `schema.json` from that loaded store; errors if read fails),
189
+ * 3. the workspace default when exactly one store is loaded (10d auto-discovery; falls to `none` if the store has no schema file),
190
+ * 4. `none` when no store is loaded at all,
191
+ * 5. `error` when multiple stores are loaded and no `store` was passed to disambiguate.
192
+ *
193
+ * Single source of truth for the resolution rules; called from both
194
+ * handleValidate (direct callers, including tests) and handleValidateMcp
195
+ * (after `schema_ref` resolution). Replaces two near-duplicate inline
196
+ * blocks from kickoff-10 (10d) that the kickoff-10 audit flagged for
197
+ * v1.1 cleanup.
198
+ */
199
+ function resolveWorkspaceSchema(
200
+ inputSchema: string | undefined,
201
+ storeParam: string | undefined,
202
+ ): SchemaResolution {
203
+ if (inputSchema !== undefined) return { kind: "resolved", schema: inputSchema };
204
+ if (storeParam) {
205
+ try {
206
+ return { kind: "resolved", schema: storeManager.readSchema(storeParam), from: storeParam };
207
+ } catch (e) {
208
+ return { kind: "error", error: e instanceof Error ? e.message : String(e) };
209
+ }
210
+ }
211
+ const def = storeManager.getDefaultStore();
212
+ if (def.kind === "single") {
213
+ try {
214
+ return { kind: "resolved", schema: storeManager.readSchema(def.store.name), from: def.store.name };
215
+ } catch {
216
+ // Store exists but has no schema file; caller falls through to syntax_only
217
+ // (or errors out in syntax_and_schema mode at the next gate).
218
+ return { kind: "none" };
219
+ }
220
+ }
221
+ if (def.kind === "ambiguous") {
222
+ return { kind: "error", error: `Multiple stores are loaded (${def.names.join(", ")}). Pass store: "<name>" to choose.` };
223
+ }
224
+ return { kind: "none" };
225
+ }
226
+
227
+ /** Parser-only validation. Used by mode="syntax_only" and by mode="auto" when no schema is resolvable. */
228
+ function parseOnlyResult(policies: string): ValidateResult {
229
+ const parseAnswer = checkParsePolicySet({ staticPolicies: policies });
230
+ if (parseAnswer.type === "failure") {
231
+ return {
232
+ valid: false,
233
+ errors: parseAnswer.errors.map((e) => {
234
+ const loc = locationFor(e, policies);
235
+ const hint = typoHint(e.message) ?? e.help ?? null;
236
+ const base: ValidateError = {
237
+ policy_id: "",
238
+ message: e.message,
239
+ hint,
240
+ };
241
+ if (loc) {
242
+ base.line = loc.line;
243
+ base.column = loc.column;
244
+ }
245
+ return base;
246
+ }),
247
+ warnings: [],
248
+ policy_count: countPolicies(policies),
249
+ validation_mode: "syntax_only",
250
+ };
251
+ }
252
+ return {
253
+ valid: true,
254
+ errors: [],
255
+ warnings: [],
256
+ policy_count: countPolicies(policies),
257
+ validation_mode: "syntax_only",
258
+ };
259
+ }
260
+
261
+ export async function handleValidate(input: ValidateInput): Promise<ValidateResult> {
262
+ const mode = input.validation_mode ?? "auto";
263
+
264
+ // 11c: explicit syntax_only short-circuits every schema path. The caller
265
+ // said "I have no schema" or "I want a parse-only check"; we honor that
266
+ // even when an inline schema is present and even when a workspace store
267
+ // is loaded.
268
+ if (mode === "syntax_only") {
269
+ return parseOnlyResult(input.policies);
270
+ }
271
+
272
+ // 10d workspace auto-discovery, single-sourced through resolveWorkspaceSchema.
273
+ // Returns inline schema verbatim, reads from `store` if named, or auto-discovers
274
+ // from the default workspace store. Errors out on read failure or multi-store
275
+ // ambiguity. mode="syntax_and_schema" turns "no schema available" into a hard
276
+ // error in the next gate.
277
+ const resolution = resolveWorkspaceSchema(input.schema, input.store);
278
+ if (resolution.kind === "error") {
279
+ return {
280
+ valid: false,
281
+ errors: [{ policy_id: "", message: resolution.error, hint: null }],
282
+ warnings: [],
283
+ policy_count: countPolicies(input.policies),
284
+ validation_mode: mode === "syntax_and_schema" ? "syntax_and_schema" : "syntax_only",
285
+ };
286
+ }
287
+ const schemaText: string | undefined = resolution.kind === "resolved" ? resolution.schema : undefined;
288
+ const schemaFrom: string | undefined = resolution.kind === "resolved" ? resolution.from : undefined;
289
+
290
+ // 11c: explicit syntax_and_schema requires a schema. After both inline and
291
+ // auto-discovery paths, if there is still no schema, the caller asked for a
292
+ // mode we cannot honor. Return a clear error rather than silently dropping
293
+ // to syntax_only (which is exactly the Round 4 Scenario I friction).
294
+ if (mode === "syntax_and_schema" && schemaText === undefined) {
295
+ return {
296
+ valid: false,
297
+ errors: [{
298
+ policy_id: "",
299
+ message: 'validation_mode "syntax_and_schema" requires a schema, but none was provided and none could be auto-discovered. Pass schema, schema_ref, or store, or use validation_mode "auto" / "syntax_only".',
300
+ hint: null,
301
+ }],
302
+ warnings: [],
303
+ policy_count: countPolicies(input.policies),
304
+ validation_mode: "syntax_and_schema",
305
+ };
306
+ }
307
+
308
+ // Syntax-only mode: no schema supplied (mode === "auto" with no resolvable
309
+ // schema). Run the parser alone so the caller can sanity-check a snippet
310
+ // without having to construct a schema first. Maps any parse failure to
311
+ // the same ValidateError shape the full-validate path uses, so downstream
312
+ // consumers do not need a separate branch.
313
+ if (schemaText === undefined) {
314
+ return parseOnlyResult(input.policies);
315
+ }
316
+
317
+ const schema = parseSchema(schemaText);
318
+
319
+ // per spike-report-wasm-api.md §2: type field is WASM call health, not policy validity.
320
+ // Check validationErrors.length for actual validity.
321
+ const answer = validate({
322
+ schema,
323
+ policies: { staticPolicies: input.policies },
324
+ });
325
+
326
+ const autoDiscovered = schemaFrom ? { schema_from: schemaFrom } : undefined;
327
+
328
+ if (answer.type === "failure") {
329
+ return {
330
+ valid: false,
331
+ errors: answer.errors.map((e) => {
332
+ const loc = locationFor(e, input.policies);
333
+ const hint = typoHint(e.message) ?? e.help ?? null;
334
+ const base: ValidateError = {
335
+ policy_id: "",
336
+ message: e.message,
337
+ hint,
338
+ };
339
+ if (loc) {
340
+ base.line = loc.line;
341
+ base.column = loc.column;
342
+ }
343
+ return base;
344
+ }),
345
+ warnings: [],
346
+ policy_count: countPolicies(input.policies),
347
+ validation_mode: "syntax_and_schema",
348
+ ...(autoDiscovered ? { auto_discovered: autoDiscovered } : {}),
349
+ };
350
+ }
351
+
352
+ const errors: ValidateError[] = answer.validationErrors.map((e) => {
353
+ const loc = locationFor(e.error, input.policies);
354
+ const base: ValidateError = {
355
+ policy_id: e.policyId,
356
+ message: e.error.message,
357
+ hint: typoHint(e.error.message) ?? e.error.help ?? null,
358
+ };
359
+ if (loc) {
360
+ base.line = loc.line;
361
+ base.column = loc.column;
362
+ }
363
+ return base;
364
+ });
365
+
366
+ const warnings: ValidateError[] = answer.validationWarnings.map((e) => {
367
+ const loc = locationFor(e.error, input.policies);
368
+ const base: ValidateError = {
369
+ policy_id: e.policyId,
370
+ message: e.error.message,
371
+ hint: typoHint(e.error.message) ?? e.error.help ?? null,
372
+ };
373
+ if (loc) {
374
+ base.line = loc.line;
375
+ base.column = loc.column;
376
+ }
377
+ return base;
378
+ });
379
+
380
+ return {
381
+ valid: errors.length === 0,
382
+ errors,
383
+ warnings,
384
+ policy_count: countPolicies(input.policies),
385
+ validation_mode: "syntax_and_schema",
386
+ ...(autoDiscovered ? { auto_discovered: autoDiscovered } : {}),
387
+ };
388
+ }
389
+
390
+ // ─── 10d workspace auto-discovery wrapper ────────────────────────────────────
391
+
392
+ /**
393
+ * Inputs accepted by the MCP-level validate entry point. Wider than
394
+ * `ValidateInput` because it also accepts the `_ref` shapes the MCP layer
395
+ * resolves before reaching `handleValidate`.
396
+ */
397
+ export interface ValidateMcpInput {
398
+ policies?: string;
399
+ policy_ref?: string;
400
+ schema?: string;
401
+ schema_ref?: string;
402
+ store?: string;
403
+ validation_mode?: "auto" | "syntax_only" | "syntax_and_schema";
404
+ }
405
+
406
+ /**
407
+ * 10d workspace auto-discovery wrapper for `cedar_validate`. Resolves the
408
+ * schema from a loaded MCP root when neither `schema` nor `schema_ref` was
409
+ * supplied. Single-store deployments upgrade to syntax_and_schema mode;
410
+ * multi-store deployments require an explicit `store` parameter and return
411
+ * an ambiguity error otherwise.
412
+ */
413
+ export async function handleValidateMcp(
414
+ input: ValidateMcpInput,
415
+ resolveRef: (uri: string) => { content: string } | { error: string },
416
+ ): Promise<{ result: ValidateResult } | { error: string }> {
417
+ let policies = input.policies;
418
+ if (!policies && input.policy_ref) {
419
+ const resolved = resolveRef(input.policy_ref);
420
+ if ("error" in resolved) return { error: resolved.error };
421
+ policies = resolved.content;
422
+ }
423
+ if (!policies) return { error: "Either policies or policy_ref is required" };
424
+
425
+ const mode = input.validation_mode ?? "auto";
426
+
427
+ // 11c: explicit syntax_only short-circuits all schema work at the wrapper
428
+ // level too. The user said parser-only; don't read schema_ref off disk,
429
+ // don't auto-discover, don't error on a missing schema. Pass straight to
430
+ // handleValidate which knows to run parseOnlyResult.
431
+ if (mode === "syntax_only") {
432
+ const result = await handleValidate({ policies, validation_mode: "syntax_only" });
433
+ return { result };
434
+ }
435
+
436
+ let schema = input.schema;
437
+ if (!schema && input.schema_ref) {
438
+ const resolved = resolveRef(input.schema_ref);
439
+ if ("error" in resolved) return { error: resolved.error };
440
+ schema = resolved.content;
441
+ }
442
+
443
+ // 10d workspace auto-discovery, single-sourced through resolveWorkspaceSchema.
444
+ // schema_ref was resolved above; if a caller used schema_ref the helper short-
445
+ // circuits on the inline-schema check and never touches StoreManager.
446
+ const resolution = resolveWorkspaceSchema(schema, input.store);
447
+ if (resolution.kind === "error") return { error: resolution.error };
448
+ let autoSchemaFrom: string | undefined;
449
+ if (resolution.kind === "resolved") {
450
+ schema = resolution.schema;
451
+ autoSchemaFrom = resolution.from;
452
+ }
453
+
454
+ const result = await handleValidate({ policies, schema, validation_mode: mode });
455
+ if (autoSchemaFrom) {
456
+ result.auto_discovered = { schema_from: autoSchemaFrom };
457
+ }
458
+ return { result };
459
+ }