@mastra/server-adapters-test-suite 0.1.3-alpha.2 → 0.1.4-alpha.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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ErrorCategory, ErrorDomain, MastraError } from "@mastra/core/error";
2
+ import { registerApiRoute } from "@mastra/core/server";
2
3
  import { handleError } from "@mastra/server/handlers/error";
3
4
  import { HTTPException, SERVER_ROUTES, WorkflowRegistry, createRoute } from "@mastra/server/server-adapter";
4
5
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -23,8 +24,9 @@ import { MastraVector } from "@mastra/core/vector";
23
24
  import { CompositeVoice } from "@mastra/core/voice";
24
25
  import { createStep, createWorkflow } from "@mastra/core/workflows";
25
26
  import { LocalFilesystem, Workspace } from "@mastra/core/workspace";
26
- import { MCPClient, MCPServer } from "@mastra/mcp";
27
+ import { MCPClient, MCPServer, defineEvent } from "@mastra/mcp";
27
28
  import { Mastra as Mastra$1 } from "@mastra/core/mastra";
29
+ import http from "http";
28
30
  import { MCPServerBase } from "@mastra/core/mcp";
29
31
  import { RequestContext } from "@mastra/core/request-context";
30
32
  import { standardSchemaToJSONSchema } from "@mastra/core/schema";
@@ -59,6 +61,8 @@ function getRouteSpecificSchemaDefaults(route) {
59
61
  predicateScope: "trace",
60
62
  path: "entityName"
61
63
  } };
64
+ if (route.path === "/agent-controller/:controllerId/sessions" && route.method === "POST") return { body: { resourceId: "test-resource" } };
65
+ if (route.path === "/mcp/:serverId/events/:eventName/emit") return { body: { data: { id: "test-ticket" } } };
62
66
  if (route.path === "/datasets/:datasetId/compare") return { body: {
63
67
  experimentIds: ["test-experiment-a", "test-experiment-b"],
64
68
  baselineId: "test-experiment-a"
@@ -136,8 +140,10 @@ function generateValidDataFromSchema(schema, fieldName) {
136
140
  def = getZodDef(schema);
137
141
  }
138
142
  if (typeName === "ZodOptional" || typeName === "ZodNullable") return generateValidDataFromSchema(def.innerType, fieldName);
139
- if (typeName === "ZodDefault") if ("_zod" in schema) return def.defaultValue;
140
- else return def.defaultValue();
143
+ if (typeName === "ZodDefault") {
144
+ if ("_zod" in schema) return def.defaultValue;
145
+ else return def.defaultValue();
146
+ }
141
147
  if (typeName === "ZodString") {
142
148
  if ((def.checks ?? []).some((check) => {
143
149
  const checkDef = check._zod?.def ?? check.def ?? check;
@@ -235,13 +241,19 @@ function generateValidDataFromSchema(schema, fieldName) {
235
241
  if (typeName === "ZodUndefined") return void 0;
236
242
  if (typeName === "ZodDate") return /* @__PURE__ */ new Date();
237
243
  if (typeName === "ZodBigInt") return BigInt(0);
238
- if (typeName === "ZodLiteral") if ("_zod" in schema) return def.values?.[0];
239
- else return def.value;
240
- if (typeName === "ZodEnum") if ("_zod" in schema) return Object.values(def.entries)[0];
241
- else return def.values[0];
244
+ if (typeName === "ZodLiteral") {
245
+ if ("_zod" in schema) return def.values?.[0];
246
+ else return def.value;
247
+ }
248
+ if (typeName === "ZodEnum") {
249
+ if ("_zod" in schema) return Object.values(def.entries)[0];
250
+ else return def.values[0];
251
+ }
242
252
  if (typeName === "ZodNativeEnum") return Object.values(def.values)[0];
243
- if (typeName === "ZodArray") if ("_zod" in schema) return [generateValidDataFromSchema(def.element, fieldName)];
244
- else return [generateValidDataFromSchema(def.type, fieldName)];
253
+ if (typeName === "ZodArray") {
254
+ if ("_zod" in schema) return [generateValidDataFromSchema(def.element, fieldName)];
255
+ else return [generateValidDataFromSchema(def.type, fieldName)];
256
+ }
245
257
  if (typeName === "ZodObject") {
246
258
  const shape = typeof def.shape === "function" ? def.shape() : def.shape;
247
259
  const obj = {};
@@ -309,6 +321,7 @@ function getDefaultValidPathParams(route) {
309
321
  if (route.path.includes(":controllerId")) params.controllerId = "test-controller";
310
322
  if (route.path.includes(":responseId")) params.responseId = "test-response";
311
323
  if (route.path.includes(":resourceId")) params.resourceId = "test-resource";
324
+ if (route.path.includes(":sessionId")) params.sessionId = "test-session";
312
325
  if (route.path.includes(":modelConfigId")) params.modelConfigId = "id1";
313
326
  if (route.path.includes(":scorerId") && route.path.includes("/stored/scorers/")) params.scorerId = "test-stored-scorer";
314
327
  else if (route.path.includes(":scorerId")) params.scorerId = "test-scorer";
@@ -333,6 +346,7 @@ function getDefaultValidPathParams(route) {
333
346
  if (route.path.includes(":id") && route.path.includes("/mcp/v0/servers/")) params.id = "test-server-1";
334
347
  if (route.path.includes(":serverId")) params.serverId = "test-server-1";
335
348
  if (route.path.includes(":toolId") && route.path.includes("/mcp/")) params.toolId = "getWeather";
349
+ if (route.path.includes(":eventName")) params.eventName = "ticket.created";
336
350
  if (route.path.includes(":workspaceId")) params.workspaceId = "test-workspace";
337
351
  if (route.path.includes(":skillName")) params.skillName = "test-skill";
338
352
  if (route.path.includes(":referencePath")) params.referencePath = "test-reference.md";
@@ -432,7 +446,8 @@ function createRouteTestSuite(config) {
432
446
  });
433
447
  });
434
448
  routes.forEach((route) => {
435
- describe(`${route.method} ${route.path}`, () => {
449
+ const routeKey = `${route.method} ${route.path}`;
450
+ describe(routeKey, () => {
436
451
  if (route.deprecated) {
437
452
  it("should be marked as deprecated", () => {
438
453
  expect(route.deprecated).toBe(true);
@@ -784,9 +799,7 @@ async function createDefaultTestContext() {
784
799
  case "multiply":
785
800
  result = a * b;
786
801
  break;
787
- case "divide":
788
- result = b !== 0 ? a / b : Infinity;
789
- break;
802
+ case "divide": result = b !== 0 ? a / b : Infinity;
790
803
  }
791
804
  return { result };
792
805
  }
@@ -811,7 +824,11 @@ async function createDefaultTestContext() {
811
824
  appResources: { "ui://test/app": {
812
825
  name: "Test App",
813
826
  html: "<html><body>Test</body></html>"
814
- } }
827
+ } },
828
+ events: { "ticket.created": defineEvent({
829
+ description: "A ticket was created",
830
+ payloadSchema: z$3.object({ id: z$3.string() })
831
+ }) }
815
832
  });
816
833
  const mcpServer2 = new MCPServer({
817
834
  name: "Test Server 2",
@@ -956,7 +973,8 @@ async function createDefaultTestContext() {
956
973
  "trace-aggregate",
957
974
  "thread-query",
958
975
  "thread-query-rows",
959
- "span-query"
976
+ "span-query",
977
+ "span-aggregate"
960
978
  ]);
961
979
  vi.spyOn(observability, "queryTraces").mockResolvedValue({
962
980
  traces: [],
@@ -982,6 +1000,10 @@ async function createDefaultTestContext() {
982
1000
  spans: [],
983
1001
  page: { next: null }
984
1002
  });
1003
+ vi.spyOn(observability, "aggregateSpans").mockResolvedValue({
1004
+ rows: [],
1005
+ truncated: false
1006
+ });
985
1007
  await observability.createSpan({ span: {
986
1008
  spanId: "test-span",
987
1009
  traceId: "test-trace",
@@ -1338,9 +1360,10 @@ Follow these instructions for the test skill.
1338
1360
  installedAt: (/* @__PURE__ */ new Date("2024-01-01T00:00:00.000Z")).toISOString()
1339
1361
  };
1340
1362
  fs.writeFileSync(path.join(agentSkillsDir, ".meta.json"), JSON.stringify(metaJson, null, 2));
1363
+ const filesystem = new LocalFilesystem({ basePath: tempDir });
1341
1364
  const workspace = new Workspace({
1342
1365
  id: "test-workspace",
1343
- filesystem: new LocalFilesystem({ basePath: tempDir }),
1366
+ filesystem,
1344
1367
  skills: ["skills"],
1345
1368
  bm25: true
1346
1369
  });
@@ -1364,7 +1387,8 @@ function createTestTool(overrides = {}) {
1364
1387
  * Following the pattern from handler tests - uses actual MockMemory implementation
1365
1388
  */
1366
1389
  function createMockMemory() {
1367
- const mockMemory = new MockMemory({ storage: new InMemoryStore() });
1390
+ const storage = new InMemoryStore();
1391
+ const mockMemory = new MockMemory({ storage });
1368
1392
  mockMemory.__registerMastra = vi.fn();
1369
1393
  return mockMemory;
1370
1394
  }
@@ -1833,7 +1857,8 @@ function createRouteAdapterTestSuite(config) {
1833
1857
  expect(response.status).toBe(200);
1834
1858
  const data = response.data;
1835
1859
  expect(data.filter).toEqual({ status: "active" });
1836
- expect(Array.isArray(data.tag) ? data.tag : [data.tag]).toContain("b");
1860
+ const tags = Array.isArray(data.tag) ? data.tag : [data.tag];
1861
+ expect(tags).toContain("b");
1837
1862
  expect(data.keys).not.toContain("__proto__");
1838
1863
  expect(data.keys).not.toContain("constructor");
1839
1864
  expect(data.keys).not.toContain("prototype");
@@ -2104,7 +2129,8 @@ function createRouteAdapterTestSuite(config) {
2104
2129
  Object.entries(deprecatedByCategory).forEach(([category, routes]) => {
2105
2130
  describe(category, () => {
2106
2131
  routes.forEach((route) => {
2107
- describe(`${route.method} ${route.path}`, () => {
2132
+ const testName = `${route.method} ${route.path}`;
2133
+ describe(testName, () => {
2108
2134
  it("should be marked as deprecated", () => {
2109
2135
  expect(route.deprecated).toBe(true);
2110
2136
  expect(route.openapi?.deprecated).toBe(true);
@@ -2143,6 +2169,7 @@ function createRouteAdapterTestSuite(config) {
2143
2169
  "/editor/builder/registries/:registryId/install",
2144
2170
  "/background-tasks/stream",
2145
2171
  "/agents/:agentId/observe",
2172
+ "/mcp/:serverId/events/:eventName/stream",
2146
2173
  "/agents/:agentId/recover",
2147
2174
  "/agents/:agentId/plans/file",
2148
2175
  "/tool-providers/:providerId/connections/:connectionId",
@@ -2155,7 +2182,9 @@ function createRouteAdapterTestSuite(config) {
2155
2182
  ];
2156
2183
  const excludedPrefixes = ["/datasets", "/agent-controller"];
2157
2184
  const isExcluded = (r) => r.deprecated || r.responseType === "mcp-http" || r.responseType === "mcp-sse" || authRoutesRequiringProviders.includes(r.path) || routesRequiringExternalDeps.includes(r.path) || excludedPrefixes.some((prefix) => r.path.startsWith(prefix));
2158
- const routesByCategory = SERVER_ROUTES.filter((r) => !isExcluded(r)).reduce((acc, route) => {
2185
+ const activeRoutes = SERVER_ROUTES.filter((r) => !isExcluded(r));
2186
+ const devOnlyRoutes = ["/mcp/:serverId/events/:eventName/emit"];
2187
+ const routesByCategory = activeRoutes.reduce((acc, route) => {
2159
2188
  const category = route.path.split("/")[1] || "root";
2160
2189
  if (!acc[category]) acc[category] = [];
2161
2190
  acc[category].push(route);
@@ -2164,7 +2193,16 @@ function createRouteAdapterTestSuite(config) {
2164
2193
  Object.entries(routesByCategory).forEach(([category, routes]) => {
2165
2194
  describe(category, () => {
2166
2195
  routes.forEach((route) => {
2167
- describe(`${route.method} ${route.path}`, () => {
2196
+ const testName = `${route.method} ${route.path}`;
2197
+ describe(testName, () => {
2198
+ if (devOnlyRoutes.includes(route.path)) {
2199
+ beforeEach(() => {
2200
+ vi.stubEnv("MASTRA_DEV", "true");
2201
+ });
2202
+ afterEach(() => {
2203
+ vi.unstubAllEnvs();
2204
+ });
2205
+ }
2168
2206
  it("should execute with valid request", async () => {
2169
2207
  const request = buildRouteRequest(route);
2170
2208
  const httpRequest = {
@@ -2199,7 +2237,8 @@ function createRouteAdapterTestSuite(config) {
2199
2237
  query: request.query,
2200
2238
  body: request.body
2201
2239
  };
2202
- expect((await executeHttpRequest(app, httpRequest)).status).toBe(404);
2240
+ const response = await executeHttpRequest(app, httpRequest);
2241
+ expect(response.status).toBe(404);
2203
2242
  });
2204
2243
  if (route.path.includes(":workflowId")) it("should return 404 when workflow not found", async () => {
2205
2244
  const request = buildRouteRequest(route, { pathParams: { workflowId: "non-existent-workflow" } });
@@ -2209,7 +2248,8 @@ function createRouteAdapterTestSuite(config) {
2209
2248
  query: request.query,
2210
2249
  body: request.body
2211
2250
  };
2212
- expect((await executeHttpRequest(app, httpRequest)).status).toBe(404);
2251
+ const response = await executeHttpRequest(app, httpRequest);
2252
+ expect(response.status).toBe(404);
2213
2253
  });
2214
2254
  if (route.path.includes(":backgroundTaskId")) it("should return 404 when background task not found", async () => {
2215
2255
  const request = buildRouteRequest(route, { pathParams: { backgroundTaskId: "non-existent-background-task" } });
@@ -2219,7 +2259,8 @@ function createRouteAdapterTestSuite(config) {
2219
2259
  query: request.query,
2220
2260
  body: request.body
2221
2261
  };
2222
- expect((await executeHttpRequest(app, httpRequest)).status).toBe(404);
2262
+ const response = await executeHttpRequest(app, httpRequest);
2263
+ expect(response.status).toBe(404);
2223
2264
  });
2224
2265
  if (route.path.includes(":serverId")) it("should return 404 when MCP server not found (via :serverId)", async () => {
2225
2266
  const request = buildRouteRequest(route, { pathParams: { serverId: "non-existent-server" } });
@@ -2229,7 +2270,8 @@ function createRouteAdapterTestSuite(config) {
2229
2270
  query: request.query,
2230
2271
  body: request.body
2231
2272
  };
2232
- expect((await executeHttpRequest(app, httpRequest)).status).toBe(404);
2273
+ const response = await executeHttpRequest(app, httpRequest);
2274
+ expect(response.status).toBe(404);
2233
2275
  });
2234
2276
  if (route.path.includes("/mcp/v0/servers/:id")) it("should return 404 when MCP server not found (via :id)", async () => {
2235
2277
  const request = buildRouteRequest(route, { pathParams: { id: "non-existent-server" } });
@@ -2239,7 +2281,8 @@ function createRouteAdapterTestSuite(config) {
2239
2281
  query: request.query,
2240
2282
  body: request.body
2241
2283
  };
2242
- expect((await executeHttpRequest(app, httpRequest)).status).toBe(404);
2284
+ const response = await executeHttpRequest(app, httpRequest);
2285
+ expect(response.status).toBe(404);
2243
2286
  });
2244
2287
  if (route.path.includes(":processorId")) it("should return 404 when processor not found", async () => {
2245
2288
  const request = buildRouteRequest(route, { pathParams: { processorId: "non-existent-processor" } });
@@ -2249,7 +2292,8 @@ function createRouteAdapterTestSuite(config) {
2249
2292
  query: request.query,
2250
2293
  body: request.body
2251
2294
  };
2252
- expect((await executeHttpRequest(app, httpRequest)).status).toBe(404);
2295
+ const response = await executeHttpRequest(app, httpRequest);
2296
+ expect(response.status).toBe(404);
2253
2297
  });
2254
2298
  if (route.responseType === "stream") it("should be consumable via stream reader", async () => {
2255
2299
  const request = buildRouteRequest(route);
@@ -2264,9 +2308,13 @@ function createRouteAdapterTestSuite(config) {
2264
2308
  expect(response.stream).toBeDefined();
2265
2309
  if (typeof response.stream.getReader === "function") {
2266
2310
  const reader = response.stream.getReader();
2267
- expect(await reader.read()).toBeDefined();
2311
+ const firstChunk = await reader.read();
2312
+ expect(firstChunk).toBeDefined();
2268
2313
  reader.releaseLock();
2269
- } else if (typeof response.stream[Symbol.asyncIterator] === "function") expect(await response.stream[Symbol.asyncIterator]().next()).toBeDefined();
2314
+ } else if (typeof response.stream[Symbol.asyncIterator] === "function") {
2315
+ const firstChunk = await response.stream[Symbol.asyncIterator]().next();
2316
+ expect(firstChunk).toBeDefined();
2317
+ }
2270
2318
  });
2271
2319
  if (route.queryParamSchema || route.bodySchema) it("should return 400 when schema validation fails", async () => {
2272
2320
  const request = buildRouteRequest(route);
@@ -2323,7 +2371,8 @@ function createRouteAdapterTestSuite(config) {
2323
2371
  }
2324
2372
  }
2325
2373
  };
2326
- expect((await executeHttpRequest(app, httpRequest)).status).toBeLessThan(500);
2374
+ const response = await executeHttpRequest(app, httpRequest);
2375
+ expect(response.status).toBeLessThan(500);
2327
2376
  });
2328
2377
  const bodySchema = route.bodySchema;
2329
2378
  if (["POST", "PUT"].includes(route.method) && bodySchema) it("should spread body fields to handler params", async () => {
@@ -2384,7 +2433,8 @@ function createRouteAdapterTestSuite(config) {
2384
2433
  expect(response.type).toBe("json");
2385
2434
  const errorData = response.data;
2386
2435
  expect(errorData).toBeDefined();
2387
- expect(errorData.error !== void 0 || errorData.message !== void 0 || errorData.details !== void 0 || errorData.statusCode !== void 0).toBe(true);
2436
+ const hasErrorField = errorData.error !== void 0 || errorData.message !== void 0 || errorData.details !== void 0 || errorData.statusCode !== void 0;
2437
+ expect(hasErrorField).toBe(true);
2388
2438
  });
2389
2439
  });
2390
2440
  SERVER_ROUTES.filter((r) => r.method === "POST" && r.bodySchema && !isExcluded(r)).forEach((route) => {
@@ -2412,6 +2462,83 @@ function createRouteAdapterTestSuite(config) {
2412
2462
  });
2413
2463
  });
2414
2464
  });
2465
+ describe("Studio-only RBAC", () => {
2466
+ const rolePermissions = {
2467
+ viewer: ["agents:read"],
2468
+ member: ["agents:read", "tools:execute"]
2469
+ };
2470
+ const studioAuth = {
2471
+ authenticateToken: async (token) => rolePermissions[token] ? {
2472
+ id: `user_${token}`,
2473
+ role: token
2474
+ } : null,
2475
+ authorize: async () => true
2476
+ };
2477
+ const studioRbac = {
2478
+ getPermissions: async (user) => rolePermissions[user.role] ?? [],
2479
+ getRoles: async (user) => [user.role]
2480
+ };
2481
+ async function setupStudioRbac(options) {
2482
+ const ctx = await createDefaultTestContext();
2483
+ const originalGetServer = ctx.mastra.getServer.bind(ctx.mastra);
2484
+ const customRoute = registerApiRoute("/studio-rbac/custom", {
2485
+ method: "GET",
2486
+ requiresPermission: "tools:execute",
2487
+ handler: async (c) => c.json({ ok: true })
2488
+ });
2489
+ ctx.mastra.getServer = () => ({
2490
+ ...originalGetServer(),
2491
+ apiRoutes: [customRoute],
2492
+ ...options.withServerAuth ? { auth: studioAuth } : {}
2493
+ });
2494
+ ctx.mastra.getStudio = () => ({
2495
+ auth: studioAuth,
2496
+ rbac: studioRbac
2497
+ });
2498
+ ctx.customRouteAuthConfig = /* @__PURE__ */ new Map([["GET:/studio-rbac/custom", true]]);
2499
+ const route = {
2500
+ method: "GET",
2501
+ path: "/test/studio-rbac",
2502
+ responseType: "json",
2503
+ requiresPermission: "tools:execute",
2504
+ handler: async () => ({ ok: true })
2505
+ };
2506
+ const mutableServerRoutes = SERVER_ROUTES;
2507
+ const originalLength = mutableServerRoutes.length;
2508
+ mutableServerRoutes.push(route);
2509
+ try {
2510
+ return (await setupAdapter(ctx)).app;
2511
+ } finally {
2512
+ mutableServerRoutes.splice(originalLength);
2513
+ }
2514
+ }
2515
+ const studioRequest = (path, role) => ({
2516
+ method: "GET",
2517
+ path,
2518
+ headers: {
2519
+ Authorization: `Bearer ${role}`,
2520
+ "x-mastra-client-type": "studio"
2521
+ }
2522
+ });
2523
+ describe.each([{
2524
+ label: "studio auth only",
2525
+ withServerAuth: false
2526
+ }, {
2527
+ label: "studio auth + server auth without server RBAC",
2528
+ withServerAuth: true
2529
+ }])("$label", ({ withServerAuth }) => {
2530
+ it.each(["/api/test/studio-rbac", "/studio-rbac/custom"])("denies a studio user without the required permission on %s", async (path) => {
2531
+ const studioApp = await setupStudioRbac({ withServerAuth });
2532
+ const response = await executeHttpRequest(studioApp, studioRequest(path, "viewer"));
2533
+ expect(response.status).toBe(403);
2534
+ });
2535
+ it.each(["/api/test/studio-rbac", "/studio-rbac/custom"])("allows a studio user with the required permission on %s", async (path) => {
2536
+ const studioApp = await setupStudioRbac({ withServerAuth });
2537
+ const response = await executeHttpRequest(studioApp, studioRequest(path, "member"));
2538
+ expect(response.status).toBe(200);
2539
+ });
2540
+ });
2541
+ });
2415
2542
  describe("Custom HTTPException responses", () => {
2416
2543
  async function setupCustomErrorRoutes() {
2417
2544
  let handlerCalls = 0;
@@ -2448,6 +2575,49 @@ function createRouteAdapterTestSuite(config) {
2448
2575
  handlerCalls++;
2449
2576
  throw new HTTPException(404, { message: "Legacy fallback" });
2450
2577
  }
2578
+ },
2579
+ {
2580
+ method: "GET",
2581
+ path: "/custom/http-error-failing-items",
2582
+ responseType: "json",
2583
+ handler: async () => {
2584
+ handlerCalls++;
2585
+ throw new HTTPException(400, {
2586
+ message: "Existing items fail the new schema",
2587
+ cause: { failingItems: [{
2588
+ index: 0,
2589
+ data: {},
2590
+ field: "input"
2591
+ }] }
2592
+ });
2593
+ }
2594
+ },
2595
+ {
2596
+ method: "GET",
2597
+ path: "/custom/http-error-unsafe-cause",
2598
+ responseType: "json",
2599
+ handler: async () => {
2600
+ handlerCalls++;
2601
+ throw new HTTPException(400, {
2602
+ message: "Unsafe cause",
2603
+ cause: {
2604
+ secret: "do-not-leak",
2605
+ failingItems: "not-an-array"
2606
+ }
2607
+ });
2608
+ }
2609
+ },
2610
+ {
2611
+ method: "GET",
2612
+ path: "/custom/details-status-error",
2613
+ responseType: "json",
2614
+ handler: async () => {
2615
+ handlerCalls++;
2616
+ throw Object.assign(/* @__PURE__ */ new Error("Details status"), {
2617
+ details: { status: 422 },
2618
+ cause: { failingItems: [{ index: 0 }] }
2619
+ });
2620
+ }
2451
2621
  }
2452
2622
  ];
2453
2623
  const mutableServerRoutes = SERVER_ROUTES;
@@ -2499,6 +2669,261 @@ function createRouteAdapterTestSuite(config) {
2499
2669
  expect(response.data).toEqual({ error: "Legacy fallback" });
2500
2670
  expect(custom.getHandlerCalls()).toBe(1);
2501
2671
  });
2672
+ it("includes failingItems from the HTTPException cause in the JSON body", async () => {
2673
+ const custom = await setupCustomErrorRoutes();
2674
+ const response = await executeHttpRequest(custom.app, {
2675
+ method: "GET",
2676
+ path: "/api/custom/http-error-failing-items"
2677
+ });
2678
+ expect(response.status).toBe(400);
2679
+ expect(response.data.error).toBe("Existing items fail the new schema");
2680
+ expect(response.data.cause).toEqual({ failingItems: [{
2681
+ index: 0,
2682
+ data: {},
2683
+ field: "input"
2684
+ }] });
2685
+ });
2686
+ it("never forwards an arbitrary HTTPException cause", async () => {
2687
+ const custom = await setupCustomErrorRoutes();
2688
+ const response = await executeHttpRequest(custom.app, {
2689
+ method: "GET",
2690
+ path: "/api/custom/http-error-unsafe-cause"
2691
+ });
2692
+ expect(response.status).toBe(400);
2693
+ expect(response.data.error).toBe("Unsafe cause");
2694
+ expect(response.data).not.toHaveProperty("cause");
2695
+ expect(JSON.stringify(response.data)).not.toContain("do-not-leak");
2696
+ });
2697
+ it("keeps details.status errors as a message-only body", async () => {
2698
+ const custom = await setupCustomErrorRoutes();
2699
+ const response = await executeHttpRequest(custom.app, {
2700
+ method: "GET",
2701
+ path: "/api/custom/details-status-error"
2702
+ });
2703
+ expect(response.status).toBe(422);
2704
+ expect(response.data.error).toBe("Details status");
2705
+ expect(response.data).not.toHaveProperty("cause");
2706
+ });
2707
+ });
2708
+ describe("Dataset schema update rejected by existing items", () => {
2709
+ it("returns the failing items so clients can report how many items fail", async () => {
2710
+ const context = await createDefaultTestContext();
2711
+ const dataset = await context.mastra.datasets.create({ name: "schema-update-parity" });
2712
+ await dataset.addItem({ input: { prompt: "hello" } });
2713
+ const setup = await setupAdapter(context);
2714
+ const response = await executeHttpRequest(setup.app, {
2715
+ method: "PATCH",
2716
+ path: `/api/datasets/${dataset.id}`,
2717
+ body: { inputSchema: {
2718
+ type: "object",
2719
+ properties: { question: { type: "string" } },
2720
+ required: ["question"]
2721
+ } }
2722
+ });
2723
+ expect(response.status).toBe(400);
2724
+ const failingItems = response.data?.cause?.failingItems;
2725
+ expect(Array.isArray(failingItems)).toBe(true);
2726
+ expect(failingItems).toHaveLength(1);
2727
+ expect(failingItems[0]).toMatchObject({
2728
+ index: 0,
2729
+ field: "input"
2730
+ });
2731
+ });
2732
+ });
2733
+ describe("HTTPException thrown from a registerApiRoute custom route", () => {
2734
+ class ForeignHTTPException extends Error {
2735
+ status;
2736
+ constructor(status, options) {
2737
+ super(options.message);
2738
+ this.status = status;
2739
+ }
2740
+ res = void 0;
2741
+ getResponse() {
2742
+ return new Response(this.message, { status: this.status });
2743
+ }
2744
+ }
2745
+ async function setupCustomRouteErrors() {
2746
+ const ctx = await createDefaultTestContext();
2747
+ const originalGetServer = ctx.mastra.getServer.bind(ctx.mastra);
2748
+ const routes = [
2749
+ registerApiRoute("/custom-errors/not-found", {
2750
+ method: "GET",
2751
+ handler: async () => {
2752
+ throw new HTTPException(404, { message: "Widget not found" });
2753
+ }
2754
+ }),
2755
+ registerApiRoute("/custom-errors/conflict", {
2756
+ method: "POST",
2757
+ handler: async () => {
2758
+ throw new ForeignHTTPException(409, { message: "Widget already exists" });
2759
+ }
2760
+ }),
2761
+ registerApiRoute("/custom-errors/custom-response", {
2762
+ method: "GET",
2763
+ handler: async () => {
2764
+ throw new HTTPException(429, { res: new Response(JSON.stringify({ error: "Slow down" }), {
2765
+ status: 429,
2766
+ headers: {
2767
+ "Content-Type": "application/json",
2768
+ "Retry-After": "5"
2769
+ }
2770
+ }) });
2771
+ }
2772
+ }),
2773
+ registerApiRoute("/custom-errors/unexpected", {
2774
+ method: "GET",
2775
+ handler: async () => {
2776
+ throw new Error("db password=do-not-leak");
2777
+ }
2778
+ }),
2779
+ registerApiRoute("/custom-errors/invalid-status", {
2780
+ method: "GET",
2781
+ handler: async () => {
2782
+ throw new ForeignHTTPException(NaN, { message: "bad status do-not-leak" });
2783
+ }
2784
+ })
2785
+ ];
2786
+ ctx.mastra.getServer = () => ({
2787
+ ...originalGetServer(),
2788
+ apiRoutes: routes
2789
+ });
2790
+ return (await setupAdapter(ctx)).app;
2791
+ }
2792
+ async function bodyText(response) {
2793
+ if (!response.stream) return typeof response.data === "string" ? response.data : JSON.stringify(response.data);
2794
+ const decoder = new TextDecoder();
2795
+ let text = "";
2796
+ for await (const chunk of response.stream) text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
2797
+ return text + decoder.decode();
2798
+ }
2799
+ it("keeps the status and message of a Mastra HTTPException", async () => {
2800
+ const customApp = await setupCustomRouteErrors();
2801
+ const response = await executeHttpRequest(customApp, {
2802
+ method: "GET",
2803
+ path: "/custom-errors/not-found"
2804
+ });
2805
+ expect(response.status).toBe(404);
2806
+ expect(JSON.parse(await bodyText(response))).toEqual({ error: "Widget not found" });
2807
+ });
2808
+ it("keeps the status and message of an HTTPException from another hono install", async () => {
2809
+ const customApp = await setupCustomRouteErrors();
2810
+ const response = await executeHttpRequest(customApp, {
2811
+ method: "POST",
2812
+ path: "/custom-errors/conflict",
2813
+ body: {}
2814
+ });
2815
+ expect(response.status).toBe(409);
2816
+ expect(JSON.parse(await bodyText(response))).toEqual({ error: "Widget already exists" });
2817
+ });
2818
+ it("returns the HTTPException custom response with its headers", async () => {
2819
+ const customApp = await setupCustomRouteErrors();
2820
+ const response = await executeHttpRequest(customApp, {
2821
+ method: "GET",
2822
+ path: "/custom-errors/custom-response"
2823
+ });
2824
+ expect(response.status).toBe(429);
2825
+ expect(response.headers["retry-after"]).toBe("5");
2826
+ expect(JSON.parse(await bodyText(response))).toEqual({ error: "Slow down" });
2827
+ });
2828
+ it("still hides unexpected errors behind a generic 500", async () => {
2829
+ const customApp = await setupCustomRouteErrors();
2830
+ const response = await executeHttpRequest(customApp, {
2831
+ method: "GET",
2832
+ path: "/custom-errors/unexpected"
2833
+ });
2834
+ expect(response.status).toBe(500);
2835
+ const text = await bodyText(response);
2836
+ expect(text).toContain("Internal Server Error");
2837
+ expect(text).not.toContain("do-not-leak");
2838
+ });
2839
+ it("treats an HTTPException with a non-integer status as unexpected", async () => {
2840
+ const customApp = await setupCustomRouteErrors();
2841
+ const response = await executeHttpRequest(customApp, {
2842
+ method: "GET",
2843
+ path: "/custom-errors/invalid-status"
2844
+ });
2845
+ expect(response.status).toBe(500);
2846
+ const text = await bodyText(response);
2847
+ expect(text).toContain("Internal Server Error");
2848
+ expect(text).not.toContain("do-not-leak");
2849
+ });
2850
+ });
2851
+ describe("Dataset item rejected by the input schema", () => {
2852
+ it("returns the failing field paths and messages without the submitted values", async () => {
2853
+ const context = await createDefaultTestContext();
2854
+ const dataset = await context.mastra.datasets.create({
2855
+ name: "item-schema-parity",
2856
+ inputSchema: {
2857
+ type: "object",
2858
+ properties: { question: { type: "string" } },
2859
+ required: ["question"]
2860
+ }
2861
+ });
2862
+ const setup = await setupAdapter(context);
2863
+ const response = await executeHttpRequest(setup.app, {
2864
+ method: "POST",
2865
+ path: `/api/datasets/${dataset.id}/items`,
2866
+ body: { input: {
2867
+ question: { marker: "rejected-secret-value" },
2868
+ note: "submitted-secret-value"
2869
+ } }
2870
+ });
2871
+ expect(response.status).toBe(400);
2872
+ const cause = response.data?.cause;
2873
+ expect(cause?.field).toBe("input");
2874
+ expect(cause?.errors).toEqual([{
2875
+ path: "/question",
2876
+ message: expect.any(String)
2877
+ }]);
2878
+ expect(JSON.stringify(response.data)).not.toContain("submitted-secret-value");
2879
+ expect(JSON.stringify(response.data)).not.toContain("rejected-secret-value");
2880
+ });
2881
+ it("strips anything but path and message from validation errors", async () => {
2882
+ const route = {
2883
+ method: "GET",
2884
+ path: "/custom/http-error-validation-cause",
2885
+ responseType: "json",
2886
+ handler: async () => {
2887
+ throw new HTTPException(400, {
2888
+ message: "Validation failed for input",
2889
+ cause: {
2890
+ field: "input",
2891
+ errors: [{
2892
+ path: "/question",
2893
+ code: "invalid_type",
2894
+ message: "Expected string",
2895
+ received: "leak-me"
2896
+ }],
2897
+ rawInput: "leak-me-too"
2898
+ }
2899
+ });
2900
+ }
2901
+ };
2902
+ const mutableServerRoutes = SERVER_ROUTES;
2903
+ const originalLength = mutableServerRoutes.length;
2904
+ mutableServerRoutes.push(route);
2905
+ let app;
2906
+ try {
2907
+ app = (await setupAdapter(await createDefaultTestContext())).app;
2908
+ } finally {
2909
+ mutableServerRoutes.splice(originalLength);
2910
+ }
2911
+ const response = await executeHttpRequest(app, {
2912
+ method: "GET",
2913
+ path: "/api/custom/http-error-validation-cause"
2914
+ });
2915
+ expect(response.status).toBe(400);
2916
+ expect(response.data).toEqual({
2917
+ error: "Validation failed for input",
2918
+ cause: {
2919
+ field: "input",
2920
+ errors: [{
2921
+ path: "/question",
2922
+ message: "Expected string"
2923
+ }]
2924
+ }
2925
+ });
2926
+ });
2502
2927
  });
2503
2928
  describe("Run claim conflict responses (#27638)", () => {
2504
2929
  it.each([
@@ -2545,38 +2970,43 @@ function createRouteAdapterTestSuite(config) {
2545
2970
  describe("Route Prefix", () => {
2546
2971
  it("should register routes at prefixed paths without double /api", async () => {
2547
2972
  const prefixedApp = (await setupAdapter(context, { prefix: "/v2" })).app;
2548
- expect((await executeHttpRequest(prefixedApp, {
2973
+ const response = await executeHttpRequest(prefixedApp, {
2549
2974
  method: "GET",
2550
2975
  path: "/v2/agents"
2551
- })).status).toBeLessThan(400);
2976
+ });
2977
+ expect(response.status).toBeLessThan(400);
2552
2978
  });
2553
2979
  it("should not have routes at double /api path when prefix is set", async () => {
2554
2980
  const prefixedApp = (await setupAdapter(context, { prefix: "/v2" })).app;
2555
- expect((await executeHttpRequest(prefixedApp, {
2981
+ const response = await executeHttpRequest(prefixedApp, {
2556
2982
  method: "GET",
2557
2983
  path: "/v2/api/agents"
2558
- })).status).toBe(404);
2984
+ });
2985
+ expect(response.status).toBe(404);
2559
2986
  });
2560
2987
  it("should normalize prefix with trailing slash", async () => {
2561
2988
  const prefixedApp = (await setupAdapter(context, { prefix: "/mastra/" })).app;
2562
- expect((await executeHttpRequest(prefixedApp, {
2989
+ const response = await executeHttpRequest(prefixedApp, {
2563
2990
  method: "GET",
2564
2991
  path: "/mastra/agents"
2565
- })).status).toBeLessThan(400);
2992
+ });
2993
+ expect(response.status).toBeLessThan(400);
2566
2994
  });
2567
2995
  it("should normalize prefix without leading slash", async () => {
2568
2996
  const prefixedApp = (await setupAdapter(context, { prefix: "mastra" })).app;
2569
- expect((await executeHttpRequest(prefixedApp, {
2997
+ const response = await executeHttpRequest(prefixedApp, {
2570
2998
  method: "GET",
2571
2999
  path: "/mastra/agents"
2572
- })).status).toBeLessThan(400);
3000
+ });
3001
+ expect(response.status).toBeLessThan(400);
2573
3002
  });
2574
3003
  it("should not have routes at double-slash path when prefix has trailing slash", async () => {
2575
3004
  const prefixedApp = (await setupAdapter(context, { prefix: "/mastra/" })).app;
2576
- expect((await executeHttpRequest(prefixedApp, {
3005
+ const response = await executeHttpRequest(prefixedApp, {
2577
3006
  method: "GET",
2578
3007
  path: "/mastra//agents"
2579
- })).status).toBe(404);
3008
+ });
3009
+ expect(response.status).toBe(404);
2580
3010
  });
2581
3011
  });
2582
3012
  });
@@ -2740,10 +3170,11 @@ function createMCPRouteTestSuite(config) {
2740
3170
  expect(res.data.tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: "getWeather" }), expect.objectContaining({ name: "calculate" })]));
2741
3171
  });
2742
3172
  it("should return 404 for non-existent server", async () => {
2743
- expect((await executeHttpRequest(app, {
3173
+ const res = await executeHttpRequest(app, {
2744
3174
  method: "GET",
2745
3175
  path: "/api/mcp/non-existent/tools"
2746
- })).status).toBe(404);
3176
+ });
3177
+ expect(res.status).toBe(404);
2747
3178
  });
2748
3179
  });
2749
3180
  describe("GET /api/mcp/:serverId/tools/:toolId", () => {
@@ -2760,10 +3191,11 @@ function createMCPRouteTestSuite(config) {
2760
3191
  });
2761
3192
  });
2762
3193
  it("should return 404 for non-existent tool", async () => {
2763
- expect((await executeHttpRequest(app, {
3194
+ const res = await executeHttpRequest(app, {
2764
3195
  method: "GET",
2765
3196
  path: `/api/mcp/${mcpServer1.id}/tools/non-existent`
2766
- })).status).toBe(404);
3197
+ });
3198
+ expect(res.status).toBe(404);
2767
3199
  });
2768
3200
  });
2769
3201
  describe("POST /api/mcp/:serverId/tools/:toolId/execute", () => {
@@ -2793,7 +3225,7 @@ function createMCPRouteTestSuite(config) {
2793
3225
  });
2794
3226
  });
2795
3227
  it("should return 404 for non-existent server", async () => {
2796
- expect((await executeHttpRequest(app, {
3228
+ const res = await executeHttpRequest(app, {
2797
3229
  method: "POST",
2798
3230
  path: "/api/mcp/non-existent/tools/calculate/execute",
2799
3231
  body: { data: {
@@ -2801,7 +3233,8 @@ function createMCPRouteTestSuite(config) {
2801
3233
  a: 1,
2802
3234
  b: 2
2803
3235
  } }
2804
- })).status).toBe(404);
3236
+ });
3237
+ expect(res.status).toBe(404);
2805
3238
  });
2806
3239
  it("should return error for non-existent tool", async () => {
2807
3240
  const res = await executeHttpRequest(app, {
@@ -3072,17 +3505,35 @@ function createMCPTransportTestSuite(config) {
3072
3505
  case "multiply":
3073
3506
  result = a * b;
3074
3507
  break;
3075
- case "divide":
3076
- result = a / b;
3077
- break;
3508
+ case "divide": result = a / b;
3078
3509
  }
3079
3510
  return { result };
3080
3511
  }
3081
3512
  });
3513
+ const slowToolRuns = [];
3514
+ const slowTool = createTool({
3515
+ id: "slow",
3516
+ description: "Waits until aborted, optionally streaming a log notification first",
3517
+ inputSchema: z.object({ streamFirst: z.boolean() }),
3518
+ execute: async ({ streamFirst }, context) => {
3519
+ const signal = context?.abortSignal;
3520
+ const run = {};
3521
+ slowToolRuns.push(run);
3522
+ signal?.addEventListener("abort", () => run.abortedAt = Date.now(), { once: true });
3523
+ if (streamFirst) await context?.mcp?.log?.("info", "started");
3524
+ await new Promise((resolve) => {
3525
+ const timer = setTimeout(resolve, 5e3);
3526
+ signal?.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
3527
+ });
3528
+ run.finishedAt = Date.now();
3529
+ return { aborted: !!signal?.aborted };
3530
+ }
3531
+ });
3082
3532
  let httpServer;
3083
3533
  let port;
3084
3534
  let mcpServer1;
3085
3535
  let mcpServer2;
3536
+ let slowServer;
3086
3537
  let mcpClient;
3087
3538
  let mastra;
3088
3539
  beforeAll(async () => {
@@ -3101,9 +3552,15 @@ function createMCPTransportTestSuite(config) {
3101
3552
  description: "Test MCP Server 2",
3102
3553
  tools: {}
3103
3554
  });
3555
+ slowServer = new MCPServer({
3556
+ name: "slow-server",
3557
+ version: "1.0.0",
3558
+ tools: { slow: slowTool }
3559
+ });
3104
3560
  mastra = new Mastra$1({ mcpServers: {
3105
3561
  "test-server-1": mcpServer1,
3106
3562
  "test-server-2": mcpServer2,
3563
+ "slow-server": slowServer,
3107
3564
  "native-fixture": new NativeMCPFixture()
3108
3565
  } });
3109
3566
  const serverSetup = await createServer(mastra);
@@ -3119,6 +3576,7 @@ function createMCPTransportTestSuite(config) {
3119
3576
  httpServer?.close();
3120
3577
  await mcpServer1?.close();
3121
3578
  await mcpServer2?.close();
3579
+ await slowServer?.close();
3122
3580
  }, 3e4);
3123
3581
  describe("native MCP v2 adapter dispatch", () => {
3124
3582
  it("dispatches HTTP through the shared adapter path", async () => {
@@ -3136,7 +3594,8 @@ function createMCPTransportTestSuite(config) {
3136
3594
  path: "messages",
3137
3595
  method: "POST"
3138
3596
  }])("rejects the legacy $path route for a native server", async ({ path, method }) => {
3139
- expect((await fetch(`http://localhost:${port}/api/mcp/native-fixture/${path}`, { method })).status).toBe(404);
3597
+ const response = await fetch(`http://localhost:${port}/api/mcp/native-fixture/${path}`, { method });
3598
+ expect(response.status).toBe(404);
3140
3599
  });
3141
3600
  it("executes ordinary tools with the v2 request context and reports a suspended tool truthfully", async () => {
3142
3601
  const base = `http://localhost:${port}/api/mcp/native-fixture/tools`;
@@ -3188,7 +3647,7 @@ function createMCPTransportTestSuite(config) {
3188
3647
  describe("HTTP Transport (/api/mcp/:serverId/mcp)", () => {
3189
3648
  describe("Error handling (raw HTTP)", () => {
3190
3649
  it("should return 404 for non-existent server", async () => {
3191
- expect((await fetch(`http://localhost:${port}/api/mcp/non-existent/mcp`, {
3650
+ const res = await fetch(`http://localhost:${port}/api/mcp/non-existent/mcp`, {
3192
3651
  method: "POST",
3193
3652
  headers: {
3194
3653
  "Content-Type": "application/json",
@@ -3207,7 +3666,8 @@ function createMCPTransportTestSuite(config) {
3207
3666
  },
3208
3667
  id: 1
3209
3668
  })
3210
- })).status).toBe(404);
3669
+ });
3670
+ expect(res.status).toBe(404);
3211
3671
  });
3212
3672
  });
3213
3673
  describe("Protocol operations (MCPClient)", () => {
@@ -3243,7 +3703,8 @@ function createMCPTransportTestSuite(config) {
3243
3703
  const tools = await mcpClient.listTools();
3244
3704
  expect(tools["server1_getWeather"]).toBeDefined();
3245
3705
  expect(tools["server1_calculate"]).toBeDefined();
3246
- expect(Object.keys(tools).filter((k) => k.startsWith("server2_"))).toHaveLength(0);
3706
+ const server2Tools = Object.keys(tools).filter((k) => k.startsWith("server2_"));
3707
+ expect(server2Tools).toHaveLength(0);
3247
3708
  });
3248
3709
  });
3249
3710
  describe("Tool execution errors (MCPClient)", () => {
@@ -3252,17 +3713,18 @@ function createMCPTransportTestSuite(config) {
3252
3713
  let failingMastra;
3253
3714
  let failingHttpServer;
3254
3715
  beforeAll(async () => {
3716
+ const failingTool = createTool({
3717
+ id: "failingTool",
3718
+ description: "A tool that always throws an error",
3719
+ inputSchema: z.object({}),
3720
+ execute: async () => {
3721
+ throw new Error("Tool execution failed intentionally");
3722
+ }
3723
+ });
3255
3724
  failingServer = new MCPServer({
3256
3725
  name: "failingServer",
3257
3726
  version: "1.0.0",
3258
- tools: { failingTool: createTool({
3259
- id: "failingTool",
3260
- description: "A tool that always throws an error",
3261
- inputSchema: z.object({}),
3262
- execute: async () => {
3263
- throw new Error("Tool execution failed intentionally");
3264
- }
3265
- }) }
3727
+ tools: { failingTool }
3266
3728
  });
3267
3729
  failingMastra = new Mastra$1({ mcpServers: { "failing-server": failingServer } });
3268
3730
  const serverSetup = await createServer(failingMastra);
@@ -3294,13 +3756,73 @@ function createMCPTransportTestSuite(config) {
3294
3756
  });
3295
3757
  });
3296
3758
  });
3759
+ describe("HTTP Transport client disconnect", () => {
3760
+ it.each([{
3761
+ name: "before any response bytes are sent",
3762
+ streamFirst: false
3763
+ }, {
3764
+ name: "after the response has started streaming",
3765
+ streamFirst: true
3766
+ }])("aborts the in-flight tool call when the client disconnects $name", async ({ streamFirst }) => {
3767
+ const runsBefore = slowToolRuns.length;
3768
+ const body = JSON.stringify({
3769
+ jsonrpc: "2.0",
3770
+ id: 1,
3771
+ method: "tools/call",
3772
+ params: {
3773
+ name: "slow",
3774
+ arguments: { streamFirst },
3775
+ _meta: {
3776
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
3777
+ "io.modelcontextprotocol/clientInfo": {
3778
+ name: "disconnect-test",
3779
+ version: "1.0.0"
3780
+ },
3781
+ "io.modelcontextprotocol/clientCapabilities": {},
3782
+ "io.modelcontextprotocol/logLevel": "info"
3783
+ }
3784
+ }
3785
+ });
3786
+ let responseBytes = 0;
3787
+ const req = http.request({
3788
+ host: "localhost",
3789
+ port,
3790
+ method: "POST",
3791
+ path: `/api/mcp/${slowServer.id}/mcp`,
3792
+ headers: {
3793
+ "content-type": "application/json",
3794
+ accept: "application/json, text/event-stream",
3795
+ "mcp-protocol-version": "2026-07-28",
3796
+ "mcp-method": "tools/call",
3797
+ "mcp-name": "slow",
3798
+ "content-length": Buffer.byteLength(body)
3799
+ }
3800
+ });
3801
+ req.on("response", (res) => {
3802
+ res.on("data", (chunk) => responseBytes += chunk.length);
3803
+ res.on("error", () => {});
3804
+ });
3805
+ req.on("error", () => {});
3806
+ req.end(body);
3807
+ await expect.poll(() => slowToolRuns.length, { timeout: 5e3 }).toBe(runsBefore + 1);
3808
+ if (streamFirst) await expect.poll(() => responseBytes, { timeout: 5e3 }).toBeGreaterThan(0);
3809
+ else expect(responseBytes).toBe(0);
3810
+ const run = slowToolRuns[runsBefore];
3811
+ const disconnectedAt = Date.now();
3812
+ req.destroy();
3813
+ await expect.poll(() => run.finishedAt, { timeout: 6e3 }).toBeDefined();
3814
+ expect(run.abortedAt).toBeDefined();
3815
+ expect(run.abortedAt - disconnectedAt).toBeLessThan(1e3);
3816
+ }, 15e3);
3817
+ });
3297
3818
  describe("SSE Transport (/api/mcp/:serverId/sse)", () => {
3298
3819
  describe("Error handling (raw HTTP)", () => {
3299
3820
  it("should return 404 for non-existent server on GET /sse", async () => {
3300
- expect((await fetch(`http://localhost:${port}/api/mcp/non-existent/sse`)).status).toBe(404);
3821
+ const res = await fetch(`http://localhost:${port}/api/mcp/non-existent/sse`);
3822
+ expect(res.status).toBe(404);
3301
3823
  });
3302
3824
  it("should return 404 for non-existent server on POST /messages", async () => {
3303
- expect((await fetch(`http://localhost:${port}/api/mcp/non-existent/messages`, {
3825
+ const res = await fetch(`http://localhost:${port}/api/mcp/non-existent/messages`, {
3304
3826
  method: "POST",
3305
3827
  headers: { "Content-Type": "application/json" },
3306
3828
  body: JSON.stringify({
@@ -3308,14 +3830,16 @@ function createMCPTransportTestSuite(config) {
3308
3830
  method: "test",
3309
3831
  id: 1
3310
3832
  })
3311
- })).status).toBe(404);
3833
+ });
3834
+ expect(res.status).toBe(404);
3312
3835
  });
3313
3836
  });
3314
3837
  describe("MCP 2.x servers (MCPClient)", () => {
3315
3838
  it("does not serve the legacy SSE transport for a 2.x MCPServer", async (ctx) => {
3316
3839
  if (mcpServer1.mcpVersion !== 2) ctx.skip();
3317
- expect((await fetch(`http://localhost:${port}/api/mcp/${mcpServer1.id}/sse`)).status).toBe(404);
3318
- expect((await fetch(`http://localhost:${port}/api/mcp/${mcpServer1.id}/messages`, {
3840
+ const res = await fetch(`http://localhost:${port}/api/mcp/${mcpServer1.id}/sse`);
3841
+ expect(res.status).toBe(404);
3842
+ const messages = await fetch(`http://localhost:${port}/api/mcp/${mcpServer1.id}/messages`, {
3319
3843
  method: "POST",
3320
3844
  headers: { "Content-Type": "application/json" },
3321
3845
  body: JSON.stringify({
@@ -3323,7 +3847,8 @@ function createMCPTransportTestSuite(config) {
3323
3847
  method: "tools/list",
3324
3848
  id: 1
3325
3849
  })
3326
- })).status).toBe(404);
3850
+ });
3851
+ expect(messages.status).toBe(404);
3327
3852
  });
3328
3853
  it("fails to connect through the SSE URL without downgrading", async (ctx) => {
3329
3854
  if (mcpServer1.mcpVersion !== 2) ctx.skip();
@@ -3400,7 +3925,8 @@ function createMultipartTestSuite(config) {
3400
3925
  body: formData
3401
3926
  });
3402
3927
  expect(response.status).toBe(200);
3403
- expect(await response.json()).toMatchObject({
3928
+ const result = await response.json();
3929
+ expect(result).toMatchObject({
3404
3930
  success: true,
3405
3931
  hasAudio: true
3406
3932
  });
@@ -3435,10 +3961,11 @@ function createMultipartTestSuite(config) {
3435
3961
  language: "en",
3436
3962
  format: "mp3"
3437
3963
  }));
3438
- expect((await fetch(`${serverInfo.baseUrl}/test/upload-with-options`, {
3964
+ const response = await fetch(`${serverInfo.baseUrl}/test/upload-with-options`, {
3439
3965
  method: "POST",
3440
3966
  body: formData
3441
- })).status).toBe(200);
3967
+ });
3968
+ expect(response.status).toBe(200);
3442
3969
  expect(receivedBody.options).toBeDefined();
3443
3970
  expect(typeof receivedBody.options).toBe("object");
3444
3971
  expect(receivedBody.options.language).toBe("en");
@@ -3468,10 +3995,11 @@ function createMultipartTestSuite(config) {
3468
3995
  ])], { type: "audio/wav" });
3469
3996
  formData.append("audio", audioBlob, "test.wav");
3470
3997
  formData.append("name", "test-recording");
3471
- expect((await fetch(`${serverInfo.baseUrl}/test/upload-with-string`, {
3998
+ const response = await fetch(`${serverInfo.baseUrl}/test/upload-with-string`, {
3472
3999
  method: "POST",
3473
4000
  body: formData
3474
- })).status).toBe(200);
4001
+ });
4002
+ expect(response.status).toBe(200);
3475
4003
  expect(receivedBody.name).toBe("test-recording");
3476
4004
  });
3477
4005
  it("should reject files exceeding size limit", async () => {
@@ -3493,10 +4021,11 @@ function createMultipartTestSuite(config) {
3493
4021
  const formData = new FormData();
3494
4022
  const largeBlob = new Blob([/* @__PURE__ */ new Uint8Array(200)], { type: "audio/wav" });
3495
4023
  formData.append("audio", largeBlob, "large.wav");
3496
- expect((await fetch(`${serverInfo.baseUrl}/test/upload-limited`, {
4024
+ const response = await fetch(`${serverInfo.baseUrl}/test/upload-limited`, {
3497
4025
  method: "POST",
3498
4026
  body: formData
3499
- })).status).toBeGreaterThanOrEqual(400);
4027
+ });
4028
+ expect(response.status).toBeGreaterThanOrEqual(400);
3500
4029
  });
3501
4030
  it("should handle empty FormData gracefully", async () => {
3502
4031
  const { adapter, app } = await setupAdapter(context);
@@ -3529,7 +4058,8 @@ function createMultipartTestSuite(config) {
3529
4058
  body: formData
3530
4059
  });
3531
4060
  expect(response.status).toBe(200);
3532
- expect(await response.json()).toMatchObject({
4061
+ const result = await response.json();
4062
+ expect(result).toMatchObject({
3533
4063
  success: true,
3534
4064
  bodyKeys: []
3535
4065
  });
@@ -3571,7 +4101,8 @@ function createMultipartTestSuite(config) {
3571
4101
  body: formData
3572
4102
  });
3573
4103
  expect(response.status).toBe(200);
3574
- expect(await response.json()).toMatchObject({
4104
+ const result = await response.json();
4105
+ expect(result).toMatchObject({
3575
4106
  hasFile1: true,
3576
4107
  hasFile2: true
3577
4108
  });
@@ -3600,7 +4131,8 @@ function createMultipartTestSuite(config) {
3600
4131
  body: JSON.stringify({ message: "Hello, World!" })
3601
4132
  });
3602
4133
  expect(response.status).toBe(200);
3603
- expect(await response.json()).toMatchObject({
4134
+ const result = await response.json();
4135
+ expect(result).toMatchObject({
3604
4136
  success: true,
3605
4137
  message: "Hello, World!"
3606
4138
  });
@@ -3700,7 +4232,8 @@ function createHttpLoggingTestSuite(config) {
3700
4232
  logSpy = vi.spyOn(adapter.logger, "info");
3701
4233
  await adapter.init();
3702
4234
  await addRoute(app, "GET", "/test", () => ({ message: "success" }));
3703
- expect((await executeRequest(app, "GET", "http://localhost/test")).status).toBe(200);
4235
+ const response = await executeRequest(app, "GET", "http://localhost/test");
4236
+ expect(response.status).toBe(200);
3704
4237
  expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/GET \/test 200 \d+ms/), expect.objectContaining({
3705
4238
  method: "GET",
3706
4239
  path: "/test",
@@ -3860,7 +4393,7 @@ function createHttpLoggingTestSuite(config) {
3860
4393
  * request body for DELETE the same way it does for POST/PUT/PATCH.
3861
4394
  */
3862
4395
  function createBodyLimitTestSuite(config) {
3863
- const { suiteName = "Body Size Limit", maxSize = 100, createApp, setupAdapter, registerRoute, executeRequest, executeRequestWithoutContentLength, cleanupApp } = config;
4396
+ const { suiteName = "Body Size Limit", maxSize = 100, createApp, setupAdapter, registerRoute, executeRequest, executeRequestWithoutContentLength, skipNonJsonStreamedBody, cleanupApp } = config;
3864
4397
  describe(suiteName, () => {
3865
4398
  const oversizedPayload = JSON.stringify({ padding: "x".repeat(maxSize * 4) });
3866
4399
  it("enforces a route-specific limit without global body limit options", async () => {
@@ -3878,35 +4411,155 @@ function createBodyLimitTestSuite(config) {
3878
4411
  return { receivedBody: body };
3879
4412
  }
3880
4413
  });
3881
- expect((await executeRequest(wiredApp, "POST", "http://localhost/test/route-body-limit", {
4414
+ const response = await executeRequest(wiredApp, "POST", "http://localhost/test/route-body-limit", {
3882
4415
  headers: { "Content-Type": "application/json" },
3883
4416
  body: oversizedPayload
3884
- })).status).toBe(413);
3885
- expect(handlerCalled).toBe(false);
3886
- await cleanupApp?.(wiredApp);
3887
- });
3888
- if (executeRequestWithoutContentLength) it("rejects an oversized route body without Content-Length before handler execution", async () => {
3889
- const mastra = new Mastra$1({});
3890
- const app = createApp();
3891
- const { adapter, app: wiredApp } = await setupAdapter(app, mastra);
3892
- let handlerCalled = false;
3893
- await registerRoute(adapter, wiredApp, {
3894
- method: "POST",
3895
- path: "/test/route-body-limit-no-content-length",
3896
- responseType: "json",
3897
- maxBodySize: maxSize,
3898
- handler: async ({ body }) => {
3899
- handlerCalled = true;
3900
- return { receivedBody: body };
3901
- }
3902
4417
  });
3903
- expect((await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/route-body-limit-no-content-length", {
3904
- headers: { "Content-Type": "application/json" },
3905
- body: oversizedPayload
3906
- })).status).toBe(413);
4418
+ expect(response.status).toBe(413);
3907
4419
  expect(handlerCalled).toBe(false);
3908
4420
  await cleanupApp?.(wiredApp);
3909
4421
  });
4422
+ if (executeRequestWithoutContentLength) {
4423
+ it("rejects an oversized route body without Content-Length before handler execution", async () => {
4424
+ const mastra = new Mastra$1({});
4425
+ const app = createApp();
4426
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra);
4427
+ let handlerCalled = false;
4428
+ await registerRoute(adapter, wiredApp, {
4429
+ method: "POST",
4430
+ path: "/test/route-body-limit-no-content-length",
4431
+ responseType: "json",
4432
+ maxBodySize: maxSize,
4433
+ handler: async ({ body }) => {
4434
+ handlerCalled = true;
4435
+ return { receivedBody: body };
4436
+ }
4437
+ });
4438
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/route-body-limit-no-content-length", {
4439
+ headers: { "Content-Type": "application/json" },
4440
+ body: oversizedPayload
4441
+ });
4442
+ expect(response.status).toBe(413);
4443
+ expect(handlerCalled).toBe(false);
4444
+ await cleanupApp?.(wiredApp);
4445
+ });
4446
+ it.skipIf(skipNonJsonStreamedBody).each(["text/plain", "application/octet-stream"])("rejects an oversized %s body without Content-Length before handler execution", async (contentType) => {
4447
+ const mastra = new Mastra$1({});
4448
+ const app = createApp();
4449
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
4450
+ maxSize,
4451
+ onError: () => ({ error: "Request body too large" })
4452
+ });
4453
+ let handlerCalled = false;
4454
+ await registerRoute(adapter, wiredApp, {
4455
+ method: "POST",
4456
+ path: "/test/body-limit-no-content-length-non-json",
4457
+ responseType: "json",
4458
+ handler: async () => {
4459
+ handlerCalled = true;
4460
+ return { ok: true };
4461
+ }
4462
+ });
4463
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-non-json", {
4464
+ headers: { "Content-Type": contentType },
4465
+ body: "x".repeat(maxSize * 4)
4466
+ });
4467
+ expect(response.status).toBe(413);
4468
+ expect(handlerCalled).toBe(false);
4469
+ await cleanupApp?.(wiredApp);
4470
+ });
4471
+ const boundary = "bodylimitboundary";
4472
+ const multipart = (parts) => parts.map((p) => `--${boundary}\r\nContent-Disposition: form-data; name="${p.name}"` + (p.filename ? `; filename="${p.filename}"\r\nContent-Type: application/octet-stream` : "") + `\r\n\r\n${p.value}\r\n`).join("") + `--${boundary}--\r\n`;
4473
+ const multipartCases = {
4474
+ "many small fields": multipart(Array.from({ length: 20 }, (_, i) => ({
4475
+ name: `f${i}`,
4476
+ value: "x".repeat(Math.floor(maxSize / 2))
4477
+ }))),
4478
+ "one oversized file": multipart([{
4479
+ name: "file",
4480
+ filename: "f.bin",
4481
+ value: "x".repeat(maxSize * 4)
4482
+ }])
4483
+ };
4484
+ it.each(Object.keys(multipartCases))("rejects an oversized multipart body (%s) without Content-Length before handler execution", async (caseName) => {
4485
+ const mastra = new Mastra$1({});
4486
+ const app = createApp();
4487
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
4488
+ maxSize,
4489
+ onError: () => ({ error: "Request body too large" })
4490
+ });
4491
+ let handlerCalled = false;
4492
+ await registerRoute(adapter, wiredApp, {
4493
+ method: "POST",
4494
+ path: "/test/body-limit-no-content-length-multipart",
4495
+ responseType: "json",
4496
+ handler: async () => {
4497
+ handlerCalled = true;
4498
+ return { ok: true };
4499
+ }
4500
+ });
4501
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-multipart", {
4502
+ headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
4503
+ body: multipartCases[caseName]
4504
+ });
4505
+ expect(response.status).toBe(413);
4506
+ expect(handlerCalled).toBe(false);
4507
+ await cleanupApp?.(wiredApp);
4508
+ });
4509
+ it("passes a multipart body within the limit without Content-Length to the handler", async () => {
4510
+ const mastra = new Mastra$1({});
4511
+ const app = createApp();
4512
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
4513
+ maxSize,
4514
+ onError: () => ({ error: "Request body too large" })
4515
+ });
4516
+ let receivedBody;
4517
+ await registerRoute(adapter, wiredApp, {
4518
+ method: "POST",
4519
+ path: "/test/body-limit-no-content-length-multipart-within",
4520
+ responseType: "json",
4521
+ handler: async ({ a }) => {
4522
+ receivedBody = { a };
4523
+ return { ok: true };
4524
+ }
4525
+ });
4526
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-multipart-within", {
4527
+ headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
4528
+ body: multipart([{
4529
+ name: "a",
4530
+ value: "ok"
4531
+ }])
4532
+ });
4533
+ expect(response.status).toBe(200);
4534
+ expect(receivedBody).toEqual({ a: "ok" });
4535
+ await cleanupApp?.(wiredApp);
4536
+ });
4537
+ it("passes a JSON body within the limit without Content-Length to the handler", async () => {
4538
+ const mastra = new Mastra$1({});
4539
+ const app = createApp();
4540
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
4541
+ maxSize,
4542
+ onError: () => ({ error: "Request body too large" })
4543
+ });
4544
+ let receivedBody;
4545
+ await registerRoute(adapter, wiredApp, {
4546
+ method: "POST",
4547
+ path: "/test/body-limit-no-content-length-within",
4548
+ responseType: "json",
4549
+ handler: async ({ message }) => {
4550
+ receivedBody = { message };
4551
+ return { ok: true };
4552
+ }
4553
+ });
4554
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-within", {
4555
+ headers: { "Content-Type": "application/json" },
4556
+ body: JSON.stringify({ message: "hello" })
4557
+ });
4558
+ expect(response.status).toBe(200);
4559
+ expect(receivedBody).toEqual({ message: "hello" });
4560
+ await cleanupApp?.(wiredApp);
4561
+ });
4562
+ }
3910
4563
  it.each(["POST", "DELETE"])("rejects an oversized %s body with 413", async (method) => {
3911
4564
  const mastra = new Mastra$1({});
3912
4565
  const app = createApp();
@@ -3920,15 +4573,107 @@ function createBodyLimitTestSuite(config) {
3920
4573
  responseType: "json",
3921
4574
  handler: async ({ body }) => ({ receivedBody: body })
3922
4575
  });
3923
- expect((await executeRequest(wiredApp, method, "http://localhost/test/body-limit", {
4576
+ const response = await executeRequest(wiredApp, method, "http://localhost/test/body-limit", {
3924
4577
  headers: { "Content-Type": "application/json" },
3925
4578
  body: oversizedPayload
3926
- })).status).toBe(413);
4579
+ });
4580
+ expect(response.status).toBe(413);
3927
4581
  await cleanupApp?.(wiredApp);
3928
4582
  });
3929
4583
  });
3930
4584
  }
3931
4585
  //#endregion
3932
- export { consumeSSEStream, createBodyLimitTestSuite, createDefaultTestContext, createHttpLoggingTestSuite, createMCPRouteTestSuite, createMCPTransportTestSuite, createMultipartTestSuite, createRouteAdapterTestSuite, createStreamWithSensitiveData, createStreamWithUnserializableChunk, expectSerializedStreamChunks };
4586
+ //#region src/custom-route-fga-test-suite.ts
4587
+ /**
4588
+ * Custom `registerApiRoute()` handlers read path params from the URL. FGA must
4589
+ * authorize that same resource, so a query or body field named like a path
4590
+ * param must not change which resource is checked.
4591
+ */
4592
+ function createCustomRouteFGATestSuite(config) {
4593
+ const { suiteName = "Custom route FGA", startServer } = config;
4594
+ describe(suiteName, () => {
4595
+ let close;
4596
+ afterEach(async () => {
4597
+ await close?.();
4598
+ close = void 0;
4599
+ });
4600
+ const setup = async () => {
4601
+ const check = vi.fn(async (_user, { resource }) => resource.id === "open");
4602
+ const fga = {
4603
+ check,
4604
+ require: vi.fn(),
4605
+ filterAccessible: vi.fn()
4606
+ };
4607
+ const routeFGA = {
4608
+ resourceType: "doc",
4609
+ resourceIdParam: "docId",
4610
+ permission: "docs:read"
4611
+ };
4612
+ const handler = async (c) => c.json({ docId: c.req.param("docId") });
4613
+ const mastra = new Mastra({
4614
+ logger: false,
4615
+ server: {
4616
+ fga,
4617
+ apiRoutes: [registerApiRoute("/docs/:docId", {
4618
+ method: "GET",
4619
+ fga: routeFGA,
4620
+ handler
4621
+ }), registerApiRoute("/docs/:docId", {
4622
+ method: "POST",
4623
+ fga: routeFGA,
4624
+ handler
4625
+ })]
4626
+ }
4627
+ });
4628
+ const originalGetServer = mastra.getServer.bind(mastra);
4629
+ mastra.getServer = () => ({
4630
+ ...originalGetServer(),
4631
+ auth: {
4632
+ authenticateToken: async (token) => token === "valid-token" ? { id: "user-1" } : null,
4633
+ authorize: async () => true
4634
+ }
4635
+ });
4636
+ const server = await startServer(mastra);
4637
+ close = server.close;
4638
+ const send = (path, init = {}) => server.request(path, {
4639
+ ...init,
4640
+ headers: {
4641
+ authorization: "Bearer valid-token",
4642
+ ...init.headers
4643
+ }
4644
+ });
4645
+ return {
4646
+ check,
4647
+ send
4648
+ };
4649
+ };
4650
+ const checkedIds = (check) => check.mock.calls.map(([, input]) => input.resource.id);
4651
+ it("allows the permitted resource and denies the forbidden one", async () => {
4652
+ const { send } = await setup();
4653
+ const allowed = await send("/docs/open");
4654
+ expect(allowed.status).toBe(200);
4655
+ expect(await allowed.json()).toEqual({ docId: "open" });
4656
+ expect((await send("/docs/locked")).status).toBe(403);
4657
+ });
4658
+ it("checks the path param when the query has a field with the same name", async () => {
4659
+ const { check, send } = await setup();
4660
+ const response = await send("/docs/locked?docId=open");
4661
+ expect(response.status).toBe(403);
4662
+ expect(checkedIds(check)).toEqual(["locked"]);
4663
+ });
4664
+ it("checks the path param when the JSON body has a field with the same name", async () => {
4665
+ const { check, send } = await setup();
4666
+ const response = await send("/docs/locked", {
4667
+ method: "POST",
4668
+ headers: { "content-type": "application/json" },
4669
+ body: JSON.stringify({ docId: "open" })
4670
+ });
4671
+ expect(response.status).toBe(403);
4672
+ expect(checkedIds(check)).toEqual(["locked"]);
4673
+ });
4674
+ });
4675
+ }
4676
+ //#endregion
4677
+ export { consumeSSEStream, createBodyLimitTestSuite, createCustomRouteFGATestSuite, createDefaultTestContext, createHttpLoggingTestSuite, createMCPRouteTestSuite, createMCPTransportTestSuite, createMultipartTestSuite, createRouteAdapterTestSuite, createStreamWithSensitiveData, createStreamWithUnserializableChunk, expectSerializedStreamChunks };
3933
4678
 
3934
4679
  //# sourceMappingURL=index.js.map