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.
- package/.editorconfig +12 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +42 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +241 -0
- package/CONTRIBUTING.md +83 -0
- package/LICENSE +182 -0
- package/README.md +1635 -0
- package/SECURITY.md +37 -0
- package/dist/http-server.d.ts +61 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +194 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +270 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/policy-ast.d.ts +49 -0
- package/dist/parser/policy-ast.d.ts.map +1 -0
- package/dist/parser/policy-ast.js +311 -0
- package/dist/parser/policy-ast.js.map +1 -0
- package/dist/prompts/index.d.ts +38 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +172 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/resources/ref-resolver.d.ts +23 -0
- package/dist/resources/ref-resolver.d.ts.map +1 -0
- package/dist/resources/ref-resolver.js +128 -0
- package/dist/resources/ref-resolver.js.map +1 -0
- package/dist/resources/store-manager.d.ts +64 -0
- package/dist/resources/store-manager.d.ts.map +1 -0
- package/dist/resources/store-manager.js +221 -0
- package/dist/resources/store-manager.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +539 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/advise/avp-rules.d.ts +49 -0
- package/dist/tools/advise/avp-rules.d.ts.map +1 -0
- package/dist/tools/advise/avp-rules.js +59 -0
- package/dist/tools/advise/avp-rules.js.map +1 -0
- package/dist/tools/advise/cedar-patterns.d.ts +24 -0
- package/dist/tools/advise/cedar-patterns.d.ts.map +1 -0
- package/dist/tools/advise/cedar-patterns.js +57 -0
- package/dist/tools/advise/cedar-patterns.js.map +1 -0
- package/dist/tools/advise/context-builder.d.ts +28 -0
- package/dist/tools/advise/context-builder.d.ts.map +1 -0
- package/dist/tools/advise/context-builder.js +89 -0
- package/dist/tools/advise/context-builder.js.map +1 -0
- package/dist/tools/advise/gotchas.d.ts +15 -0
- package/dist/tools/advise/gotchas.d.ts.map +1 -0
- package/dist/tools/advise/gotchas.js +83 -0
- package/dist/tools/advise/gotchas.js.map +1 -0
- package/dist/tools/advise.d.ts +96 -0
- package/dist/tools/advise.d.ts.map +1 -0
- package/dist/tools/advise.js +258 -0
- package/dist/tools/advise.js.map +1 -0
- package/dist/tools/authorize-batch.d.ts +35 -0
- package/dist/tools/authorize-batch.d.ts.map +1 -0
- package/dist/tools/authorize-batch.js +262 -0
- package/dist/tools/authorize-batch.js.map +1 -0
- package/dist/tools/authorize.d.ts +115 -0
- package/dist/tools/authorize.d.ts.map +1 -0
- package/dist/tools/authorize.js +373 -0
- package/dist/tools/authorize.js.map +1 -0
- package/dist/tools/check-change.d.ts +19 -0
- package/dist/tools/check-change.d.ts.map +1 -0
- package/dist/tools/check-change.js +91 -0
- package/dist/tools/check-change.js.map +1 -0
- package/dist/tools/diff-schema.d.ts +103 -0
- package/dist/tools/diff-schema.d.ts.map +1 -0
- package/dist/tools/diff-schema.js +379 -0
- package/dist/tools/diff-schema.js.map +1 -0
- package/dist/tools/diff-stores.d.ts +45 -0
- package/dist/tools/diff-stores.d.ts.map +1 -0
- package/dist/tools/diff-stores.js +222 -0
- package/dist/tools/diff-stores.js.map +1 -0
- package/dist/tools/explain.d.ts +80 -0
- package/dist/tools/explain.d.ts.map +1 -0
- package/dist/tools/explain.js +187 -0
- package/dist/tools/explain.js.map +1 -0
- package/dist/tools/format.d.ts +11 -0
- package/dist/tools/format.d.ts.map +1 -0
- package/dist/tools/format.js +20 -0
- package/dist/tools/format.js.map +1 -0
- package/dist/tools/generate-sample.d.ts +28 -0
- package/dist/tools/generate-sample.d.ts.map +1 -0
- package/dist/tools/generate-sample.js +568 -0
- package/dist/tools/generate-sample.js.map +1 -0
- package/dist/tools/link-template.d.ts +17 -0
- package/dist/tools/link-template.d.ts.map +1 -0
- package/dist/tools/link-template.js +78 -0
- package/dist/tools/link-template.js.map +1 -0
- package/dist/tools/list-template-links.d.ts +16 -0
- package/dist/tools/list-template-links.d.ts.map +1 -0
- package/dist/tools/list-template-links.js +22 -0
- package/dist/tools/list-template-links.js.map +1 -0
- package/dist/tools/list-templates.d.ts +16 -0
- package/dist/tools/list-templates.d.ts.map +1 -0
- package/dist/tools/list-templates.js +36 -0
- package/dist/tools/list-templates.js.map +1 -0
- package/dist/tools/translate.d.ts +11 -0
- package/dist/tools/translate.d.ts.map +1 -0
- package/dist/tools/translate.js +53 -0
- package/dist/tools/translate.js.map +1 -0
- package/dist/tools/validate-entities.d.ts +19 -0
- package/dist/tools/validate-entities.d.ts.map +1 -0
- package/dist/tools/validate-entities.js +88 -0
- package/dist/tools/validate-entities.js.map +1 -0
- package/dist/tools/validate-schema.d.ts +22 -0
- package/dist/tools/validate-schema.d.ts.map +1 -0
- package/dist/tools/validate-schema.js +89 -0
- package/dist/tools/validate-schema.js.map +1 -0
- package/dist/tools/validate-template.d.ts +18 -0
- package/dist/tools/validate-template.d.ts.map +1 -0
- package/dist/tools/validate-template.js +59 -0
- package/dist/tools/validate-template.js.map +1 -0
- package/dist/tools/validate.d.ts +90 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +351 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/utils/format-detector.d.ts +49 -0
- package/dist/utils/format-detector.d.ts.map +1 -0
- package/dist/utils/format-detector.js +298 -0
- package/dist/utils/format-detector.js.map +1 -0
- package/examples/README.md +36 -0
- package/examples/abac-multi-tenant/README.md +150 -0
- package/examples/abac-multi-tenant/entities/users-and-docs.json +33 -0
- package/examples/abac-multi-tenant/policies/member-read-internal.cedar +9 -0
- package/examples/abac-multi-tenant/policies/owner-full-access.cedar +9 -0
- package/examples/abac-multi-tenant/policies/premium-share-guard.cedar +9 -0
- package/examples/abac-multi-tenant/policies/private-doc-guard.cedar +13 -0
- package/examples/abac-multi-tenant/run.ts +92 -0
- package/examples/abac-multi-tenant/schema.json +60 -0
- package/examples/api-gateway-path-routing/README.md +154 -0
- package/examples/api-gateway-path-routing/entities/users-and-roles.json +20 -0
- package/examples/api-gateway-path-routing/policies/admin-full-access.cedar +6 -0
- package/examples/api-gateway-path-routing/policies/developer-projects.cedar +14 -0
- package/examples/api-gateway-path-routing/policies/viewer-readonly.cedar +10 -0
- package/examples/api-gateway-path-routing/run.ts +108 -0
- package/examples/api-gateway-path-routing/schema.json +54 -0
- package/examples/rbac-document-management/README.md +167 -0
- package/examples/rbac-document-management/entities/users-and-docs.json +43 -0
- package/examples/rbac-document-management/policies/admin.cedar +6 -0
- package/examples/rbac-document-management/policies/editor.cedar +6 -0
- package/examples/rbac-document-management/policies/top-secret-forbid.cedar +13 -0
- package/examples/rbac-document-management/policies/viewer.cedar +6 -0
- package/examples/rbac-document-management/run.ts +87 -0
- package/examples/rbac-document-management/schema.json +57 -0
- package/package.json +50 -0
- package/src/http-server.ts +239 -0
- package/src/index.ts +294 -0
- package/src/parser/policy-ast.ts +345 -0
- package/src/prompts/README.md +3 -0
- package/src/prompts/index.ts +217 -0
- package/src/resources/ref-resolver.ts +134 -0
- package/src/resources/store-manager.ts +248 -0
- package/src/server.ts +711 -0
- package/src/tools/advise/avp-rules.ts +70 -0
- package/src/tools/advise/cedar-patterns.ts +73 -0
- package/src/tools/advise/context-builder.ts +109 -0
- package/src/tools/advise/gotchas.ts +92 -0
- package/src/tools/advise.ts +366 -0
- package/src/tools/authorize-batch.ts +345 -0
- package/src/tools/authorize.ts +464 -0
- package/src/tools/check-change.ts +119 -0
- package/src/tools/diff-schema.ts +510 -0
- package/src/tools/diff-stores.ts +298 -0
- package/src/tools/explain.ts +278 -0
- package/src/tools/format.ts +33 -0
- package/src/tools/generate-sample.ts +665 -0
- package/src/tools/link-template.ts +109 -0
- package/src/tools/list-template-links.ts +41 -0
- package/src/tools/list-templates.ts +55 -0
- package/src/tools/translate.ts +66 -0
- package/src/tools/validate-entities.ts +125 -0
- package/src/tools/validate-schema.ts +128 -0
- package/src/tools/validate-template.ts +72 -0
- package/src/tools/validate.ts +459 -0
- package/src/utils/format-detector.ts +356 -0
- package/test/fixtures/docmgmt.ts +121 -0
- package/test/fixtures/multitenant.ts +163 -0
- package/test/index.test.ts +96 -0
- package/test/integration/e2e/behavior.test.ts +359 -0
- package/test/integration/e2e/edge-cases.test.ts +365 -0
- package/test/integration/e2e/failure-modes.test.ts +266 -0
- package/test/integration/e2e/protocol.test.ts +252 -0
- package/test/integration/http-smoke.test.ts +588 -0
- package/test/integration/smoke.test.ts +475 -0
- package/test/prompts/prompts.test.ts +173 -0
- package/test/property/properties.test.ts +234 -0
- package/test/resources/ref-resolver.test.ts +186 -0
- package/test/resources/store-manager.test.ts +344 -0
- package/test/setup.test.ts +7 -0
- package/test/tools/advise/avp-rules.test.ts +76 -0
- package/test/tools/advise.test.ts +339 -0
- package/test/tools/authorize-batch.test.ts +459 -0
- package/test/tools/authorize.test.ts +682 -0
- package/test/tools/check-change.test.ts +104 -0
- package/test/tools/cross-fixture.test.ts +170 -0
- package/test/tools/diff-schema.test.ts +355 -0
- package/test/tools/diff-stores.test.ts +291 -0
- package/test/tools/explain.test.ts +221 -0
- package/test/tools/format.test.ts +33 -0
- package/test/tools/generate-sample.test.ts +480 -0
- package/test/tools/link-template.test.ts +90 -0
- package/test/tools/list-templates.test.ts +151 -0
- package/test/tools/translate.test.ts +89 -0
- package/test/tools/validate-entities.test.ts +178 -0
- package/test/tools/validate-schema.test.ts +86 -0
- package/test/tools/validate-template.test.ts +89 -0
- package/test/tools/validate.test.ts +331 -0
- package/test/utils/format-detector.test.ts +518 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration smoke test for HTTP (Streamable HTTP) transport.
|
|
3
|
+
*
|
|
4
|
+
* Starts cedar-mcp-server's HTTP entry point on an ephemeral port, connects
|
|
5
|
+
* via a real MCP StreamableHTTPClientTransport, and exercises:
|
|
6
|
+
* H1 — listTools returns the 17 tools
|
|
7
|
+
* H2 — cedar_validate via the protocol returns valid:true
|
|
8
|
+
* H3 — cedar_authorize via the protocol returns Allow
|
|
9
|
+
* H4 — health endpoint returns ok JSON
|
|
10
|
+
* H5 — malformed JSON to /mcp returns a structured error (no server crash)
|
|
11
|
+
* H6 — graceful shutdown closes cleanly
|
|
12
|
+
*
|
|
13
|
+
* Runs separately from unit tests:
|
|
14
|
+
* npx vitest run test/integration
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
17
|
+
import { createServer as createNetServer } from "node:net";
|
|
18
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
22
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
23
|
+
import { startHttpServer, type RunningHttpServer } from "../../src/http-server.js";
|
|
24
|
+
|
|
25
|
+
const DOCMGMT_SCHEMA = JSON.stringify({
|
|
26
|
+
DocMgmt: {
|
|
27
|
+
entityTypes: {
|
|
28
|
+
User: {
|
|
29
|
+
memberOfTypes: ["Role"],
|
|
30
|
+
shape: {
|
|
31
|
+
type: "Record",
|
|
32
|
+
attributes: {
|
|
33
|
+
name: { type: "String", required: true },
|
|
34
|
+
email: { type: "String", required: true },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
39
|
+
Document: {
|
|
40
|
+
memberOfTypes: ["Folder"],
|
|
41
|
+
shape: {
|
|
42
|
+
type: "Record",
|
|
43
|
+
attributes: {
|
|
44
|
+
owner: { type: "String", required: true },
|
|
45
|
+
classification: { type: "String", required: true },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
Folder: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
50
|
+
},
|
|
51
|
+
actions: {
|
|
52
|
+
read: {
|
|
53
|
+
appliesTo: { principalTypes: ["User"], resourceTypes: ["Document"], context: { type: "Record", attributes: {} } },
|
|
54
|
+
memberOf: [],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const ADMIN_POLICY = `permit (
|
|
61
|
+
principal in DocMgmt::Role::"admin",
|
|
62
|
+
action,
|
|
63
|
+
resource
|
|
64
|
+
);`;
|
|
65
|
+
|
|
66
|
+
const ALICE_ENTITIES = JSON.stringify([
|
|
67
|
+
{
|
|
68
|
+
uid: { type: "DocMgmt::User", id: "alice" },
|
|
69
|
+
attrs: { name: "Alice", email: "alice@example.com" },
|
|
70
|
+
parents: [{ type: "DocMgmt::Role", id: "admin" }],
|
|
71
|
+
},
|
|
72
|
+
{ uid: { type: "DocMgmt::Role", id: "admin" }, attrs: {}, parents: [] },
|
|
73
|
+
{
|
|
74
|
+
uid: { type: "DocMgmt::Document", id: "doc-public" },
|
|
75
|
+
attrs: { owner: "alice", classification: "public" },
|
|
76
|
+
parents: [{ type: "DocMgmt::Folder", id: "shared" }],
|
|
77
|
+
},
|
|
78
|
+
{ uid: { type: "DocMgmt::Folder", id: "shared" }, attrs: {}, parents: [] },
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
function parseToolResult(result: unknown): unknown {
|
|
82
|
+
const r = result as { content?: Array<{ type: string; text?: string }> };
|
|
83
|
+
const textBlock = r.content?.find((b) => b.type === "text");
|
|
84
|
+
if (!textBlock?.text) throw new Error("No text content in tool result");
|
|
85
|
+
return JSON.parse(textBlock.text);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Find an unused TCP port by binding to 0 and reading what the OS gave us. */
|
|
89
|
+
function getFreePort(): Promise<number> {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const srv = createNetServer();
|
|
92
|
+
srv.unref();
|
|
93
|
+
srv.on("error", reject);
|
|
94
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
95
|
+
const addr = srv.address();
|
|
96
|
+
if (addr && typeof addr === "object") {
|
|
97
|
+
const port = addr.port;
|
|
98
|
+
srv.close(() => resolve(port));
|
|
99
|
+
} else {
|
|
100
|
+
srv.close();
|
|
101
|
+
reject(new Error("Could not determine ephemeral port"));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("integration HTTP smoke", () => {
|
|
108
|
+
let running: RunningHttpServer;
|
|
109
|
+
let baseUrl: string;
|
|
110
|
+
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
const port = await getFreePort();
|
|
113
|
+
running = await startHttpServer({ port, host: "127.0.0.1" });
|
|
114
|
+
baseUrl = `http://127.0.0.1:${port}`;
|
|
115
|
+
}, 20_000);
|
|
116
|
+
|
|
117
|
+
afterAll(async () => {
|
|
118
|
+
if (running) await running.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("H1 — listTools returns the 17 registered tools", async () => {
|
|
122
|
+
const client = new Client({ name: "http-smoke", version: "1.0.0" }, { capabilities: {} });
|
|
123
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
124
|
+
await client.connect(transport);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const { tools } = await client.listTools();
|
|
128
|
+
const names = tools.map((t) => t.name).sort();
|
|
129
|
+
// Asserts on both presence (semantic) and total count (regression guard).
|
|
130
|
+
// If a tool is added but not registered, total mismatches; if a tool is
|
|
131
|
+
// dropped, the .toContain check catches that.
|
|
132
|
+
expect(names).toContain("cedar_validate");
|
|
133
|
+
expect(names).toContain("cedar_authorize");
|
|
134
|
+
expect(names).toContain("cedar_authorize_batch");
|
|
135
|
+
expect(names).toContain("cedar_validate_schema");
|
|
136
|
+
expect(names).toContain("cedar_diff_schema");
|
|
137
|
+
expect(names).toContain("cedar_validate_entities");
|
|
138
|
+
expect(names).toContain("cedar_validate_template");
|
|
139
|
+
expect(names).toContain("cedar_link_template");
|
|
140
|
+
expect(names).toContain("cedar_list_templates");
|
|
141
|
+
expect(names).toContain("cedar_list_template_links");
|
|
142
|
+
expect(names).toContain("cedar_diff_policy_stores");
|
|
143
|
+
expect(names).toContain("cedar_advise");
|
|
144
|
+
expect(names).toHaveLength(17);
|
|
145
|
+
} finally {
|
|
146
|
+
await client.close();
|
|
147
|
+
}
|
|
148
|
+
}, 15_000);
|
|
149
|
+
|
|
150
|
+
it("H2 — cedar_validate via HTTP returns valid:true for correct policy + schema", async () => {
|
|
151
|
+
const client = new Client({ name: "http-smoke", version: "1.0.0" }, { capabilities: {} });
|
|
152
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
153
|
+
await client.connect(transport);
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const raw = await client.callTool({
|
|
157
|
+
name: "cedar_validate",
|
|
158
|
+
arguments: { policies: ADMIN_POLICY, schema: DOCMGMT_SCHEMA },
|
|
159
|
+
});
|
|
160
|
+
const result = parseToolResult(raw) as { valid: boolean; errors: unknown[]; policy_count: number };
|
|
161
|
+
expect(result.valid).toBe(true);
|
|
162
|
+
expect(result.errors).toHaveLength(0);
|
|
163
|
+
expect(result.policy_count).toBe(1);
|
|
164
|
+
} finally {
|
|
165
|
+
await client.close();
|
|
166
|
+
}
|
|
167
|
+
}, 15_000);
|
|
168
|
+
|
|
169
|
+
it("H3 — cedar_authorize via HTTP returns Allow for alice reading doc-public", async () => {
|
|
170
|
+
const client = new Client({ name: "http-smoke", version: "1.0.0" }, { capabilities: {} });
|
|
171
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
172
|
+
await client.connect(transport);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const raw = await client.callTool({
|
|
176
|
+
name: "cedar_authorize",
|
|
177
|
+
arguments: {
|
|
178
|
+
policies: ADMIN_POLICY,
|
|
179
|
+
principal: 'DocMgmt::User::"alice"',
|
|
180
|
+
action: 'DocMgmt::Action::"read"',
|
|
181
|
+
resource: 'DocMgmt::Document::"doc-public"',
|
|
182
|
+
entities: ALICE_ENTITIES,
|
|
183
|
+
schema: DOCMGMT_SCHEMA,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const result = parseToolResult(raw) as { decision: string };
|
|
187
|
+
expect(result.decision).toBe("Allow");
|
|
188
|
+
} finally {
|
|
189
|
+
await client.close();
|
|
190
|
+
}
|
|
191
|
+
}, 15_000);
|
|
192
|
+
|
|
193
|
+
it("H4 — /health endpoint returns ok JSON", async () => {
|
|
194
|
+
const res = await fetch(`${baseUrl}/health`);
|
|
195
|
+
expect(res.status).toBe(200);
|
|
196
|
+
const body = await res.json();
|
|
197
|
+
expect(body).toMatchObject({ status: "ok", transport: "streamable-http", mode: "stateful" });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("H6 — three concurrent sessions don't interfere; each gets correct correlated responses", async () => {
|
|
201
|
+
// True transport-level concurrency: three independent Clients, each with its
|
|
202
|
+
// own Mcp-Session-Id from the server's stateful sessionIdGenerator, hitting
|
|
203
|
+
// the HTTP endpoint in parallel via Promise.all. Failure case: session
|
|
204
|
+
// routing mixes responses between sessions, or shared transport state across
|
|
205
|
+
// sessions corrupts the message history. The stdio F11 test in failure-modes
|
|
206
|
+
// covers ID routing within a single session; this test covers ID routing
|
|
207
|
+
// ACROSS sessions, which only HTTP supports.
|
|
208
|
+
|
|
209
|
+
const clients = [0, 1, 2].map(() => new Client(
|
|
210
|
+
{ name: "http-smoke-concurrent", version: "1.0.0" },
|
|
211
|
+
{ capabilities: {} }
|
|
212
|
+
));
|
|
213
|
+
const transports = [0, 1, 2].map(() => new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`)));
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// Connect all three in parallel
|
|
217
|
+
await Promise.all(clients.map((c, i) => c.connect(transports[i]!)));
|
|
218
|
+
|
|
219
|
+
// Each client identifies its request via a unique role name in the policy.
|
|
220
|
+
// If session routing mixed responses, client[i] would receive role-${j}
|
|
221
|
+
// (j ≠ i) in its response body.
|
|
222
|
+
const calls = clients.map((c, i) =>
|
|
223
|
+
c.callTool({
|
|
224
|
+
name: "cedar_explain",
|
|
225
|
+
arguments: {
|
|
226
|
+
policy: `permit (principal in DocMgmt::Role::"role-session-${i}", action, resource);`,
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
const results = await Promise.all(calls);
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < results.length; i++) {
|
|
233
|
+
const parsed = parseToolResult(results[i]!);
|
|
234
|
+
const body = JSON.stringify(parsed);
|
|
235
|
+
expect(body, `session ${i} response`).toContain(`role-session-${i}`);
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
await Promise.all(clients.map((c) => c.close().catch(() => {})));
|
|
239
|
+
}
|
|
240
|
+
}, 30_000);
|
|
241
|
+
|
|
242
|
+
it("H7 — schema_ref via cedar:// URI does NOT resolve in HTTP mode without --root configured", async () => {
|
|
243
|
+
// Sanity: without --root flags, the storeManager has no stores. A client
|
|
244
|
+
// passing schema_ref: "cedar://schema/anything" should get a clean error,
|
|
245
|
+
// not a hang or a crash. This documents the boundary between the default
|
|
246
|
+
// HTTP server (no roots) and the configured HTTP deployment (with roots,
|
|
247
|
+
// covered by the H-ROOT suite below).
|
|
248
|
+
const client = new Client({ name: "http-no-roots", version: "1.0.0" }, { capabilities: {} });
|
|
249
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
250
|
+
await client.connect(transport);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const raw = await client.callTool({
|
|
254
|
+
name: "cedar_validate",
|
|
255
|
+
arguments: {
|
|
256
|
+
policies: ADMIN_POLICY,
|
|
257
|
+
schema_ref: "cedar://schema/nonexistent",
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
const result = raw as { content: Array<{ type: string; text?: string }> };
|
|
261
|
+
const textBlock = result.content.find((b) => b.type === "text");
|
|
262
|
+
const parsed = JSON.parse(textBlock!.text!);
|
|
263
|
+
expect(parsed.error).toBeDefined();
|
|
264
|
+
} finally {
|
|
265
|
+
await client.close();
|
|
266
|
+
}
|
|
267
|
+
}, 15_000);
|
|
268
|
+
|
|
269
|
+
it("H5 — malformed JSON-RPC body returns a structured error, not a crash", async () => {
|
|
270
|
+
// Send a payload that's syntactically valid JSON but not valid JSON-RPC.
|
|
271
|
+
// The transport must respond with a structured error (HTTP 400/422 or a
|
|
272
|
+
// JSON-RPC error envelope) — never a 500 with a raw stack, and never a
|
|
273
|
+
// hung connection. This is the falsification case for "what would prove
|
|
274
|
+
// the transport robust to malformed input?"
|
|
275
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { "content-type": "application/json", "accept": "application/json, text/event-stream" },
|
|
278
|
+
body: JSON.stringify({ not_a: "valid", jsonrpc_message: true }),
|
|
279
|
+
});
|
|
280
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
281
|
+
expect(res.status).toBeLessThan(600);
|
|
282
|
+
// Body should parse as JSON (structured error), not be HTML or empty.
|
|
283
|
+
const text = await res.text();
|
|
284
|
+
expect(text.length).toBeGreaterThan(0);
|
|
285
|
+
// Don't assert on exact shape — different MCP SDK versions structure the error differently.
|
|
286
|
+
// Key invariant: no server crash, and a response was returned.
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* H-ROOT suite — exercises the actual deployment path the peer review called
|
|
292
|
+
* out: cedar-mcp-server --http <port> --root name=path. Without this, the
|
|
293
|
+
* other HTTP smoke tests pass without ever invoking the cedar:// resolution
|
|
294
|
+
* path that real deployments depend on. Closes the gap.
|
|
295
|
+
*/
|
|
296
|
+
describe("integration HTTP smoke — with --root configured", () => {
|
|
297
|
+
let running: RunningHttpServer;
|
|
298
|
+
let baseUrl: string;
|
|
299
|
+
let storeDir: string;
|
|
300
|
+
|
|
301
|
+
const STORE_SCHEMA = `namespace DocMgmt {
|
|
302
|
+
entity User in [Role] = { name: String, email: String };
|
|
303
|
+
entity Role;
|
|
304
|
+
entity Document in [Folder] = { owner: String, classification: String };
|
|
305
|
+
entity Folder;
|
|
306
|
+
action read appliesTo {
|
|
307
|
+
principal: [User],
|
|
308
|
+
resource: [Document]
|
|
309
|
+
};
|
|
310
|
+
}`.trim();
|
|
311
|
+
|
|
312
|
+
const STORE_ADMIN_POLICY = `permit (
|
|
313
|
+
principal in DocMgmt::Role::"admin",
|
|
314
|
+
action,
|
|
315
|
+
resource
|
|
316
|
+
);`;
|
|
317
|
+
|
|
318
|
+
const STORE_ENTITIES_FILE = JSON.stringify([
|
|
319
|
+
{ uid: { type: "DocMgmt::User", id: "alice" }, attrs: { name: "Alice", email: "a@b.c" }, parents: [{ type: "DocMgmt::Role", id: "admin" }] },
|
|
320
|
+
{ uid: { type: "DocMgmt::Role", id: "admin" }, attrs: {}, parents: [] },
|
|
321
|
+
{ uid: { type: "DocMgmt::Document", id: "doc-public" }, attrs: { owner: "alice", classification: "public" }, parents: [{ type: "DocMgmt::Folder", id: "shared" }] },
|
|
322
|
+
{ uid: { type: "DocMgmt::Folder", id: "shared" }, attrs: {}, parents: [] },
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
beforeAll(async () => {
|
|
326
|
+
// Build a real on-disk policy store
|
|
327
|
+
storeDir = mkdtempSync(join(tmpdir(), "cedar-mcp-http-root-"));
|
|
328
|
+
mkdirSync(join(storeDir, "policies"), { recursive: true });
|
|
329
|
+
mkdirSync(join(storeDir, "entities"), { recursive: true });
|
|
330
|
+
writeFileSync(join(storeDir, "policies", "admin.cedar"), STORE_ADMIN_POLICY);
|
|
331
|
+
writeFileSync(join(storeDir, "schema.cedarschema"), STORE_SCHEMA);
|
|
332
|
+
writeFileSync(join(storeDir, "entities", "alice-and-docs.json"), STORE_ENTITIES_FILE);
|
|
333
|
+
|
|
334
|
+
const port = await getFreePort();
|
|
335
|
+
running = await startHttpServer({
|
|
336
|
+
port,
|
|
337
|
+
host: "127.0.0.1",
|
|
338
|
+
roots: [{ name: "test-store", path: storeDir }],
|
|
339
|
+
});
|
|
340
|
+
baseUrl = `http://127.0.0.1:${port}`;
|
|
341
|
+
}, 30_000);
|
|
342
|
+
|
|
343
|
+
afterAll(async () => {
|
|
344
|
+
if (running) await running.close();
|
|
345
|
+
if (storeDir) {
|
|
346
|
+
try { rmSync(storeDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("HR1 — cedar://policies/test-store resolves all .cedar files through HTTP", async () => {
|
|
351
|
+
// The deployment path: a client passes policy_ref instead of inlining
|
|
352
|
+
// policy text. The HTTP server resolves the URI via the deployer-configured
|
|
353
|
+
// store. Without this test, the entire "shared remote MCP for a team"
|
|
354
|
+
// value prop was untested over HTTP.
|
|
355
|
+
const client = new Client({ name: "http-root", version: "1.0.0" }, { capabilities: {} });
|
|
356
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
357
|
+
await client.connect(transport);
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const raw = await client.callTool({
|
|
361
|
+
name: "cedar_validate",
|
|
362
|
+
arguments: {
|
|
363
|
+
policy_ref: "cedar://policies/test-store",
|
|
364
|
+
schema_ref: "cedar://schema/test-store",
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
const result = raw as { content: Array<{ type: string; text?: string }> };
|
|
368
|
+
const parsed = JSON.parse(result.content.find((b) => b.type === "text")!.text!) as { valid: boolean; policy_count: number };
|
|
369
|
+
expect(parsed.valid).toBe(true);
|
|
370
|
+
expect(parsed.policy_count).toBe(1);
|
|
371
|
+
} finally {
|
|
372
|
+
await client.close();
|
|
373
|
+
}
|
|
374
|
+
}, 20_000);
|
|
375
|
+
|
|
376
|
+
it("HR2 — cedar://entities/test-store + cedar_authorize via entities_ref returns Allow", async () => {
|
|
377
|
+
// The entities_ref path. Even reading a single file (alice-and-docs.json)
|
|
378
|
+
// via cedar://entities/test-store/alice-and-docs is non-trivial — it
|
|
379
|
+
// exercises the store's listEntities/readEntities pair through the HTTP
|
|
380
|
+
// transport, then routes the resolved content into the authorize call.
|
|
381
|
+
const client = new Client({ name: "http-root-auth", version: "1.0.0" }, { capabilities: {} });
|
|
382
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
383
|
+
await client.connect(transport);
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const raw = await client.callTool({
|
|
387
|
+
name: "cedar_authorize",
|
|
388
|
+
arguments: {
|
|
389
|
+
policy_ref: "cedar://policies/test-store",
|
|
390
|
+
schema_ref: "cedar://schema/test-store",
|
|
391
|
+
entities_ref: "cedar://entities/test-store",
|
|
392
|
+
principal: 'DocMgmt::User::"alice"',
|
|
393
|
+
action: 'DocMgmt::Action::"read"',
|
|
394
|
+
resource: 'DocMgmt::Document::"doc-public"',
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
const result = raw as { content: Array<{ type: string; text?: string }> };
|
|
398
|
+
const parsed = JSON.parse(result.content.find((b) => b.type === "text")!.text!) as { decision: string };
|
|
399
|
+
expect(parsed.decision).toBe("Allow");
|
|
400
|
+
} finally {
|
|
401
|
+
await client.close();
|
|
402
|
+
}
|
|
403
|
+
}, 20_000);
|
|
404
|
+
|
|
405
|
+
it("HR3 — cedar://policies/{store}/{id} resolves a single file by id", async () => {
|
|
406
|
+
// The fine-grained resolution path. Failure case: a resolver that requires
|
|
407
|
+
// store-level listing would fail when given an id-specific URI, or vice
|
|
408
|
+
// versa. This proves both granularities work through HTTP.
|
|
409
|
+
const client = new Client({ name: "http-root-single", version: "1.0.0" }, { capabilities: {} });
|
|
410
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
411
|
+
await client.connect(transport);
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const raw = await client.callTool({
|
|
415
|
+
name: "cedar_validate",
|
|
416
|
+
arguments: {
|
|
417
|
+
policy_ref: "cedar://policies/test-store/admin",
|
|
418
|
+
schema_ref: "cedar://schema/test-store",
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
const result = raw as { content: Array<{ type: string; text?: string }> };
|
|
422
|
+
const parsed = JSON.parse(result.content.find((b) => b.type === "text")!.text!) as { valid: boolean; policy_count: number };
|
|
423
|
+
expect(parsed.valid).toBe(true);
|
|
424
|
+
expect(parsed.policy_count).toBe(1);
|
|
425
|
+
} finally {
|
|
426
|
+
await client.close();
|
|
427
|
+
}
|
|
428
|
+
}, 20_000);
|
|
429
|
+
|
|
430
|
+
it("HR5 — max-sessions cap returns HTTP 503 instead of unbounded growth", async () => {
|
|
431
|
+
// The failure mode this guards against: long-running deployments leaking
|
|
432
|
+
// sessions if transport.onclose doesn't fire (TCP RST, network partition,
|
|
433
|
+
// misbehaving client). Without a cap the sessions Map grows unbounded.
|
|
434
|
+
//
|
|
435
|
+
// This test starts a dedicated server with maxSessions: 2, opens 2 valid
|
|
436
|
+
// sessions, then verifies a third initialize request returns HTTP 503
|
|
437
|
+
// with a structured error body.
|
|
438
|
+
const port = await getFreePort();
|
|
439
|
+
const cappedServer = await startHttpServer({
|
|
440
|
+
port,
|
|
441
|
+
host: "127.0.0.1",
|
|
442
|
+
maxSessions: 2,
|
|
443
|
+
reaperIntervalMs: 60_000, // disable reaper for this test
|
|
444
|
+
});
|
|
445
|
+
const cappedUrl = `http://127.0.0.1:${port}/mcp`;
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
// Fill the cap with two real sessions
|
|
449
|
+
const c1 = new Client({ name: "cap-1", version: "1.0.0" }, { capabilities: {} });
|
|
450
|
+
const t1 = new StreamableHTTPClientTransport(new URL(cappedUrl));
|
|
451
|
+
await c1.connect(t1);
|
|
452
|
+
await c1.listTools();
|
|
453
|
+
|
|
454
|
+
const c2 = new Client({ name: "cap-2", version: "1.0.0" }, { capabilities: {} });
|
|
455
|
+
const t2 = new StreamableHTTPClientTransport(new URL(cappedUrl));
|
|
456
|
+
await c2.connect(t2);
|
|
457
|
+
await c2.listTools();
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
// Third initialize attempt — must hit the cap. Use raw fetch so we can
|
|
461
|
+
// observe the HTTP status code directly (SDK client would just throw).
|
|
462
|
+
const res = await fetch(cappedUrl, {
|
|
463
|
+
method: "POST",
|
|
464
|
+
headers: { "content-type": "application/json", "accept": "application/json, text/event-stream" },
|
|
465
|
+
body: JSON.stringify({
|
|
466
|
+
jsonrpc: "2.0",
|
|
467
|
+
id: 1,
|
|
468
|
+
method: "initialize",
|
|
469
|
+
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "cap-overflow", version: "1.0.0" } },
|
|
470
|
+
}),
|
|
471
|
+
});
|
|
472
|
+
expect(res.status).toBe(503);
|
|
473
|
+
const body = await res.json() as { error: string; active_sessions: number; max_sessions: number };
|
|
474
|
+
expect(body.error).toMatch(/too many|max-session/i);
|
|
475
|
+
expect(body.max_sessions).toBe(2);
|
|
476
|
+
expect(body.active_sessions).toBe(2);
|
|
477
|
+
|
|
478
|
+
// Health should reflect the cap as configured
|
|
479
|
+
const health = await (await fetch(`http://127.0.0.1:${port}/health`)).json() as { max_sessions: number };
|
|
480
|
+
expect(health.max_sessions).toBe(2);
|
|
481
|
+
} finally {
|
|
482
|
+
await c1.close().catch(() => { /* ignore */ });
|
|
483
|
+
await c2.close().catch(() => { /* ignore */ });
|
|
484
|
+
}
|
|
485
|
+
} finally {
|
|
486
|
+
await cappedServer.close();
|
|
487
|
+
}
|
|
488
|
+
}, 30_000);
|
|
489
|
+
|
|
490
|
+
it("HR6 — idle sessions are evicted by the reaper after the configured TTL", async () => {
|
|
491
|
+
// The failure mode: transport.onclose unreliable on network faults; without
|
|
492
|
+
// the reaper, the sessions Map leaks. This test sets a very short idle TTL
|
|
493
|
+
// (200ms) and a fast reaper interval (50ms), opens a session, waits long
|
|
494
|
+
// enough for eviction, then verifies active_sessions drops to 0.
|
|
495
|
+
//
|
|
496
|
+
// We don't try to use the evicted session afterward — the SDK client would
|
|
497
|
+
// try to send on a stale transport and get a 404 (the spec'd response for
|
|
498
|
+
// unknown session id). That's a separate test that depends on the SDK
|
|
499
|
+
// client's error semantics.
|
|
500
|
+
const port = await getFreePort();
|
|
501
|
+
const reaperServer = await startHttpServer({
|
|
502
|
+
port,
|
|
503
|
+
host: "127.0.0.1",
|
|
504
|
+
sessionIdleTtlMs: 200,
|
|
505
|
+
reaperIntervalMs: 50,
|
|
506
|
+
});
|
|
507
|
+
const reaperUrl = `http://127.0.0.1:${port}`;
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
const client = new Client({ name: "reap-target", version: "1.0.0" }, { capabilities: {} });
|
|
511
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${reaperUrl}/mcp`));
|
|
512
|
+
await client.connect(transport);
|
|
513
|
+
await client.listTools();
|
|
514
|
+
|
|
515
|
+
// Confirm the session is registered
|
|
516
|
+
const before = await (await fetch(`${reaperUrl}/health`)).json() as { active_sessions: number };
|
|
517
|
+
expect(before.active_sessions).toBeGreaterThanOrEqual(1);
|
|
518
|
+
|
|
519
|
+
// Wait > (idle TTL + reaper interval) so eviction definitely fires
|
|
520
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
|
521
|
+
|
|
522
|
+
const after = await (await fetch(`${reaperUrl}/health`)).json() as { active_sessions: number };
|
|
523
|
+
// After idle eviction, the session count must drop. (Note: client may
|
|
524
|
+
// not have called .close() at this point, but the reaper acted on its
|
|
525
|
+
// own.)
|
|
526
|
+
expect(after.active_sessions).toBeLessThan(before.active_sessions);
|
|
527
|
+
|
|
528
|
+
// Cleanup client — it'll get an error closing over evicted transport, ignore
|
|
529
|
+
await client.close().catch(() => { /* ignore */ });
|
|
530
|
+
} finally {
|
|
531
|
+
await reaperServer.close();
|
|
532
|
+
}
|
|
533
|
+
}, 30_000);
|
|
534
|
+
|
|
535
|
+
it("HR4 — /health exposes active_sessions count + the deployer roots model", async () => {
|
|
536
|
+
// The /health endpoint reports active_sessions. Open a session, hit /health,
|
|
537
|
+
// verify count went up. Catches a leak where active_sessions doesn't
|
|
538
|
+
// reflect the actual map state, OR a bug where the map double-counts.
|
|
539
|
+
const before = await (await fetch(`${baseUrl}/health`)).json() as { active_sessions: number };
|
|
540
|
+
|
|
541
|
+
const client = new Client({ name: "http-root-health", version: "1.0.0" }, { capabilities: {} });
|
|
542
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
543
|
+
await client.connect(transport);
|
|
544
|
+
// Force the session to fully initialize by making one round-trip
|
|
545
|
+
await client.listTools();
|
|
546
|
+
|
|
547
|
+
const during = await (await fetch(`${baseUrl}/health`)).json() as { active_sessions: number };
|
|
548
|
+
expect(during.active_sessions).toBeGreaterThanOrEqual(before.active_sessions + 1);
|
|
549
|
+
|
|
550
|
+
await client.close();
|
|
551
|
+
}, 20_000);
|
|
552
|
+
|
|
553
|
+
it("HR7 — resources/list enumerates cedar:// URIs from loaded roots (H2)", async () => {
|
|
554
|
+
// The MCP resources/list method must enumerate the cedar:// URIs the
|
|
555
|
+
// server can actually serve, so UI-driven MCP clients (and any client
|
|
556
|
+
// not pre-trained on the URI scheme) can discover what is available.
|
|
557
|
+
// Before H2 fixed this, the server returned an empty list even though
|
|
558
|
+
// every cedar:// URI resolved correctly when referenced as policy_ref,
|
|
559
|
+
// schema_ref, or entities_ref.
|
|
560
|
+
const client = new Client({ name: "http-root-list-resources", version: "1.0.0" }, { capabilities: {} });
|
|
561
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
562
|
+
await client.connect(transport);
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
const { resources } = await client.listResources();
|
|
566
|
+
const uris = resources.map((r) => r.uri);
|
|
567
|
+
|
|
568
|
+
// Per-item resources for the test-store: 1 policy + 1 schema + 1 entities file
|
|
569
|
+
expect(uris).toContain("cedar://policies/test-store/admin");
|
|
570
|
+
expect(uris).toContain("cedar://schema/test-store");
|
|
571
|
+
expect(uris).toContain("cedar://entities/test-store/alice-and-docs");
|
|
572
|
+
|
|
573
|
+
// Index resources (the {store} listing endpoints)
|
|
574
|
+
expect(uris).toContain("cedar://policies/test-store");
|
|
575
|
+
expect(uris).toContain("cedar://entities/test-store");
|
|
576
|
+
|
|
577
|
+
// Sanity: total should be at least 5 (1 policy + 1 schema + 1 entities + 2 indexes)
|
|
578
|
+
expect(uris.length).toBeGreaterThanOrEqual(5);
|
|
579
|
+
|
|
580
|
+
// Every resource must carry a non-empty name field (MCP requires it)
|
|
581
|
+
for (const r of resources) {
|
|
582
|
+
expect(r.name, `resource ${r.uri} missing name`).toBeTruthy();
|
|
583
|
+
}
|
|
584
|
+
} finally {
|
|
585
|
+
await client.close();
|
|
586
|
+
}
|
|
587
|
+
}, 20_000);
|
|
588
|
+
});
|