create-m5kdev 0.36.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 (23) 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 +1 -0
  5. package/templates/minimal-app/apps/e2e/tests/mcp-helpers.ts.tpl +406 -0
  6. package/templates/minimal-app/apps/e2e/tests/mcp.spec.ts.tpl +108 -0
  7. package/templates/minimal-app/apps/server/AGENTS.md.tpl +10 -0
  8. package/templates/minimal-app/apps/server/db.e2e.ts.tpl +1 -0
  9. package/templates/minimal-app/apps/server/db.ts.tpl +1 -0
  10. package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +159 -0
  11. package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +2 -0
  12. package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +87 -0
  13. package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3603 -0
  14. package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3620 -0
  15. package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3620 -0
  16. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +21 -0
  17. package/templates/minimal-app/apps/server/src/app.ts.tpl +8 -1
  18. package/templates/minimal-app/apps/server/src/modules/posts/posts.mcp.ts.tpl +17 -0
  19. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +11 -0
  20. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +13 -4
  21. package/templates/minimal-app/apps/server/src/schema.ts.tpl +13 -0
  22. package/templates/minimal-app/pnpm-workspace.yaml.tpl +14 -14
  23. package/templates/minimal-app/template.manifest.json +9 -0
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "create-m5kdev",
3
- "version": "0.36.0",
3
+ "version": "0.37.2",
4
4
  "license": "GPL-3.0-only",
5
5
  "engines": {
6
6
  "node": ">=24"
7
7
  },
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/michalkow/m5kdev.git"
10
+ "url": "git+https://github.com/michalkow/m5kdev.git"
11
11
  },
12
12
  "homepage": "https://github.com/michalkow/m5kdev#readme",
13
13
  "bugs": "https://github.com/michalkow/m5kdev/issues",
@@ -25,7 +25,7 @@
25
25
  "semver": "7.8.5",
26
26
  "tslib": "2.8.1",
27
27
  "yaml": "2.9.0",
28
- "zod": "4.2.1"
28
+ "zod": "4.4.3"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/jscodeshift": "17.3.0",
@@ -11,6 +11,12 @@ node_modules
11
11
  # Local env files — never bake secrets into the image
12
12
  .env*
13
13
 
14
+ # Databases
15
+ *.db
16
+ *.db-wal
17
+ *.db-shm
18
+ *.db-info
19
+
14
20
  # Testing
15
21
  coverage
16
22
 
@@ -33,6 +33,9 @@ export default defineConfig({
33
33
  // m5k:workflows:start
34
34
  "workflow.spec.ts",
35
35
  // m5k:workflows:end
36
+ // m5k:mcp:start
37
+ "mcp.spec.ts",
38
+ // m5k:mcp:end
36
39
  ],
37
40
  use: {
38
41
  ...devices["Desktop Chrome"],
@@ -58,6 +58,7 @@ function serverEnv(profile: E2EProfile, expo: boolean): Record<string, string> {
58
58
  REDIS_URL: `redis://127.0.0.1:6379/${expo ? PORTS[profile].expoRedisDb : PORTS[profile].redisDb}`,
59
59
  VITE_ENABLE_WAITLIST: profile === "waitlist" ? "true" : "false",
60
60
  AUTH_E2E_PROFILE: profile,
61
+ M5K_MCP_E2E_CIMD: "1",
61
62
  BETTER_AUTH_SECRET: "auth-e2e-local-secret-auth-e2e-local-secret",
62
63
  SYSTEM_NOTIFICATION_EMAIL: "ops@auth-e2e.local",
63
64
  NODE_ENV: "development",
@@ -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
+ });
@@ -20,6 +20,9 @@ apps/server/src/modules/<module>/
20
20
  ├── <module>.repository.ts
21
21
  ├── <module>.service.ts
22
22
  ├── <module>.trpc.ts
23
+ // m5k:mcp:start
24
+ ├── <module>.mcp.ts
25
+ // m5k:mcp:end
23
26
  └── <module>.module.ts
24
27
  ```
25
28
 
@@ -28,6 +31,9 @@ apps/server/src/modules/<module>/
28
31
  - Repositories own persistence and query construction.
29
32
  - Services own business rules, orchestration, and context-aware defaults.
30
33
  - tRPC files own transport only and must delegate to services.
34
+ // m5k:mcp:start
35
+ - MCP call maps live in `<module>.mcp.ts` and are contributed from `mcp()` / `mcpUser()`. Delegate to Procedures or unguarded service methods; do not scrape Service properties.
36
+ // m5k:mcp:end
31
37
  - Register Core Modules and app modules in `apps/server/src/app.ts` via `createBackendApp(config, [modules])`. Do not use `backendApp.use`. Optional Backend Modules (`@m5kdev/module-*`) are not scaffolded; add them only when the product needs Clay, Docx, Pdf, Social, or Video.
32
38
  // m5k:ai:start
33
39
  - `AIModule` is registered in `app.ts` with an app-owned Mastra Agent (`assistant`). Conversation HTTP lives on `/ai`. Set `OPENROUTER_API_KEY` for live replies. Do not add a Starter service wrapper around `AIService` chat.
@@ -50,6 +56,10 @@ apps/server/src/modules/<module>/
50
56
  - `index.ts` calls `builtBackendApp.start()`, which listens and handles SIGINT/SIGTERM. Extra shutdown work (telemetry) is `onShutdown` on `createBackendApp`.
51
57
  - After changing Drizzle tables, run `pnpm --filter ./apps/server drizzle:generate` then `drizzle:migrate` — do not hand-edit SQL migrations in this repo.
52
58
 
59
+ // m5k:mcp:start
60
+ - `McpModule` is registered in `app.ts`. MCP clients authenticate with Better Auth MCP OAuth (not API keys). Compose `auth.oauth.db` plus `mcpAllowlistEntries`. Contribute Organization-scoped calls from `posts.mcp.ts`. Omit the module to disable MCP.
61
+ // m5k:mcp:end
62
+
53
63
  // m5k:notifications:start
54
64
  - `NotificationModule` is registered in `app.ts` when this feature is selected. Push-related server env vars are documented in `apps/shared/.env.example`.
55
65
  // m5k:notifications:end
@@ -82,6 +82,7 @@ async function ensureUser(input: {
82
82
  await orm.insert(schema.accounts).values({
83
83
  id: uuidv4(),
84
84
  accountId: user.id,
85
+ issuer: "local:credential",
85
86
  providerId: "credential",
86
87
  userId: user.id,
87
88
  password: await hashPassword(password),
@@ -52,6 +52,7 @@ async function ensureDemoUser(orm: SeedOrm): Promise<typeof schema.users.$inferS
52
52
  await orm.insert(schema.accounts).values({
53
53
  id: uuidv4(),
54
54
  accountId: user.id,
55
+ issuer: "local:credential",
55
56
  providerId: "credential",
56
57
  userId: user.id,
57
58
  password: await hashPassword(DEMO_PASSWORD),