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.
- package/package.json +3 -3
- package/templates/minimal-app/.dockerignore.tpl +6 -0
- package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +3 -0
- package/templates/minimal-app/apps/e2e/servers.ts.tpl +1 -0
- package/templates/minimal-app/apps/e2e/tests/mcp-helpers.ts.tpl +406 -0
- package/templates/minimal-app/apps/e2e/tests/mcp.spec.ts.tpl +108 -0
- package/templates/minimal-app/apps/server/AGENTS.md.tpl +10 -0
- package/templates/minimal-app/apps/server/db.e2e.ts.tpl +1 -0
- package/templates/minimal-app/apps/server/db.ts.tpl +1 -0
- package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +159 -0
- package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +2 -0
- package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +87 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3603 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3620 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3620 -0
- package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +21 -0
- package/templates/minimal-app/apps/server/src/app.ts.tpl +8 -1
- package/templates/minimal-app/apps/server/src/modules/posts/posts.mcp.ts.tpl +17 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +11 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +13 -4
- package/templates/minimal-app/apps/server/src/schema.ts.tpl +13 -0
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +14 -14
- 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.
|
|
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.
|
|
28
|
+
"zod": "4.4.3"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/jscodeshift": "17.3.0",
|
|
@@ -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
|
|
@@ -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),
|