create-m5kdev 0.35.0 → 0.37.2

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 (61) hide show
  1. package/package.json +3 -3
  2. package/templates/minimal-app/.dockerignore.tpl +6 -0
  3. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +3 -0
  4. package/templates/minimal-app/apps/e2e/servers.ts.tpl +4 -0
  5. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +2 -34
  6. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +225 -5
  7. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +47 -1
  8. package/templates/minimal-app/apps/e2e/tests/mcp-helpers.ts.tpl +406 -0
  9. package/templates/minimal-app/apps/e2e/tests/mcp.spec.ts.tpl +108 -0
  10. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +290 -8
  11. package/templates/minimal-app/apps/expo/app.json.tpl +1 -0
  12. package/templates/minimal-app/apps/expo/package.json.tpl +4 -0
  13. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +4 -1
  14. package/templates/minimal-app/apps/expo/src/app/notifications.tsx.tpl +70 -0
  15. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +8 -16
  16. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +4 -1
  17. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +11 -2
  18. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +2 -3
  19. package/templates/minimal-app/apps/server/AGENTS.md.tpl +10 -0
  20. package/templates/minimal-app/apps/server/db.e2e.ts.tpl +6 -40
  21. package/templates/minimal-app/apps/server/db.ts.tpl +6 -42
  22. package/templates/minimal-app/apps/server/drizzle/{0000_wealthy_absorbing_man.sql → 0000_whole_echo.sql} +102 -3
  23. package/templates/minimal-app/apps/server/drizzle/0001_yummy_spectrum.sql +26 -0
  24. package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +47 -0
  25. package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +4 -0
  26. package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +99 -0
  27. package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +2 -0
  28. package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +159 -0
  29. package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +2 -0
  30. package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +87 -0
  31. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2625 -1780
  32. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2671 -1796
  33. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2716 -1818
  34. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +715 -164
  35. package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2498 -0
  36. package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2484 -0
  37. package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3603 -0
  38. package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3620 -0
  39. package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3620 -0
  40. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +44 -9
  41. package/templates/minimal-app/apps/server/src/app.ts.tpl +12 -2
  42. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +2 -2
  43. package/templates/minimal-app/apps/server/src/modules/posts/posts.mcp.ts.tpl +17 -0
  44. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +11 -0
  45. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +14 -5
  46. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +13 -36
  47. package/templates/minimal-app/apps/server/src/schema.ts.tpl +16 -2
  48. package/templates/minimal-app/apps/shared/package.json.tpl +1 -0
  49. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
  50. package/templates/minimal-app/apps/shared/src/modules/notification/notification.constants.ts.tpl +5 -0
  51. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +1 -1
  52. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +17 -1
  53. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +10 -8
  54. package/templates/minimal-app/apps/webapp/src/modules/notification/NotificationPreferencesRoute.tsx.tpl +29 -0
  55. package/templates/minimal-app/apps/webapp/src/modules/notification/StarterWebPush.tsx.tpl +44 -0
  56. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +8 -0
  57. package/templates/minimal-app/pnpm-workspace.yaml.tpl +14 -13
  58. package/templates/minimal-app/template.manifest.json +9 -0
  59. package/templates/minimal-app/apps/server/drizzle/0001_third_ink.sql +0 -1
  60. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +0 -3
  61. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +0 -27
@@ -0,0 +1,406 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer, type Server } from "node:http";
3
+ import {
4
+ E2E_MCP_CIMD_CLIENT_ID,
5
+ E2E_MCP_CIMD_REDIRECT_URI,
6
+ } from "@m5kdev/backend/modules/auth/auth.mcp-e2e";
7
+ import {
8
+ MCP_AUTH_ISSUER_PATH,
9
+ MCP_AUTHORIZATION_SERVER_METADATA_PATH,
10
+ MCP_HTTP_PATH,
11
+ MCP_PROTECTED_RESOURCE_METADATA_PATH,
12
+ mcpResourceUrl,
13
+ } from "@m5kdev/backend/modules/mcp/mcp.types";
14
+ import type { APIRequestContext, APIResponse, Page } from "@playwright/test";
15
+ import { expect } from "@playwright/test";
16
+
17
+ export const MCP_PROTOCOL_VERSION = "2026-07-28";
18
+
19
+ export interface McpOAuthDiscovery {
20
+ resource: string;
21
+ authorizationEndpoint: string;
22
+ tokenEndpoint: string;
23
+ scopes: string[];
24
+ }
25
+
26
+ export interface McpOAuthSession {
27
+ accessToken: string;
28
+ redirectUri: string;
29
+ close(): Promise<void>;
30
+ }
31
+
32
+ interface AuthorizationServerMetadata {
33
+ authorization_endpoint: string;
34
+ token_endpoint: string;
35
+ scopes_supported: string[];
36
+ }
37
+
38
+ interface ProtectedResourceMetadata {
39
+ resource?: string;
40
+ scopes_supported: string[];
41
+ }
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ function readString(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.length > 0 ? value : undefined;
49
+ }
50
+
51
+ function readStringArray(value: unknown): string[] {
52
+ if (!Array.isArray(value)) return [];
53
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
54
+ }
55
+
56
+ function pkce(): { verifier: string; challenge: string } {
57
+ const verifier = randomBytes(32).toString("base64url");
58
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
59
+ return { verifier, challenge };
60
+ }
61
+
62
+ async function readJson(response: Response): Promise<unknown> {
63
+ const text = await response.text();
64
+ if (!response.ok) {
65
+ throw new Error(`${response.status} ${response.statusText}: ${text}`);
66
+ }
67
+ try {
68
+ return JSON.parse(text) as unknown;
69
+ } catch {
70
+ throw new Error(`Expected JSON from ${response.url}, received: ${text.slice(0, 300)}`);
71
+ }
72
+ }
73
+
74
+ function parseAuthorizationServerMetadata(value: unknown): AuthorizationServerMetadata {
75
+ if (!isRecord(value)) {
76
+ throw new Error("Authorization server metadata was not an object");
77
+ }
78
+ const authorizationEndpoint = readString(value.authorization_endpoint);
79
+ const tokenEndpoint = readString(value.token_endpoint);
80
+ if (!authorizationEndpoint || !tokenEndpoint) {
81
+ throw new Error("Authorization server metadata is missing endpoints");
82
+ }
83
+ return {
84
+ authorization_endpoint: authorizationEndpoint,
85
+ token_endpoint: tokenEndpoint,
86
+ scopes_supported: readStringArray(value.scopes_supported),
87
+ };
88
+ }
89
+
90
+ function parseProtectedResourceMetadata(value: unknown): ProtectedResourceMetadata {
91
+ if (!isRecord(value)) {
92
+ return { scopes_supported: [] };
93
+ }
94
+ return {
95
+ resource: readString(value.resource),
96
+ scopes_supported: readStringArray(value.scopes_supported),
97
+ };
98
+ }
99
+
100
+ export async function discoverMcpOAuth(serverUrl: string): Promise<McpOAuthDiscovery> {
101
+ const protectedResourceResponse = await fetch(
102
+ `${serverUrl}${MCP_PROTECTED_RESOURCE_METADATA_PATH}/mcp`
103
+ );
104
+ const protectedResource = parseProtectedResourceMetadata(
105
+ await readJson(protectedResourceResponse)
106
+ );
107
+ const authorizationServer = parseAuthorizationServerMetadata(
108
+ await readJson(
109
+ await fetch(`${serverUrl}${MCP_AUTHORIZATION_SERVER_METADATA_PATH}${MCP_AUTH_ISSUER_PATH}`)
110
+ )
111
+ );
112
+ const resource = protectedResource.resource ?? mcpResourceUrl(serverUrl);
113
+ const scopes =
114
+ protectedResource.scopes_supported.length > 0
115
+ ? protectedResource.scopes_supported
116
+ : authorizationServer.scopes_supported.length > 0
117
+ ? authorizationServer.scopes_supported
118
+ : ["openid"];
119
+ return {
120
+ resource,
121
+ authorizationEndpoint: authorizationServer.authorization_endpoint,
122
+ tokenEndpoint: authorizationServer.token_endpoint,
123
+ scopes,
124
+ };
125
+ }
126
+
127
+ function startCallbackServer(): Promise<{
128
+ redirectUri: string;
129
+ waitForCode: Promise<string>;
130
+ close: () => Promise<void>;
131
+ }> {
132
+ return new Promise((resolveStart, rejectStart) => {
133
+ let settleCode: ((code: string) => void) | undefined;
134
+ let failCode: ((error: Error) => void) | undefined;
135
+ const waitForCode = new Promise<string>((resolve, reject) => {
136
+ settleCode = resolve;
137
+ failCode = reject;
138
+ });
139
+
140
+ const server: Server = createServer((req, res) => {
141
+ const requestUrl = new URL(req.url ?? "/", E2E_MCP_CIMD_REDIRECT_URI);
142
+ if (requestUrl.pathname !== "/callback") {
143
+ res.writeHead(404);
144
+ res.end();
145
+ return;
146
+ }
147
+ const error = requestUrl.searchParams.get("error");
148
+ const code = requestUrl.searchParams.get("code");
149
+ res.writeHead(200, { "content-type": "text/plain" });
150
+ if (error) {
151
+ const description = requestUrl.searchParams.get("error_description") ?? error;
152
+ res.end(description);
153
+ failCode?.(new Error(description));
154
+ return;
155
+ }
156
+ if (!code) {
157
+ res.end("missing code");
158
+ failCode?.(new Error("OAuth callback did not include a code"));
159
+ return;
160
+ }
161
+ res.end("ok");
162
+ settleCode?.(code);
163
+ });
164
+
165
+ server.once("error", rejectStart);
166
+ server.listen(0, "127.0.0.1", () => {
167
+ const address = server.address();
168
+ if (!address || typeof address === "string") {
169
+ rejectStart(new Error("OAuth callback server did not bind a port"));
170
+ return;
171
+ }
172
+ resolveStart({
173
+ redirectUri: `http://127.0.0.1:${address.port}/callback`,
174
+ waitForCode,
175
+ close: () =>
176
+ new Promise<void>((resolve, reject) => {
177
+ server.close((error) => {
178
+ if (error) reject(error);
179
+ else resolve();
180
+ });
181
+ }),
182
+ });
183
+ });
184
+ });
185
+ }
186
+
187
+ async function loginIfNeeded(page: Page, email: string, password: string): Promise<void> {
188
+ const emailField = page.locator('[name="login-email"]');
189
+ if (!(await emailField.isVisible().catch(() => false))) return;
190
+ await emailField.fill(email);
191
+ await page.locator('[name="login-password"]').fill(password);
192
+ await page.getByRole("button", { name: /^login$/i }).click();
193
+ }
194
+
195
+ async function allowConsent(page: Page, organization: { id: string; name: string }): Promise<void> {
196
+ await expect(
197
+ page.getByText("Allow this MCP client"),
198
+ `expected MCP consent page, at ${page.url()}`
199
+ ).toBeVisible({ timeout: 20_000 });
200
+ const orgRow = page.getByTestId(`mcp-consent-org-${organization.id}`);
201
+ await expect(orgRow).toBeVisible({ timeout: 15_000 });
202
+ const checkbox = orgRow.getByRole("checkbox");
203
+ if (!(await checkbox.isChecked())) {
204
+ await orgRow.locator('[data-slot="checkbox-control"]').click();
205
+ await expect(checkbox).toBeChecked();
206
+ }
207
+ await page.getByTestId("mcp-consent-allow").click();
208
+ await expect(page, `expected OAuth redirect after consent, at ${page.url()}`).not.toHaveURL(
209
+ /\/consent(?:\?|$)/,
210
+ { timeout: 20_000 }
211
+ );
212
+ }
213
+
214
+ async function exchangeAuthorizationCode(input: {
215
+ tokenEndpoint: string;
216
+ code: string;
217
+ redirectUri: string;
218
+ verifier: string;
219
+ resource: string;
220
+ }): Promise<string> {
221
+ const body = new URLSearchParams({
222
+ grant_type: "authorization_code",
223
+ code: input.code,
224
+ redirect_uri: input.redirectUri,
225
+ client_id: E2E_MCP_CIMD_CLIENT_ID,
226
+ code_verifier: input.verifier,
227
+ resource: input.resource,
228
+ });
229
+ const response = await fetch(input.tokenEndpoint, {
230
+ method: "POST",
231
+ headers: { "content-type": "application/x-www-form-urlencoded" },
232
+ body,
233
+ });
234
+ const payload = await readJson(response);
235
+ if (!isRecord(payload)) {
236
+ throw new Error("Token response was not an object");
237
+ }
238
+ const accessToken = readString(payload.access_token);
239
+ if (!accessToken) {
240
+ throw new Error(`Token response did not include access_token: ${JSON.stringify(payload)}`);
241
+ }
242
+ return accessToken;
243
+ }
244
+
245
+ export async function authorizeMcpClient(input: {
246
+ page: Page;
247
+ serverUrl: string;
248
+ email: string;
249
+ password: string;
250
+ organization: { id: string; name: string };
251
+ }): Promise<McpOAuthSession> {
252
+ const discovery = await discoverMcpOAuth(input.serverUrl);
253
+ const { verifier, challenge } = pkce();
254
+ const state = randomBytes(16).toString("hex");
255
+ const callback = await startCallbackServer();
256
+ const authorizeUrl = new URL(discovery.authorizationEndpoint);
257
+ authorizeUrl.searchParams.set("response_type", "code");
258
+ authorizeUrl.searchParams.set("client_id", E2E_MCP_CIMD_CLIENT_ID);
259
+ authorizeUrl.searchParams.set("redirect_uri", callback.redirectUri);
260
+ authorizeUrl.searchParams.set("state", state);
261
+ authorizeUrl.searchParams.set("code_challenge", challenge);
262
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
263
+ authorizeUrl.searchParams.set("resource", discovery.resource);
264
+ authorizeUrl.searchParams.set("scope", "openid");
265
+
266
+ try {
267
+ await input.page.goto(authorizeUrl.toString());
268
+ await expect(
269
+ input.page.getByText("Allow this MCP client").or(input.page.locator('[name="login-email"]')),
270
+ `expected consent or login after authorize, at ${input.page.url()}`
271
+ ).toBeVisible({ timeout: 30_000 });
272
+ await loginIfNeeded(input.page, input.email, input.password);
273
+ await allowConsent(input.page, input.organization);
274
+ const code = await callback.waitForCode;
275
+ const accessToken = await exchangeAuthorizationCode({
276
+ tokenEndpoint: discovery.tokenEndpoint,
277
+ code,
278
+ redirectUri: callback.redirectUri,
279
+ verifier,
280
+ resource: discovery.resource,
281
+ });
282
+ return {
283
+ accessToken,
284
+ redirectUri: callback.redirectUri,
285
+ close: callback.close,
286
+ };
287
+ } catch (error) {
288
+ await callback.close().catch(() => undefined);
289
+ throw error;
290
+ }
291
+ }
292
+
293
+ function parseSseJson(body: string): unknown {
294
+ const dataLines = body
295
+ .split("\n")
296
+ .map((line) => line.trim())
297
+ .filter((line) => line.startsWith("data:"))
298
+ .map((line) => line.slice("data:".length).trim());
299
+ if (dataLines.length === 0) {
300
+ throw new Error(`SSE body did not include data: ${body.slice(0, 300)}`);
301
+ }
302
+ return JSON.parse(dataLines.join("\n")) as unknown;
303
+ }
304
+
305
+ function unwrapJsonRpc(payload: unknown): unknown {
306
+ if (!isRecord(payload)) {
307
+ throw new Error(`MCP JSON-RPC payload was not an object: ${JSON.stringify(payload)}`);
308
+ }
309
+ if ("error" in payload) {
310
+ throw new Error(`MCP JSON-RPC error: ${JSON.stringify(payload.error)}`);
311
+ }
312
+ if (!("result" in payload)) {
313
+ throw new Error(`MCP JSON-RPC response missing result: ${JSON.stringify(payload)}`);
314
+ }
315
+ return payload.result;
316
+ }
317
+
318
+ function readToolText(result: unknown): { isError: boolean; text: string | undefined } {
319
+ if (!isRecord(result)) {
320
+ throw new Error(`MCP tool result was not an object: ${JSON.stringify(result)}`);
321
+ }
322
+ const content = result.content;
323
+ const textEntry = Array.isArray(content)
324
+ ? content.find(
325
+ (entry) => isRecord(entry) && entry.type === "text" && typeof entry.text === "string"
326
+ )
327
+ : undefined;
328
+ return {
329
+ isError: result.isError === true,
330
+ text: isRecord(textEntry) && typeof textEntry.text === "string" ? textEntry.text : undefined,
331
+ };
332
+ }
333
+
334
+ async function readMcpJsonRpc(response: APIResponse): Promise<unknown> {
335
+ const text = await response.text();
336
+ if (!response.ok()) {
337
+ throw new Error(`${response.status()} ${response.statusText()}: ${text}`);
338
+ }
339
+ const contentType = response.headers()["content-type"] ?? "";
340
+ if (contentType.includes("text/event-stream")) {
341
+ return parseSseJson(text);
342
+ }
343
+ try {
344
+ return JSON.parse(text) as unknown;
345
+ } catch {
346
+ throw new Error(`MCP response was not JSON: ${text.slice(0, 300)}`);
347
+ }
348
+ }
349
+
350
+ function mcpToolCallParams(input: {
351
+ name: string;
352
+ arguments?: Record<string, unknown>;
353
+ }): Record<string, unknown> {
354
+ return {
355
+ name: input.name,
356
+ arguments: input.arguments ?? {},
357
+ _meta: {
358
+ "io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL_VERSION,
359
+ "io.modelcontextprotocol/clientCapabilities": {},
360
+ "io.modelcontextprotocol/clientInfo": {
361
+ name: "starter-e2e",
362
+ version: "1.0.0",
363
+ },
364
+ },
365
+ };
366
+ }
367
+
368
+ export async function callMcpTool(input: {
369
+ request: APIRequestContext;
370
+ serverUrl: string;
371
+ accessToken: string;
372
+ name: string;
373
+ arguments?: Record<string, unknown>;
374
+ }): Promise<unknown> {
375
+ const response = await input.request.post(`${input.serverUrl}${MCP_HTTP_PATH}`, {
376
+ headers: {
377
+ authorization: `Bearer ${input.accessToken}`,
378
+ accept: "application/json",
379
+ "content-type": "application/json",
380
+ "mcp-protocol-version": MCP_PROTOCOL_VERSION,
381
+ "mcp-method": "tools/call",
382
+ "mcp-name": input.name,
383
+ },
384
+ data: {
385
+ jsonrpc: "2.0",
386
+ id: 1,
387
+ method: "tools/call",
388
+ params: mcpToolCallParams({ name: input.name, arguments: input.arguments }),
389
+ },
390
+ });
391
+ const result = unwrapJsonRpc(await readMcpJsonRpc(response));
392
+ const toolText = readToolText(result);
393
+ if (toolText.isError) {
394
+ throw new Error(toolText.text ?? `MCP call ${input.name} failed`);
395
+ }
396
+ if (toolText.text === undefined) {
397
+ return result;
398
+ }
399
+ try {
400
+ return JSON.parse(toolText.text) as unknown;
401
+ } catch {
402
+ return toolText.text;
403
+ }
404
+ }
405
+
406
+ export { E2E_MCP_CIMD_CLIENT_ID, MCP_HTTP_PATH };
@@ -0,0 +1,108 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { clearEmails, getUserState, login, profiles, signUp, verifyLatestEmail } from "./helpers";
3
+ import { authorizeMcpClient, callMcpTool, MCP_HTTP_PATH } from "./mcp-helpers";
4
+
5
+ const profile = "standard" as const;
6
+
7
+ test.beforeEach(async ({ request }) => {
8
+ await clearEmails(request, profile);
9
+ });
10
+
11
+ test("authenticate through MCP OAuth and call list-organizations, list-posts, and create-post", async ({
12
+ page,
13
+ request,
14
+ }) => {
15
+ test.setTimeout(120_000);
16
+ const email = `mcp.${Date.now()}@auth-e2e.local`;
17
+ const password = "password1234";
18
+ const serverUrl = profiles[profile].serverUrl;
19
+ const title = `MCP e2e ${Date.now()}`;
20
+
21
+ await signUp(page, email, password);
22
+ await verifyLatestEmail(request, profile, email);
23
+ await login(page, email, password);
24
+
25
+ const unauthenticated = await request.post(`${serverUrl}${MCP_HTTP_PATH}`, {
26
+ data: {
27
+ jsonrpc: "2.0",
28
+ id: 1,
29
+ method: "tools/list",
30
+ params: {},
31
+ },
32
+ });
33
+ expect(unauthenticated.status()).toBe(401);
34
+ expect(unauthenticated.headers()["www-authenticate"] ?? "").toMatch(/Bearer/i);
35
+
36
+ const state = await getUserState(request, profile, email);
37
+ const organization = state.organizations[0];
38
+ if (!organization) {
39
+ throw new Error("Expected signup to create an Organization");
40
+ }
41
+
42
+ const session = await authorizeMcpClient({
43
+ page,
44
+ serverUrl,
45
+ email,
46
+ password,
47
+ organization,
48
+ });
49
+
50
+ try {
51
+ const listedOrganizations = await callMcpTool({
52
+ request,
53
+ serverUrl,
54
+ accessToken: session.accessToken,
55
+ name: "list-organizations",
56
+ });
57
+ expect(listedOrganizations).toEqual(
58
+ expect.arrayContaining([
59
+ expect.objectContaining({
60
+ id: organization.id,
61
+ name: organization.name,
62
+ }),
63
+ ])
64
+ );
65
+
66
+ const listedBefore = await callMcpTool({
67
+ request,
68
+ serverUrl,
69
+ accessToken: session.accessToken,
70
+ name: "list-posts",
71
+ arguments: { organizationId: organization.id },
72
+ });
73
+ expect(listedBefore).toEqual(
74
+ expect.objectContaining({
75
+ rows: expect.any(Array),
76
+ total: expect.any(Number),
77
+ })
78
+ );
79
+
80
+ const created = await callMcpTool({
81
+ request,
82
+ serverUrl,
83
+ accessToken: session.accessToken,
84
+ name: "create-post",
85
+ arguments: {
86
+ organizationId: organization.id,
87
+ title,
88
+ content: "Written through the MCP client.",
89
+ },
90
+ });
91
+ expect(created).toEqual(expect.objectContaining({ title }));
92
+
93
+ const listedAfter = await callMcpTool({
94
+ request,
95
+ serverUrl,
96
+ accessToken: session.accessToken,
97
+ name: "list-posts",
98
+ arguments: { organizationId: organization.id },
99
+ });
100
+ expect(listedAfter).toEqual(
101
+ expect.objectContaining({
102
+ rows: expect.arrayContaining([expect.objectContaining({ title })]),
103
+ })
104
+ );
105
+ } finally {
106
+ await session.close();
107
+ }
108
+ });