@mastra/server-adapters-test-suite 0.1.3 → 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";
@@ -60,6 +62,7 @@ function getRouteSpecificSchemaDefaults(route) {
60
62
  path: "entityName"
61
63
  } };
62
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" } } };
63
66
  if (route.path === "/datasets/:datasetId/compare") return { body: {
64
67
  experimentIds: ["test-experiment-a", "test-experiment-b"],
65
68
  baselineId: "test-experiment-a"
@@ -343,6 +346,7 @@ function getDefaultValidPathParams(route) {
343
346
  if (route.path.includes(":id") && route.path.includes("/mcp/v0/servers/")) params.id = "test-server-1";
344
347
  if (route.path.includes(":serverId")) params.serverId = "test-server-1";
345
348
  if (route.path.includes(":toolId") && route.path.includes("/mcp/")) params.toolId = "getWeather";
349
+ if (route.path.includes(":eventName")) params.eventName = "ticket.created";
346
350
  if (route.path.includes(":workspaceId")) params.workspaceId = "test-workspace";
347
351
  if (route.path.includes(":skillName")) params.skillName = "test-skill";
348
352
  if (route.path.includes(":referencePath")) params.referencePath = "test-reference.md";
@@ -820,7 +824,11 @@ async function createDefaultTestContext() {
820
824
  appResources: { "ui://test/app": {
821
825
  name: "Test App",
822
826
  html: "<html><body>Test</body></html>"
823
- } }
827
+ } },
828
+ events: { "ticket.created": defineEvent({
829
+ description: "A ticket was created",
830
+ payloadSchema: z$3.object({ id: z$3.string() })
831
+ }) }
824
832
  });
825
833
  const mcpServer2 = new MCPServer({
826
834
  name: "Test Server 2",
@@ -965,7 +973,8 @@ async function createDefaultTestContext() {
965
973
  "trace-aggregate",
966
974
  "thread-query",
967
975
  "thread-query-rows",
968
- "span-query"
976
+ "span-query",
977
+ "span-aggregate"
969
978
  ]);
970
979
  vi.spyOn(observability, "queryTraces").mockResolvedValue({
971
980
  traces: [],
@@ -991,6 +1000,10 @@ async function createDefaultTestContext() {
991
1000
  spans: [],
992
1001
  page: { next: null }
993
1002
  });
1003
+ vi.spyOn(observability, "aggregateSpans").mockResolvedValue({
1004
+ rows: [],
1005
+ truncated: false
1006
+ });
994
1007
  await observability.createSpan({ span: {
995
1008
  spanId: "test-span",
996
1009
  traceId: "test-trace",
@@ -2156,6 +2169,7 @@ function createRouteAdapterTestSuite(config) {
2156
2169
  "/editor/builder/registries/:registryId/install",
2157
2170
  "/background-tasks/stream",
2158
2171
  "/agents/:agentId/observe",
2172
+ "/mcp/:serverId/events/:eventName/stream",
2159
2173
  "/agents/:agentId/recover",
2160
2174
  "/agents/:agentId/plans/file",
2161
2175
  "/tool-providers/:providerId/connections/:connectionId",
@@ -2168,7 +2182,9 @@ function createRouteAdapterTestSuite(config) {
2168
2182
  ];
2169
2183
  const excludedPrefixes = ["/datasets", "/agent-controller"];
2170
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));
2171
- 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) => {
2172
2188
  const category = route.path.split("/")[1] || "root";
2173
2189
  if (!acc[category]) acc[category] = [];
2174
2190
  acc[category].push(route);
@@ -2179,6 +2195,14 @@ function createRouteAdapterTestSuite(config) {
2179
2195
  routes.forEach((route) => {
2180
2196
  const testName = `${route.method} ${route.path}`;
2181
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
+ }
2182
2206
  it("should execute with valid request", async () => {
2183
2207
  const request = buildRouteRequest(route);
2184
2208
  const httpRequest = {
@@ -2438,6 +2462,83 @@ function createRouteAdapterTestSuite(config) {
2438
2462
  });
2439
2463
  });
2440
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
+ });
2441
2542
  describe("Custom HTTPException responses", () => {
2442
2543
  async function setupCustomErrorRoutes() {
2443
2544
  let handlerCalls = 0;
@@ -2474,6 +2575,49 @@ function createRouteAdapterTestSuite(config) {
2474
2575
  handlerCalls++;
2475
2576
  throw new HTTPException(404, { message: "Legacy fallback" });
2476
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
+ }
2477
2621
  }
2478
2622
  ];
2479
2623
  const mutableServerRoutes = SERVER_ROUTES;
@@ -2525,6 +2669,261 @@ function createRouteAdapterTestSuite(config) {
2525
2669
  expect(response.data).toEqual({ error: "Legacy fallback" });
2526
2670
  expect(custom.getHandlerCalls()).toBe(1);
2527
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
+ });
2528
2927
  });
2529
2928
  describe("Run claim conflict responses (#27638)", () => {
2530
2929
  it.each([
@@ -3111,10 +3510,30 @@ function createMCPTransportTestSuite(config) {
3111
3510
  return { result };
3112
3511
  }
3113
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
+ });
3114
3532
  let httpServer;
3115
3533
  let port;
3116
3534
  let mcpServer1;
3117
3535
  let mcpServer2;
3536
+ let slowServer;
3118
3537
  let mcpClient;
3119
3538
  let mastra;
3120
3539
  beforeAll(async () => {
@@ -3133,9 +3552,15 @@ function createMCPTransportTestSuite(config) {
3133
3552
  description: "Test MCP Server 2",
3134
3553
  tools: {}
3135
3554
  });
3555
+ slowServer = new MCPServer({
3556
+ name: "slow-server",
3557
+ version: "1.0.0",
3558
+ tools: { slow: slowTool }
3559
+ });
3136
3560
  mastra = new Mastra$1({ mcpServers: {
3137
3561
  "test-server-1": mcpServer1,
3138
3562
  "test-server-2": mcpServer2,
3563
+ "slow-server": slowServer,
3139
3564
  "native-fixture": new NativeMCPFixture()
3140
3565
  } });
3141
3566
  const serverSetup = await createServer(mastra);
@@ -3151,6 +3576,7 @@ function createMCPTransportTestSuite(config) {
3151
3576
  httpServer?.close();
3152
3577
  await mcpServer1?.close();
3153
3578
  await mcpServer2?.close();
3579
+ await slowServer?.close();
3154
3580
  }, 3e4);
3155
3581
  describe("native MCP v2 adapter dispatch", () => {
3156
3582
  it("dispatches HTTP through the shared adapter path", async () => {
@@ -3330,6 +3756,65 @@ function createMCPTransportTestSuite(config) {
3330
3756
  });
3331
3757
  });
3332
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
+ });
3333
3818
  describe("SSE Transport (/api/mcp/:serverId/sse)", () => {
3334
3819
  describe("Error handling (raw HTTP)", () => {
3335
3820
  it("should return 404 for non-existent server on GET /sse", async () => {
@@ -3908,7 +4393,7 @@ function createHttpLoggingTestSuite(config) {
3908
4393
  * request body for DELETE the same way it does for POST/PUT/PATCH.
3909
4394
  */
3910
4395
  function createBodyLimitTestSuite(config) {
3911
- 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;
3912
4397
  describe(suiteName, () => {
3913
4398
  const oversizedPayload = JSON.stringify({ padding: "x".repeat(maxSize * 4) });
3914
4399
  it("enforces a route-specific limit without global body limit options", async () => {
@@ -3934,29 +4419,147 @@ function createBodyLimitTestSuite(config) {
3934
4419
  expect(handlerCalled).toBe(false);
3935
4420
  await cleanupApp?.(wiredApp);
3936
4421
  });
3937
- if (executeRequestWithoutContentLength) it("rejects an oversized route body without Content-Length before handler execution", async () => {
3938
- const mastra = new Mastra$1({});
3939
- const app = createApp();
3940
- const { adapter, app: wiredApp } = await setupAdapter(app, mastra);
3941
- let handlerCalled = false;
3942
- await registerRoute(adapter, wiredApp, {
3943
- method: "POST",
3944
- path: "/test/route-body-limit-no-content-length",
3945
- responseType: "json",
3946
- maxBodySize: maxSize,
3947
- handler: async ({ body }) => {
3948
- handlerCalled = true;
3949
- return { receivedBody: body };
3950
- }
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);
3951
4445
  });
3952
- const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/route-body-limit-no-content-length", {
3953
- headers: { "Content-Type": "application/json" },
3954
- body: oversizedPayload
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);
3955
4470
  });
3956
- expect(response.status).toBe(413);
3957
- expect(handlerCalled).toBe(false);
3958
- await cleanupApp?.(wiredApp);
3959
- });
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
+ }
3960
4563
  it.each(["POST", "DELETE"])("rejects an oversized %s body with 413", async (method) => {
3961
4564
  const mastra = new Mastra$1({});
3962
4565
  const app = createApp();
@@ -3980,6 +4583,97 @@ function createBodyLimitTestSuite(config) {
3980
4583
  });
3981
4584
  }
3982
4585
  //#endregion
3983
- 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 };
3984
4678
 
3985
4679
  //# sourceMappingURL=index.js.map