@mastra/server-adapters-test-suite 0.1.3 → 0.1.4-alpha.1
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/CHANGELOG.md +26 -0
- package/dist/body-limit-test-suite.d.ts +5 -0
- package/dist/body-limit-test-suite.d.ts.map +1 -1
- package/dist/custom-route-fga-test-suite.d.ts +23 -0
- package/dist/custom-route-fga-test-suite.d.ts.map +1 -0
- package/dist/durable-disconnect-test-suite.d.ts +23 -0
- package/dist/durable-disconnect-test-suite.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +995 -27
- package/dist/index.js.map +1 -1
- package/dist/mcp-transport-test-suite.d.ts.map +1 -1
- package/dist/route-adapter-test-suite.d.ts.map +1 -1
- package/dist/route-test-utils.d.ts.map +1 -1
- package/dist/test-helpers.d.ts.map +1 -1
- package/package.json +8 -8
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,10 @@ 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";
|
|
28
|
+
import { createDurableAgent, createEventedAgent } from "@mastra/core/agent/durable";
|
|
27
29
|
import { Mastra as Mastra$1 } from "@mastra/core/mastra";
|
|
30
|
+
import http from "http";
|
|
28
31
|
import { MCPServerBase } from "@mastra/core/mcp";
|
|
29
32
|
import { RequestContext } from "@mastra/core/request-context";
|
|
30
33
|
import { standardSchemaToJSONSchema } from "@mastra/core/schema";
|
|
@@ -60,6 +63,7 @@ function getRouteSpecificSchemaDefaults(route) {
|
|
|
60
63
|
path: "entityName"
|
|
61
64
|
} };
|
|
62
65
|
if (route.path === "/agent-controller/:controllerId/sessions" && route.method === "POST") return { body: { resourceId: "test-resource" } };
|
|
66
|
+
if (route.path === "/mcp/:serverId/events/:eventName/emit") return { body: { data: { id: "test-ticket" } } };
|
|
63
67
|
if (route.path === "/datasets/:datasetId/compare") return { body: {
|
|
64
68
|
experimentIds: ["test-experiment-a", "test-experiment-b"],
|
|
65
69
|
baselineId: "test-experiment-a"
|
|
@@ -343,6 +347,7 @@ function getDefaultValidPathParams(route) {
|
|
|
343
347
|
if (route.path.includes(":id") && route.path.includes("/mcp/v0/servers/")) params.id = "test-server-1";
|
|
344
348
|
if (route.path.includes(":serverId")) params.serverId = "test-server-1";
|
|
345
349
|
if (route.path.includes(":toolId") && route.path.includes("/mcp/")) params.toolId = "getWeather";
|
|
350
|
+
if (route.path.includes(":eventName")) params.eventName = "ticket.created";
|
|
346
351
|
if (route.path.includes(":workspaceId")) params.workspaceId = "test-workspace";
|
|
347
352
|
if (route.path.includes(":skillName")) params.skillName = "test-skill";
|
|
348
353
|
if (route.path.includes(":referencePath")) params.referencePath = "test-reference.md";
|
|
@@ -820,7 +825,11 @@ async function createDefaultTestContext() {
|
|
|
820
825
|
appResources: { "ui://test/app": {
|
|
821
826
|
name: "Test App",
|
|
822
827
|
html: "<html><body>Test</body></html>"
|
|
823
|
-
} }
|
|
828
|
+
} },
|
|
829
|
+
events: { "ticket.created": defineEvent({
|
|
830
|
+
description: "A ticket was created",
|
|
831
|
+
payloadSchema: z$3.object({ id: z$3.string() })
|
|
832
|
+
}) }
|
|
824
833
|
});
|
|
825
834
|
const mcpServer2 = new MCPServer({
|
|
826
835
|
name: "Test Server 2",
|
|
@@ -965,7 +974,8 @@ async function createDefaultTestContext() {
|
|
|
965
974
|
"trace-aggregate",
|
|
966
975
|
"thread-query",
|
|
967
976
|
"thread-query-rows",
|
|
968
|
-
"span-query"
|
|
977
|
+
"span-query",
|
|
978
|
+
"span-aggregate"
|
|
969
979
|
]);
|
|
970
980
|
vi.spyOn(observability, "queryTraces").mockResolvedValue({
|
|
971
981
|
traces: [],
|
|
@@ -991,6 +1001,10 @@ async function createDefaultTestContext() {
|
|
|
991
1001
|
spans: [],
|
|
992
1002
|
page: { next: null }
|
|
993
1003
|
});
|
|
1004
|
+
vi.spyOn(observability, "aggregateSpans").mockResolvedValue({
|
|
1005
|
+
rows: [],
|
|
1006
|
+
truncated: false
|
|
1007
|
+
});
|
|
994
1008
|
await observability.createSpan({ span: {
|
|
995
1009
|
spanId: "test-span",
|
|
996
1010
|
traceId: "test-trace",
|
|
@@ -2156,7 +2170,9 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2156
2170
|
"/editor/builder/registries/:registryId/install",
|
|
2157
2171
|
"/background-tasks/stream",
|
|
2158
2172
|
"/agents/:agentId/observe",
|
|
2173
|
+
"/mcp/:serverId/events/:eventName/stream",
|
|
2159
2174
|
"/agents/:agentId/recover",
|
|
2175
|
+
"/agents/:agentId/runs/:runId/abort",
|
|
2160
2176
|
"/agents/:agentId/plans/file",
|
|
2161
2177
|
"/tool-providers/:providerId/connections/:connectionId",
|
|
2162
2178
|
"/tool-providers/:providerId/connections/:connectionId/usage",
|
|
@@ -2168,7 +2184,9 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2168
2184
|
];
|
|
2169
2185
|
const excludedPrefixes = ["/datasets", "/agent-controller"];
|
|
2170
2186
|
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
|
|
2187
|
+
const activeRoutes = SERVER_ROUTES.filter((r) => !isExcluded(r));
|
|
2188
|
+
const devOnlyRoutes = ["/mcp/:serverId/events/:eventName/emit"];
|
|
2189
|
+
const routesByCategory = activeRoutes.reduce((acc, route) => {
|
|
2172
2190
|
const category = route.path.split("/")[1] || "root";
|
|
2173
2191
|
if (!acc[category]) acc[category] = [];
|
|
2174
2192
|
acc[category].push(route);
|
|
@@ -2179,6 +2197,14 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2179
2197
|
routes.forEach((route) => {
|
|
2180
2198
|
const testName = `${route.method} ${route.path}`;
|
|
2181
2199
|
describe(testName, () => {
|
|
2200
|
+
if (devOnlyRoutes.includes(route.path)) {
|
|
2201
|
+
beforeEach(() => {
|
|
2202
|
+
vi.stubEnv("MASTRA_DEV", "true");
|
|
2203
|
+
});
|
|
2204
|
+
afterEach(() => {
|
|
2205
|
+
vi.unstubAllEnvs();
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2182
2208
|
it("should execute with valid request", async () => {
|
|
2183
2209
|
const request = buildRouteRequest(route);
|
|
2184
2210
|
const httpRequest = {
|
|
@@ -2438,6 +2464,83 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2438
2464
|
});
|
|
2439
2465
|
});
|
|
2440
2466
|
});
|
|
2467
|
+
describe("Studio-only RBAC", () => {
|
|
2468
|
+
const rolePermissions = {
|
|
2469
|
+
viewer: ["agents:read"],
|
|
2470
|
+
member: ["agents:read", "tools:execute"]
|
|
2471
|
+
};
|
|
2472
|
+
const studioAuth = {
|
|
2473
|
+
authenticateToken: async (token) => rolePermissions[token] ? {
|
|
2474
|
+
id: `user_${token}`,
|
|
2475
|
+
role: token
|
|
2476
|
+
} : null,
|
|
2477
|
+
authorize: async () => true
|
|
2478
|
+
};
|
|
2479
|
+
const studioRbac = {
|
|
2480
|
+
getPermissions: async (user) => rolePermissions[user.role] ?? [],
|
|
2481
|
+
getRoles: async (user) => [user.role]
|
|
2482
|
+
};
|
|
2483
|
+
async function setupStudioRbac(options) {
|
|
2484
|
+
const ctx = await createDefaultTestContext();
|
|
2485
|
+
const originalGetServer = ctx.mastra.getServer.bind(ctx.mastra);
|
|
2486
|
+
const customRoute = registerApiRoute("/studio-rbac/custom", {
|
|
2487
|
+
method: "GET",
|
|
2488
|
+
requiresPermission: "tools:execute",
|
|
2489
|
+
handler: async (c) => c.json({ ok: true })
|
|
2490
|
+
});
|
|
2491
|
+
ctx.mastra.getServer = () => ({
|
|
2492
|
+
...originalGetServer(),
|
|
2493
|
+
apiRoutes: [customRoute],
|
|
2494
|
+
...options.withServerAuth ? { auth: studioAuth } : {}
|
|
2495
|
+
});
|
|
2496
|
+
ctx.mastra.getStudio = () => ({
|
|
2497
|
+
auth: studioAuth,
|
|
2498
|
+
rbac: studioRbac
|
|
2499
|
+
});
|
|
2500
|
+
ctx.customRouteAuthConfig = /* @__PURE__ */ new Map([["GET:/studio-rbac/custom", true]]);
|
|
2501
|
+
const route = {
|
|
2502
|
+
method: "GET",
|
|
2503
|
+
path: "/test/studio-rbac",
|
|
2504
|
+
responseType: "json",
|
|
2505
|
+
requiresPermission: "tools:execute",
|
|
2506
|
+
handler: async () => ({ ok: true })
|
|
2507
|
+
};
|
|
2508
|
+
const mutableServerRoutes = SERVER_ROUTES;
|
|
2509
|
+
const originalLength = mutableServerRoutes.length;
|
|
2510
|
+
mutableServerRoutes.push(route);
|
|
2511
|
+
try {
|
|
2512
|
+
return (await setupAdapter(ctx)).app;
|
|
2513
|
+
} finally {
|
|
2514
|
+
mutableServerRoutes.splice(originalLength);
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
const studioRequest = (path, role) => ({
|
|
2518
|
+
method: "GET",
|
|
2519
|
+
path,
|
|
2520
|
+
headers: {
|
|
2521
|
+
Authorization: `Bearer ${role}`,
|
|
2522
|
+
"x-mastra-client-type": "studio"
|
|
2523
|
+
}
|
|
2524
|
+
});
|
|
2525
|
+
describe.each([{
|
|
2526
|
+
label: "studio auth only",
|
|
2527
|
+
withServerAuth: false
|
|
2528
|
+
}, {
|
|
2529
|
+
label: "studio auth + server auth without server RBAC",
|
|
2530
|
+
withServerAuth: true
|
|
2531
|
+
}])("$label", ({ withServerAuth }) => {
|
|
2532
|
+
it.each(["/api/test/studio-rbac", "/studio-rbac/custom"])("denies a studio user without the required permission on %s", async (path) => {
|
|
2533
|
+
const studioApp = await setupStudioRbac({ withServerAuth });
|
|
2534
|
+
const response = await executeHttpRequest(studioApp, studioRequest(path, "viewer"));
|
|
2535
|
+
expect(response.status).toBe(403);
|
|
2536
|
+
});
|
|
2537
|
+
it.each(["/api/test/studio-rbac", "/studio-rbac/custom"])("allows a studio user with the required permission on %s", async (path) => {
|
|
2538
|
+
const studioApp = await setupStudioRbac({ withServerAuth });
|
|
2539
|
+
const response = await executeHttpRequest(studioApp, studioRequest(path, "member"));
|
|
2540
|
+
expect(response.status).toBe(200);
|
|
2541
|
+
});
|
|
2542
|
+
});
|
|
2543
|
+
});
|
|
2441
2544
|
describe("Custom HTTPException responses", () => {
|
|
2442
2545
|
async function setupCustomErrorRoutes() {
|
|
2443
2546
|
let handlerCalls = 0;
|
|
@@ -2474,6 +2577,49 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2474
2577
|
handlerCalls++;
|
|
2475
2578
|
throw new HTTPException(404, { message: "Legacy fallback" });
|
|
2476
2579
|
}
|
|
2580
|
+
},
|
|
2581
|
+
{
|
|
2582
|
+
method: "GET",
|
|
2583
|
+
path: "/custom/http-error-failing-items",
|
|
2584
|
+
responseType: "json",
|
|
2585
|
+
handler: async () => {
|
|
2586
|
+
handlerCalls++;
|
|
2587
|
+
throw new HTTPException(400, {
|
|
2588
|
+
message: "Existing items fail the new schema",
|
|
2589
|
+
cause: { failingItems: [{
|
|
2590
|
+
index: 0,
|
|
2591
|
+
data: {},
|
|
2592
|
+
field: "input"
|
|
2593
|
+
}] }
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
},
|
|
2597
|
+
{
|
|
2598
|
+
method: "GET",
|
|
2599
|
+
path: "/custom/http-error-unsafe-cause",
|
|
2600
|
+
responseType: "json",
|
|
2601
|
+
handler: async () => {
|
|
2602
|
+
handlerCalls++;
|
|
2603
|
+
throw new HTTPException(400, {
|
|
2604
|
+
message: "Unsafe cause",
|
|
2605
|
+
cause: {
|
|
2606
|
+
secret: "do-not-leak",
|
|
2607
|
+
failingItems: "not-an-array"
|
|
2608
|
+
}
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
},
|
|
2612
|
+
{
|
|
2613
|
+
method: "GET",
|
|
2614
|
+
path: "/custom/details-status-error",
|
|
2615
|
+
responseType: "json",
|
|
2616
|
+
handler: async () => {
|
|
2617
|
+
handlerCalls++;
|
|
2618
|
+
throw Object.assign(/* @__PURE__ */ new Error("Details status"), {
|
|
2619
|
+
details: { status: 422 },
|
|
2620
|
+
cause: { failingItems: [{ index: 0 }] }
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2477
2623
|
}
|
|
2478
2624
|
];
|
|
2479
2625
|
const mutableServerRoutes = SERVER_ROUTES;
|
|
@@ -2525,6 +2671,261 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2525
2671
|
expect(response.data).toEqual({ error: "Legacy fallback" });
|
|
2526
2672
|
expect(custom.getHandlerCalls()).toBe(1);
|
|
2527
2673
|
});
|
|
2674
|
+
it("includes failingItems from the HTTPException cause in the JSON body", async () => {
|
|
2675
|
+
const custom = await setupCustomErrorRoutes();
|
|
2676
|
+
const response = await executeHttpRequest(custom.app, {
|
|
2677
|
+
method: "GET",
|
|
2678
|
+
path: "/api/custom/http-error-failing-items"
|
|
2679
|
+
});
|
|
2680
|
+
expect(response.status).toBe(400);
|
|
2681
|
+
expect(response.data.error).toBe("Existing items fail the new schema");
|
|
2682
|
+
expect(response.data.cause).toEqual({ failingItems: [{
|
|
2683
|
+
index: 0,
|
|
2684
|
+
data: {},
|
|
2685
|
+
field: "input"
|
|
2686
|
+
}] });
|
|
2687
|
+
});
|
|
2688
|
+
it("never forwards an arbitrary HTTPException cause", async () => {
|
|
2689
|
+
const custom = await setupCustomErrorRoutes();
|
|
2690
|
+
const response = await executeHttpRequest(custom.app, {
|
|
2691
|
+
method: "GET",
|
|
2692
|
+
path: "/api/custom/http-error-unsafe-cause"
|
|
2693
|
+
});
|
|
2694
|
+
expect(response.status).toBe(400);
|
|
2695
|
+
expect(response.data.error).toBe("Unsafe cause");
|
|
2696
|
+
expect(response.data).not.toHaveProperty("cause");
|
|
2697
|
+
expect(JSON.stringify(response.data)).not.toContain("do-not-leak");
|
|
2698
|
+
});
|
|
2699
|
+
it("keeps details.status errors as a message-only body", async () => {
|
|
2700
|
+
const custom = await setupCustomErrorRoutes();
|
|
2701
|
+
const response = await executeHttpRequest(custom.app, {
|
|
2702
|
+
method: "GET",
|
|
2703
|
+
path: "/api/custom/details-status-error"
|
|
2704
|
+
});
|
|
2705
|
+
expect(response.status).toBe(422);
|
|
2706
|
+
expect(response.data.error).toBe("Details status");
|
|
2707
|
+
expect(response.data).not.toHaveProperty("cause");
|
|
2708
|
+
});
|
|
2709
|
+
});
|
|
2710
|
+
describe("Dataset schema update rejected by existing items", () => {
|
|
2711
|
+
it("returns the failing items so clients can report how many items fail", async () => {
|
|
2712
|
+
const context = await createDefaultTestContext();
|
|
2713
|
+
const dataset = await context.mastra.datasets.create({ name: "schema-update-parity" });
|
|
2714
|
+
await dataset.addItem({ input: { prompt: "hello" } });
|
|
2715
|
+
const setup = await setupAdapter(context);
|
|
2716
|
+
const response = await executeHttpRequest(setup.app, {
|
|
2717
|
+
method: "PATCH",
|
|
2718
|
+
path: `/api/datasets/${dataset.id}`,
|
|
2719
|
+
body: { inputSchema: {
|
|
2720
|
+
type: "object",
|
|
2721
|
+
properties: { question: { type: "string" } },
|
|
2722
|
+
required: ["question"]
|
|
2723
|
+
} }
|
|
2724
|
+
});
|
|
2725
|
+
expect(response.status).toBe(400);
|
|
2726
|
+
const failingItems = response.data?.cause?.failingItems;
|
|
2727
|
+
expect(Array.isArray(failingItems)).toBe(true);
|
|
2728
|
+
expect(failingItems).toHaveLength(1);
|
|
2729
|
+
expect(failingItems[0]).toMatchObject({
|
|
2730
|
+
index: 0,
|
|
2731
|
+
field: "input"
|
|
2732
|
+
});
|
|
2733
|
+
});
|
|
2734
|
+
});
|
|
2735
|
+
describe("HTTPException thrown from a registerApiRoute custom route", () => {
|
|
2736
|
+
class ForeignHTTPException extends Error {
|
|
2737
|
+
status;
|
|
2738
|
+
constructor(status, options) {
|
|
2739
|
+
super(options.message);
|
|
2740
|
+
this.status = status;
|
|
2741
|
+
}
|
|
2742
|
+
res = void 0;
|
|
2743
|
+
getResponse() {
|
|
2744
|
+
return new Response(this.message, { status: this.status });
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
async function setupCustomRouteErrors() {
|
|
2748
|
+
const ctx = await createDefaultTestContext();
|
|
2749
|
+
const originalGetServer = ctx.mastra.getServer.bind(ctx.mastra);
|
|
2750
|
+
const routes = [
|
|
2751
|
+
registerApiRoute("/custom-errors/not-found", {
|
|
2752
|
+
method: "GET",
|
|
2753
|
+
handler: async () => {
|
|
2754
|
+
throw new HTTPException(404, { message: "Widget not found" });
|
|
2755
|
+
}
|
|
2756
|
+
}),
|
|
2757
|
+
registerApiRoute("/custom-errors/conflict", {
|
|
2758
|
+
method: "POST",
|
|
2759
|
+
handler: async () => {
|
|
2760
|
+
throw new ForeignHTTPException(409, { message: "Widget already exists" });
|
|
2761
|
+
}
|
|
2762
|
+
}),
|
|
2763
|
+
registerApiRoute("/custom-errors/custom-response", {
|
|
2764
|
+
method: "GET",
|
|
2765
|
+
handler: async () => {
|
|
2766
|
+
throw new HTTPException(429, { res: new Response(JSON.stringify({ error: "Slow down" }), {
|
|
2767
|
+
status: 429,
|
|
2768
|
+
headers: {
|
|
2769
|
+
"Content-Type": "application/json",
|
|
2770
|
+
"Retry-After": "5"
|
|
2771
|
+
}
|
|
2772
|
+
}) });
|
|
2773
|
+
}
|
|
2774
|
+
}),
|
|
2775
|
+
registerApiRoute("/custom-errors/unexpected", {
|
|
2776
|
+
method: "GET",
|
|
2777
|
+
handler: async () => {
|
|
2778
|
+
throw new Error("db password=do-not-leak");
|
|
2779
|
+
}
|
|
2780
|
+
}),
|
|
2781
|
+
registerApiRoute("/custom-errors/invalid-status", {
|
|
2782
|
+
method: "GET",
|
|
2783
|
+
handler: async () => {
|
|
2784
|
+
throw new ForeignHTTPException(NaN, { message: "bad status do-not-leak" });
|
|
2785
|
+
}
|
|
2786
|
+
})
|
|
2787
|
+
];
|
|
2788
|
+
ctx.mastra.getServer = () => ({
|
|
2789
|
+
...originalGetServer(),
|
|
2790
|
+
apiRoutes: routes
|
|
2791
|
+
});
|
|
2792
|
+
return (await setupAdapter(ctx)).app;
|
|
2793
|
+
}
|
|
2794
|
+
async function bodyText(response) {
|
|
2795
|
+
if (!response.stream) return typeof response.data === "string" ? response.data : JSON.stringify(response.data);
|
|
2796
|
+
const decoder = new TextDecoder();
|
|
2797
|
+
let text = "";
|
|
2798
|
+
for await (const chunk of response.stream) text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
2799
|
+
return text + decoder.decode();
|
|
2800
|
+
}
|
|
2801
|
+
it("keeps the status and message of a Mastra HTTPException", async () => {
|
|
2802
|
+
const customApp = await setupCustomRouteErrors();
|
|
2803
|
+
const response = await executeHttpRequest(customApp, {
|
|
2804
|
+
method: "GET",
|
|
2805
|
+
path: "/custom-errors/not-found"
|
|
2806
|
+
});
|
|
2807
|
+
expect(response.status).toBe(404);
|
|
2808
|
+
expect(JSON.parse(await bodyText(response))).toEqual({ error: "Widget not found" });
|
|
2809
|
+
});
|
|
2810
|
+
it("keeps the status and message of an HTTPException from another hono install", async () => {
|
|
2811
|
+
const customApp = await setupCustomRouteErrors();
|
|
2812
|
+
const response = await executeHttpRequest(customApp, {
|
|
2813
|
+
method: "POST",
|
|
2814
|
+
path: "/custom-errors/conflict",
|
|
2815
|
+
body: {}
|
|
2816
|
+
});
|
|
2817
|
+
expect(response.status).toBe(409);
|
|
2818
|
+
expect(JSON.parse(await bodyText(response))).toEqual({ error: "Widget already exists" });
|
|
2819
|
+
});
|
|
2820
|
+
it("returns the HTTPException custom response with its headers", async () => {
|
|
2821
|
+
const customApp = await setupCustomRouteErrors();
|
|
2822
|
+
const response = await executeHttpRequest(customApp, {
|
|
2823
|
+
method: "GET",
|
|
2824
|
+
path: "/custom-errors/custom-response"
|
|
2825
|
+
});
|
|
2826
|
+
expect(response.status).toBe(429);
|
|
2827
|
+
expect(response.headers["retry-after"]).toBe("5");
|
|
2828
|
+
expect(JSON.parse(await bodyText(response))).toEqual({ error: "Slow down" });
|
|
2829
|
+
});
|
|
2830
|
+
it("still hides unexpected errors behind a generic 500", async () => {
|
|
2831
|
+
const customApp = await setupCustomRouteErrors();
|
|
2832
|
+
const response = await executeHttpRequest(customApp, {
|
|
2833
|
+
method: "GET",
|
|
2834
|
+
path: "/custom-errors/unexpected"
|
|
2835
|
+
});
|
|
2836
|
+
expect(response.status).toBe(500);
|
|
2837
|
+
const text = await bodyText(response);
|
|
2838
|
+
expect(text).toContain("Internal Server Error");
|
|
2839
|
+
expect(text).not.toContain("do-not-leak");
|
|
2840
|
+
});
|
|
2841
|
+
it("treats an HTTPException with a non-integer status as unexpected", async () => {
|
|
2842
|
+
const customApp = await setupCustomRouteErrors();
|
|
2843
|
+
const response = await executeHttpRequest(customApp, {
|
|
2844
|
+
method: "GET",
|
|
2845
|
+
path: "/custom-errors/invalid-status"
|
|
2846
|
+
});
|
|
2847
|
+
expect(response.status).toBe(500);
|
|
2848
|
+
const text = await bodyText(response);
|
|
2849
|
+
expect(text).toContain("Internal Server Error");
|
|
2850
|
+
expect(text).not.toContain("do-not-leak");
|
|
2851
|
+
});
|
|
2852
|
+
});
|
|
2853
|
+
describe("Dataset item rejected by the input schema", () => {
|
|
2854
|
+
it("returns the failing field paths and messages without the submitted values", async () => {
|
|
2855
|
+
const context = await createDefaultTestContext();
|
|
2856
|
+
const dataset = await context.mastra.datasets.create({
|
|
2857
|
+
name: "item-schema-parity",
|
|
2858
|
+
inputSchema: {
|
|
2859
|
+
type: "object",
|
|
2860
|
+
properties: { question: { type: "string" } },
|
|
2861
|
+
required: ["question"]
|
|
2862
|
+
}
|
|
2863
|
+
});
|
|
2864
|
+
const setup = await setupAdapter(context);
|
|
2865
|
+
const response = await executeHttpRequest(setup.app, {
|
|
2866
|
+
method: "POST",
|
|
2867
|
+
path: `/api/datasets/${dataset.id}/items`,
|
|
2868
|
+
body: { input: {
|
|
2869
|
+
question: { marker: "rejected-secret-value" },
|
|
2870
|
+
note: "submitted-secret-value"
|
|
2871
|
+
} }
|
|
2872
|
+
});
|
|
2873
|
+
expect(response.status).toBe(400);
|
|
2874
|
+
const cause = response.data?.cause;
|
|
2875
|
+
expect(cause?.field).toBe("input");
|
|
2876
|
+
expect(cause?.errors).toEqual([{
|
|
2877
|
+
path: "/question",
|
|
2878
|
+
message: expect.any(String)
|
|
2879
|
+
}]);
|
|
2880
|
+
expect(JSON.stringify(response.data)).not.toContain("submitted-secret-value");
|
|
2881
|
+
expect(JSON.stringify(response.data)).not.toContain("rejected-secret-value");
|
|
2882
|
+
});
|
|
2883
|
+
it("strips anything but path and message from validation errors", async () => {
|
|
2884
|
+
const route = {
|
|
2885
|
+
method: "GET",
|
|
2886
|
+
path: "/custom/http-error-validation-cause",
|
|
2887
|
+
responseType: "json",
|
|
2888
|
+
handler: async () => {
|
|
2889
|
+
throw new HTTPException(400, {
|
|
2890
|
+
message: "Validation failed for input",
|
|
2891
|
+
cause: {
|
|
2892
|
+
field: "input",
|
|
2893
|
+
errors: [{
|
|
2894
|
+
path: "/question",
|
|
2895
|
+
code: "invalid_type",
|
|
2896
|
+
message: "Expected string",
|
|
2897
|
+
received: "leak-me"
|
|
2898
|
+
}],
|
|
2899
|
+
rawInput: "leak-me-too"
|
|
2900
|
+
}
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
};
|
|
2904
|
+
const mutableServerRoutes = SERVER_ROUTES;
|
|
2905
|
+
const originalLength = mutableServerRoutes.length;
|
|
2906
|
+
mutableServerRoutes.push(route);
|
|
2907
|
+
let app;
|
|
2908
|
+
try {
|
|
2909
|
+
app = (await setupAdapter(await createDefaultTestContext())).app;
|
|
2910
|
+
} finally {
|
|
2911
|
+
mutableServerRoutes.splice(originalLength);
|
|
2912
|
+
}
|
|
2913
|
+
const response = await executeHttpRequest(app, {
|
|
2914
|
+
method: "GET",
|
|
2915
|
+
path: "/api/custom/http-error-validation-cause"
|
|
2916
|
+
});
|
|
2917
|
+
expect(response.status).toBe(400);
|
|
2918
|
+
expect(response.data).toEqual({
|
|
2919
|
+
error: "Validation failed for input",
|
|
2920
|
+
cause: {
|
|
2921
|
+
field: "input",
|
|
2922
|
+
errors: [{
|
|
2923
|
+
path: "/question",
|
|
2924
|
+
message: "Expected string"
|
|
2925
|
+
}]
|
|
2926
|
+
}
|
|
2927
|
+
});
|
|
2928
|
+
});
|
|
2528
2929
|
});
|
|
2529
2930
|
describe("Run claim conflict responses (#27638)", () => {
|
|
2530
2931
|
it.each([
|
|
@@ -2613,6 +3014,278 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2613
3014
|
});
|
|
2614
3015
|
}
|
|
2615
3016
|
//#endregion
|
|
3017
|
+
//#region src/durable-disconnect-test-suite.ts
|
|
3018
|
+
function deferred() {
|
|
3019
|
+
let resolve;
|
|
3020
|
+
return {
|
|
3021
|
+
promise: new Promise((r) => {
|
|
3022
|
+
resolve = r;
|
|
3023
|
+
}),
|
|
3024
|
+
resolve
|
|
3025
|
+
};
|
|
3026
|
+
}
|
|
3027
|
+
function createDurableDisconnectTestSuite(config) {
|
|
3028
|
+
describe.each([
|
|
3029
|
+
"plain",
|
|
3030
|
+
"durable",
|
|
3031
|
+
"evented"
|
|
3032
|
+
])("HTTP observer disconnect: %s (COR-1489)", (kind) => {
|
|
3033
|
+
let server;
|
|
3034
|
+
let fixture;
|
|
3035
|
+
let mastra;
|
|
3036
|
+
afterEach(async () => {
|
|
3037
|
+
if (mastra) await mastra.shutdown();
|
|
3038
|
+
if (server) await server.close();
|
|
3039
|
+
if (fixture) await fixture.close();
|
|
3040
|
+
server = void 0;
|
|
3041
|
+
mastra = void 0;
|
|
3042
|
+
fixture = void 0;
|
|
3043
|
+
});
|
|
3044
|
+
for (const route of [
|
|
3045
|
+
"stream",
|
|
3046
|
+
"stream-alias",
|
|
3047
|
+
"stream-until-idle",
|
|
3048
|
+
"resume-stream",
|
|
3049
|
+
"resume-alias",
|
|
3050
|
+
"resume-stream-until-idle",
|
|
3051
|
+
"approve-tool-call",
|
|
3052
|
+
"decline-tool-call"
|
|
3053
|
+
]) for (const explicitAbort of [
|
|
3054
|
+
false,
|
|
3055
|
+
true,
|
|
3056
|
+
"run",
|
|
3057
|
+
"threadless"
|
|
3058
|
+
]) {
|
|
3059
|
+
if (typeof explicitAbort === "string" && (kind === "plain" || !["stream", "stream-alias"].includes(route))) continue;
|
|
3060
|
+
const threadless = explicitAbort === "threadless";
|
|
3061
|
+
it(`${route}: disconnect ${explicitAbort ? `then explicit ${explicitAbort === true ? "thread" : explicitAbort} abort stops execution` : "preserves durable execution and replay"}`, async () => {
|
|
3062
|
+
fixture = await config.setupModel();
|
|
3063
|
+
const entered = deferred();
|
|
3064
|
+
const gate = deferred();
|
|
3065
|
+
const disconnected = deferred();
|
|
3066
|
+
const calls = [];
|
|
3067
|
+
let toolSignal;
|
|
3068
|
+
const storage = new InMemoryStore();
|
|
3069
|
+
const memory = new MockMemory({ storage });
|
|
3070
|
+
const slow = createTool({
|
|
3071
|
+
id: "slow",
|
|
3072
|
+
description: "Execute a numbered step",
|
|
3073
|
+
inputSchema: z.object({ n: z.number() }),
|
|
3074
|
+
execute: async ({ n }, context) => {
|
|
3075
|
+
calls.push(n);
|
|
3076
|
+
if (n === 1) {
|
|
3077
|
+
toolSignal = context?.abortSignal;
|
|
3078
|
+
entered.resolve();
|
|
3079
|
+
await Promise.race([gate.promise, new Promise((resolve) => {
|
|
3080
|
+
if (toolSignal?.aborted) return resolve();
|
|
3081
|
+
toolSignal?.addEventListener("abort", () => resolve(), { once: true });
|
|
3082
|
+
})]);
|
|
3083
|
+
}
|
|
3084
|
+
return { n };
|
|
3085
|
+
}
|
|
3086
|
+
});
|
|
3087
|
+
const research = createTool({
|
|
3088
|
+
id: "research",
|
|
3089
|
+
description: "Background research",
|
|
3090
|
+
inputSchema: z.object({}),
|
|
3091
|
+
background: { enabled: true },
|
|
3092
|
+
execute: async () => ({ done: true })
|
|
3093
|
+
});
|
|
3094
|
+
let authorizations = 0;
|
|
3095
|
+
const authorize = createTool({
|
|
3096
|
+
id: "authorize",
|
|
3097
|
+
description: "Authorize the steps",
|
|
3098
|
+
inputSchema: z.object({}),
|
|
3099
|
+
requireApproval: true,
|
|
3100
|
+
execute: async () => {
|
|
3101
|
+
authorizations++;
|
|
3102
|
+
return { approved: true };
|
|
3103
|
+
}
|
|
3104
|
+
});
|
|
3105
|
+
const resumed = route.startsWith("resume") || route.endsWith("tool-call");
|
|
3106
|
+
const offset = resumed ? 1 : 0;
|
|
3107
|
+
const inner = new Agent({
|
|
3108
|
+
id: "disconnect-agent",
|
|
3109
|
+
name: "Disconnect agent",
|
|
3110
|
+
instructions: "Execute three steps.",
|
|
3111
|
+
model: fixture.model,
|
|
3112
|
+
...threadless ? {} : { memory },
|
|
3113
|
+
tools: {
|
|
3114
|
+
slow,
|
|
3115
|
+
research,
|
|
3116
|
+
authorize
|
|
3117
|
+
},
|
|
3118
|
+
backgroundTasks: { tools: { research: true } }
|
|
3119
|
+
});
|
|
3120
|
+
const agent = kind === "durable" ? createDurableAgent({ agent: inner }) : kind === "evented" ? createEventedAgent({ agent: inner }) : inner;
|
|
3121
|
+
const otherAgent = createDurableAgent({ agent: new Agent({
|
|
3122
|
+
id: "other-agent",
|
|
3123
|
+
name: "Other agent",
|
|
3124
|
+
instructions: "Do not run.",
|
|
3125
|
+
model: fixture.model
|
|
3126
|
+
}) });
|
|
3127
|
+
mastra = new Mastra$1({
|
|
3128
|
+
agents: {
|
|
3129
|
+
agent,
|
|
3130
|
+
otherAgent
|
|
3131
|
+
},
|
|
3132
|
+
storage,
|
|
3133
|
+
logger: false,
|
|
3134
|
+
backgroundTasks: {
|
|
3135
|
+
enabled: true,
|
|
3136
|
+
globalConcurrency: 5,
|
|
3137
|
+
perAgentConcurrency: 3
|
|
3138
|
+
}
|
|
3139
|
+
});
|
|
3140
|
+
server = await config.startServer(mastra, (path) => {
|
|
3141
|
+
if (!resumed || !path.endsWith("/stream")) disconnected.resolve();
|
|
3142
|
+
});
|
|
3143
|
+
const base = `${server.baseUrl}/api/agents/agent`;
|
|
3144
|
+
if (resumed) fixture.addResponse(0, { toolCalls: [{
|
|
3145
|
+
id: "authorize-1",
|
|
3146
|
+
name: "authorize",
|
|
3147
|
+
arguments: {}
|
|
3148
|
+
}] });
|
|
3149
|
+
for (let n = 1; n <= 3; n++) fixture.addResponse(offset + n - 1, { toolCalls: [{
|
|
3150
|
+
id: `slow-${n}`,
|
|
3151
|
+
name: "slow",
|
|
3152
|
+
arguments: { n }
|
|
3153
|
+
}] });
|
|
3154
|
+
fixture.addResponse(offset + 3, { content: "done: 3 results" });
|
|
3155
|
+
const runId = `disconnect-${kind}-${route}-${explicitAbort}`;
|
|
3156
|
+
const thread = {
|
|
3157
|
+
thread: runId,
|
|
3158
|
+
resource: "disconnect-user"
|
|
3159
|
+
};
|
|
3160
|
+
if (resumed) {
|
|
3161
|
+
const parked = await fetch(`${base}/stream`, {
|
|
3162
|
+
method: "POST",
|
|
3163
|
+
headers: { "content-type": "application/json" },
|
|
3164
|
+
body: JSON.stringify({
|
|
3165
|
+
messages: "Start",
|
|
3166
|
+
runId,
|
|
3167
|
+
memory: thread,
|
|
3168
|
+
maxSteps: 6
|
|
3169
|
+
})
|
|
3170
|
+
});
|
|
3171
|
+
expect(parked.status).toBe(200);
|
|
3172
|
+
const reader = parked.body.getReader();
|
|
3173
|
+
let received = "";
|
|
3174
|
+
while (!received.includes("tool-call-approval")) {
|
|
3175
|
+
const { done, value } = await reader.read();
|
|
3176
|
+
expect(done).toBe(false);
|
|
3177
|
+
received += new TextDecoder().decode(value);
|
|
3178
|
+
}
|
|
3179
|
+
await reader.cancel();
|
|
3180
|
+
}
|
|
3181
|
+
const client = new AbortController();
|
|
3182
|
+
const request = fetch(`${base}/${route === "stream-alias" ? "stream" : route === "resume-alias" ? "resume-stream" : route}`, {
|
|
3183
|
+
method: "POST",
|
|
3184
|
+
headers: { "content-type": "application/json" },
|
|
3185
|
+
signal: client.signal,
|
|
3186
|
+
body: JSON.stringify({
|
|
3187
|
+
...resumed ? {
|
|
3188
|
+
resumeData: { approved: true },
|
|
3189
|
+
toolCallId: "authorize-1"
|
|
3190
|
+
} : { messages: "Start" },
|
|
3191
|
+
runId,
|
|
3192
|
+
...threadless ? {} : { memory: thread },
|
|
3193
|
+
maxSteps: 6,
|
|
3194
|
+
...["stream-alias", "resume-alias"].includes(route) ? { untilIdle: true } : {}
|
|
3195
|
+
})
|
|
3196
|
+
}).then(async (response) => {
|
|
3197
|
+
expect(response.status).toBe(200);
|
|
3198
|
+
await response.text();
|
|
3199
|
+
}).catch((error) => {
|
|
3200
|
+
if (error.name !== "AbortError") throw error;
|
|
3201
|
+
});
|
|
3202
|
+
try {
|
|
3203
|
+
await entered.promise;
|
|
3204
|
+
client.abort();
|
|
3205
|
+
await disconnected.promise;
|
|
3206
|
+
await request;
|
|
3207
|
+
if (kind !== "plain" && explicitAbort) {
|
|
3208
|
+
const runScoped = typeof explicitAbort === "string";
|
|
3209
|
+
if (runScoped) {
|
|
3210
|
+
const denied = await fetch(`${server.baseUrl}/api/agents/otherAgent/runs/${runId}/abort`, { method: "POST" });
|
|
3211
|
+
expect(denied.status).toBe(403);
|
|
3212
|
+
expect(toolSignal?.aborted).toBe(false);
|
|
3213
|
+
}
|
|
3214
|
+
const response = await fetch(`${base}/${runScoped ? `runs/${runId}/abort` : "threads/abort"}`, {
|
|
3215
|
+
method: "POST",
|
|
3216
|
+
headers: { "content-type": "application/json" },
|
|
3217
|
+
...runScoped ? {} : { body: JSON.stringify({
|
|
3218
|
+
threadId: runId,
|
|
3219
|
+
resourceId: thread.resource,
|
|
3220
|
+
expectedRunId: runId
|
|
3221
|
+
}) }
|
|
3222
|
+
});
|
|
3223
|
+
const result = await response.json();
|
|
3224
|
+
expect(response.status).toBe(200);
|
|
3225
|
+
expect(result).toEqual({ aborted: true });
|
|
3226
|
+
}
|
|
3227
|
+
const completes = kind !== "plain" && !explicitAbort;
|
|
3228
|
+
if (!completes) await vi.waitFor(() => expect(toolSignal?.aborted).toBe(true));
|
|
3229
|
+
else expect(toolSignal?.aborted).toBe(false);
|
|
3230
|
+
const observing = kind === "plain" ? void 0 : fetch(`${base}/observe`, {
|
|
3231
|
+
method: "POST",
|
|
3232
|
+
headers: { "content-type": "application/json" },
|
|
3233
|
+
body: JSON.stringify({ runId })
|
|
3234
|
+
});
|
|
3235
|
+
gate.resolve();
|
|
3236
|
+
if (observing) {
|
|
3237
|
+
const response = await observing;
|
|
3238
|
+
expect(response.status).toBe(200);
|
|
3239
|
+
const replay = await response.text();
|
|
3240
|
+
expect(replay).toContain("finish");
|
|
3241
|
+
if (completes) expect(replay).toContain("done: 3 results");
|
|
3242
|
+
else expect(replay).toContain("abort");
|
|
3243
|
+
}
|
|
3244
|
+
await vi.waitFor(() => expect(calls).toEqual(completes ? [
|
|
3245
|
+
1,
|
|
3246
|
+
2,
|
|
3247
|
+
3
|
|
3248
|
+
] : [1]));
|
|
3249
|
+
expect(fixture.getRequestCount()).toBe(offset + (completes ? 4 : 1));
|
|
3250
|
+
if (resumed) expect(authorizations).toBe(route === "decline-tool-call" ? 0 : 1);
|
|
3251
|
+
if (kind !== "plain") {
|
|
3252
|
+
const workflows = await storage.getStore("workflows");
|
|
3253
|
+
await vi.waitFor(async () => {
|
|
3254
|
+
for (const workflowName of ["durable-agentic-loop", "durable-agentic-execution"]) expect(await workflows.loadWorkflowSnapshot({
|
|
3255
|
+
workflowName,
|
|
3256
|
+
runId
|
|
3257
|
+
})).toBeFalsy();
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
if (completes) {
|
|
3261
|
+
await vi.waitFor(async () => {
|
|
3262
|
+
const { messages } = await memory.recall({
|
|
3263
|
+
threadId: runId,
|
|
3264
|
+
resourceId: thread.resource
|
|
3265
|
+
});
|
|
3266
|
+
expect(messages.flatMap((m) => m.content.parts ?? []).some((p) => p.type === "text" && p.text === "done: 3 results")).toBe(true);
|
|
3267
|
+
});
|
|
3268
|
+
const replay = await fetch(`${base}/observe`, {
|
|
3269
|
+
method: "POST",
|
|
3270
|
+
headers: { "content-type": "application/json" },
|
|
3271
|
+
body: JSON.stringify({ runId })
|
|
3272
|
+
}).then((response) => response.text());
|
|
3273
|
+
expect(replay).toContain("done: 3 results");
|
|
3274
|
+
expect(replay).toContain("finish");
|
|
3275
|
+
const lateAbort = await fetch(`${base}/runs/${runId}/abort`, { method: "POST" });
|
|
3276
|
+
expect(lateAbort.status).toBe(200);
|
|
3277
|
+
expect(await lateAbort.json()).toEqual({ aborted: false });
|
|
3278
|
+
expect(fixture.getRequestCount()).toBe(offset + 4);
|
|
3279
|
+
}
|
|
3280
|
+
} finally {
|
|
3281
|
+
gate.resolve();
|
|
3282
|
+
client.abort();
|
|
3283
|
+
}
|
|
3284
|
+
}, 3e4);
|
|
3285
|
+
}
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
//#endregion
|
|
2616
3289
|
//#region src/mcp-route-test-suite.ts
|
|
2617
3290
|
/**
|
|
2618
3291
|
* Creates a standardized integration test suite for MCP registry routes
|
|
@@ -3111,10 +3784,30 @@ function createMCPTransportTestSuite(config) {
|
|
|
3111
3784
|
return { result };
|
|
3112
3785
|
}
|
|
3113
3786
|
});
|
|
3787
|
+
const slowToolRuns = [];
|
|
3788
|
+
const slowTool = createTool({
|
|
3789
|
+
id: "slow",
|
|
3790
|
+
description: "Waits until aborted, optionally streaming a log notification first",
|
|
3791
|
+
inputSchema: z.object({ streamFirst: z.boolean() }),
|
|
3792
|
+
execute: async ({ streamFirst }, context) => {
|
|
3793
|
+
const signal = context?.abortSignal;
|
|
3794
|
+
const run = {};
|
|
3795
|
+
slowToolRuns.push(run);
|
|
3796
|
+
signal?.addEventListener("abort", () => run.abortedAt = Date.now(), { once: true });
|
|
3797
|
+
if (streamFirst) await context?.mcp?.log?.("info", "started");
|
|
3798
|
+
await new Promise((resolve) => {
|
|
3799
|
+
const timer = setTimeout(resolve, 5e3);
|
|
3800
|
+
signal?.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
|
|
3801
|
+
});
|
|
3802
|
+
run.finishedAt = Date.now();
|
|
3803
|
+
return { aborted: !!signal?.aborted };
|
|
3804
|
+
}
|
|
3805
|
+
});
|
|
3114
3806
|
let httpServer;
|
|
3115
3807
|
let port;
|
|
3116
3808
|
let mcpServer1;
|
|
3117
3809
|
let mcpServer2;
|
|
3810
|
+
let slowServer;
|
|
3118
3811
|
let mcpClient;
|
|
3119
3812
|
let mastra;
|
|
3120
3813
|
beforeAll(async () => {
|
|
@@ -3133,9 +3826,15 @@ function createMCPTransportTestSuite(config) {
|
|
|
3133
3826
|
description: "Test MCP Server 2",
|
|
3134
3827
|
tools: {}
|
|
3135
3828
|
});
|
|
3829
|
+
slowServer = new MCPServer({
|
|
3830
|
+
name: "slow-server",
|
|
3831
|
+
version: "1.0.0",
|
|
3832
|
+
tools: { slow: slowTool }
|
|
3833
|
+
});
|
|
3136
3834
|
mastra = new Mastra$1({ mcpServers: {
|
|
3137
3835
|
"test-server-1": mcpServer1,
|
|
3138
3836
|
"test-server-2": mcpServer2,
|
|
3837
|
+
"slow-server": slowServer,
|
|
3139
3838
|
"native-fixture": new NativeMCPFixture()
|
|
3140
3839
|
} });
|
|
3141
3840
|
const serverSetup = await createServer(mastra);
|
|
@@ -3151,6 +3850,7 @@ function createMCPTransportTestSuite(config) {
|
|
|
3151
3850
|
httpServer?.close();
|
|
3152
3851
|
await mcpServer1?.close();
|
|
3153
3852
|
await mcpServer2?.close();
|
|
3853
|
+
await slowServer?.close();
|
|
3154
3854
|
}, 3e4);
|
|
3155
3855
|
describe("native MCP v2 adapter dispatch", () => {
|
|
3156
3856
|
it("dispatches HTTP through the shared adapter path", async () => {
|
|
@@ -3330,6 +4030,65 @@ function createMCPTransportTestSuite(config) {
|
|
|
3330
4030
|
});
|
|
3331
4031
|
});
|
|
3332
4032
|
});
|
|
4033
|
+
describe("HTTP Transport client disconnect", () => {
|
|
4034
|
+
it.each([{
|
|
4035
|
+
name: "before any response bytes are sent",
|
|
4036
|
+
streamFirst: false
|
|
4037
|
+
}, {
|
|
4038
|
+
name: "after the response has started streaming",
|
|
4039
|
+
streamFirst: true
|
|
4040
|
+
}])("aborts the in-flight tool call when the client disconnects $name", async ({ streamFirst }) => {
|
|
4041
|
+
const runsBefore = slowToolRuns.length;
|
|
4042
|
+
const body = JSON.stringify({
|
|
4043
|
+
jsonrpc: "2.0",
|
|
4044
|
+
id: 1,
|
|
4045
|
+
method: "tools/call",
|
|
4046
|
+
params: {
|
|
4047
|
+
name: "slow",
|
|
4048
|
+
arguments: { streamFirst },
|
|
4049
|
+
_meta: {
|
|
4050
|
+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
4051
|
+
"io.modelcontextprotocol/clientInfo": {
|
|
4052
|
+
name: "disconnect-test",
|
|
4053
|
+
version: "1.0.0"
|
|
4054
|
+
},
|
|
4055
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
4056
|
+
"io.modelcontextprotocol/logLevel": "info"
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
});
|
|
4060
|
+
let responseBytes = 0;
|
|
4061
|
+
const req = http.request({
|
|
4062
|
+
host: "localhost",
|
|
4063
|
+
port,
|
|
4064
|
+
method: "POST",
|
|
4065
|
+
path: `/api/mcp/${slowServer.id}/mcp`,
|
|
4066
|
+
headers: {
|
|
4067
|
+
"content-type": "application/json",
|
|
4068
|
+
accept: "application/json, text/event-stream",
|
|
4069
|
+
"mcp-protocol-version": "2026-07-28",
|
|
4070
|
+
"mcp-method": "tools/call",
|
|
4071
|
+
"mcp-name": "slow",
|
|
4072
|
+
"content-length": Buffer.byteLength(body)
|
|
4073
|
+
}
|
|
4074
|
+
});
|
|
4075
|
+
req.on("response", (res) => {
|
|
4076
|
+
res.on("data", (chunk) => responseBytes += chunk.length);
|
|
4077
|
+
res.on("error", () => {});
|
|
4078
|
+
});
|
|
4079
|
+
req.on("error", () => {});
|
|
4080
|
+
req.end(body);
|
|
4081
|
+
await expect.poll(() => slowToolRuns.length, { timeout: 5e3 }).toBe(runsBefore + 1);
|
|
4082
|
+
if (streamFirst) await expect.poll(() => responseBytes, { timeout: 5e3 }).toBeGreaterThan(0);
|
|
4083
|
+
else expect(responseBytes).toBe(0);
|
|
4084
|
+
const run = slowToolRuns[runsBefore];
|
|
4085
|
+
const disconnectedAt = Date.now();
|
|
4086
|
+
req.destroy();
|
|
4087
|
+
await expect.poll(() => run.finishedAt, { timeout: 6e3 }).toBeDefined();
|
|
4088
|
+
expect(run.abortedAt).toBeDefined();
|
|
4089
|
+
expect(run.abortedAt - disconnectedAt).toBeLessThan(1e3);
|
|
4090
|
+
}, 15e3);
|
|
4091
|
+
});
|
|
3333
4092
|
describe("SSE Transport (/api/mcp/:serverId/sse)", () => {
|
|
3334
4093
|
describe("Error handling (raw HTTP)", () => {
|
|
3335
4094
|
it("should return 404 for non-existent server on GET /sse", async () => {
|
|
@@ -3908,7 +4667,7 @@ function createHttpLoggingTestSuite(config) {
|
|
|
3908
4667
|
* request body for DELETE the same way it does for POST/PUT/PATCH.
|
|
3909
4668
|
*/
|
|
3910
4669
|
function createBodyLimitTestSuite(config) {
|
|
3911
|
-
const { suiteName = "Body Size Limit", maxSize = 100, createApp, setupAdapter, registerRoute, executeRequest, executeRequestWithoutContentLength, cleanupApp } = config;
|
|
4670
|
+
const { suiteName = "Body Size Limit", maxSize = 100, createApp, setupAdapter, registerRoute, executeRequest, executeRequestWithoutContentLength, skipNonJsonStreamedBody, cleanupApp } = config;
|
|
3912
4671
|
describe(suiteName, () => {
|
|
3913
4672
|
const oversizedPayload = JSON.stringify({ padding: "x".repeat(maxSize * 4) });
|
|
3914
4673
|
it("enforces a route-specific limit without global body limit options", async () => {
|
|
@@ -3934,29 +4693,147 @@ function createBodyLimitTestSuite(config) {
|
|
|
3934
4693
|
expect(handlerCalled).toBe(false);
|
|
3935
4694
|
await cleanupApp?.(wiredApp);
|
|
3936
4695
|
});
|
|
3937
|
-
if (executeRequestWithoutContentLength)
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
4696
|
+
if (executeRequestWithoutContentLength) {
|
|
4697
|
+
it("rejects an oversized route body without Content-Length before handler execution", async () => {
|
|
4698
|
+
const mastra = new Mastra$1({});
|
|
4699
|
+
const app = createApp();
|
|
4700
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra);
|
|
4701
|
+
let handlerCalled = false;
|
|
4702
|
+
await registerRoute(adapter, wiredApp, {
|
|
4703
|
+
method: "POST",
|
|
4704
|
+
path: "/test/route-body-limit-no-content-length",
|
|
4705
|
+
responseType: "json",
|
|
4706
|
+
maxBodySize: maxSize,
|
|
4707
|
+
handler: async ({ body }) => {
|
|
4708
|
+
handlerCalled = true;
|
|
4709
|
+
return { receivedBody: body };
|
|
4710
|
+
}
|
|
4711
|
+
});
|
|
4712
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/route-body-limit-no-content-length", {
|
|
4713
|
+
headers: { "Content-Type": "application/json" },
|
|
4714
|
+
body: oversizedPayload
|
|
4715
|
+
});
|
|
4716
|
+
expect(response.status).toBe(413);
|
|
4717
|
+
expect(handlerCalled).toBe(false);
|
|
4718
|
+
await cleanupApp?.(wiredApp);
|
|
3951
4719
|
});
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
4720
|
+
it.skipIf(skipNonJsonStreamedBody).each(["text/plain", "application/octet-stream"])("rejects an oversized %s body without Content-Length before handler execution", async (contentType) => {
|
|
4721
|
+
const mastra = new Mastra$1({});
|
|
4722
|
+
const app = createApp();
|
|
4723
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
|
|
4724
|
+
maxSize,
|
|
4725
|
+
onError: () => ({ error: "Request body too large" })
|
|
4726
|
+
});
|
|
4727
|
+
let handlerCalled = false;
|
|
4728
|
+
await registerRoute(adapter, wiredApp, {
|
|
4729
|
+
method: "POST",
|
|
4730
|
+
path: "/test/body-limit-no-content-length-non-json",
|
|
4731
|
+
responseType: "json",
|
|
4732
|
+
handler: async () => {
|
|
4733
|
+
handlerCalled = true;
|
|
4734
|
+
return { ok: true };
|
|
4735
|
+
}
|
|
4736
|
+
});
|
|
4737
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-non-json", {
|
|
4738
|
+
headers: { "Content-Type": contentType },
|
|
4739
|
+
body: "x".repeat(maxSize * 4)
|
|
4740
|
+
});
|
|
4741
|
+
expect(response.status).toBe(413);
|
|
4742
|
+
expect(handlerCalled).toBe(false);
|
|
4743
|
+
await cleanupApp?.(wiredApp);
|
|
3955
4744
|
});
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
4745
|
+
const boundary = "bodylimitboundary";
|
|
4746
|
+
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`;
|
|
4747
|
+
const multipartCases = {
|
|
4748
|
+
"many small fields": multipart(Array.from({ length: 20 }, (_, i) => ({
|
|
4749
|
+
name: `f${i}`,
|
|
4750
|
+
value: "x".repeat(Math.floor(maxSize / 2))
|
|
4751
|
+
}))),
|
|
4752
|
+
"one oversized file": multipart([{
|
|
4753
|
+
name: "file",
|
|
4754
|
+
filename: "f.bin",
|
|
4755
|
+
value: "x".repeat(maxSize * 4)
|
|
4756
|
+
}])
|
|
4757
|
+
};
|
|
4758
|
+
it.each(Object.keys(multipartCases))("rejects an oversized multipart body (%s) without Content-Length before handler execution", async (caseName) => {
|
|
4759
|
+
const mastra = new Mastra$1({});
|
|
4760
|
+
const app = createApp();
|
|
4761
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
|
|
4762
|
+
maxSize,
|
|
4763
|
+
onError: () => ({ error: "Request body too large" })
|
|
4764
|
+
});
|
|
4765
|
+
let handlerCalled = false;
|
|
4766
|
+
await registerRoute(adapter, wiredApp, {
|
|
4767
|
+
method: "POST",
|
|
4768
|
+
path: "/test/body-limit-no-content-length-multipart",
|
|
4769
|
+
responseType: "json",
|
|
4770
|
+
handler: async () => {
|
|
4771
|
+
handlerCalled = true;
|
|
4772
|
+
return { ok: true };
|
|
4773
|
+
}
|
|
4774
|
+
});
|
|
4775
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-multipart", {
|
|
4776
|
+
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
|
4777
|
+
body: multipartCases[caseName]
|
|
4778
|
+
});
|
|
4779
|
+
expect(response.status).toBe(413);
|
|
4780
|
+
expect(handlerCalled).toBe(false);
|
|
4781
|
+
await cleanupApp?.(wiredApp);
|
|
4782
|
+
});
|
|
4783
|
+
it("passes a multipart body within the limit without Content-Length to the handler", async () => {
|
|
4784
|
+
const mastra = new Mastra$1({});
|
|
4785
|
+
const app = createApp();
|
|
4786
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
|
|
4787
|
+
maxSize,
|
|
4788
|
+
onError: () => ({ error: "Request body too large" })
|
|
4789
|
+
});
|
|
4790
|
+
let receivedBody;
|
|
4791
|
+
await registerRoute(adapter, wiredApp, {
|
|
4792
|
+
method: "POST",
|
|
4793
|
+
path: "/test/body-limit-no-content-length-multipart-within",
|
|
4794
|
+
responseType: "json",
|
|
4795
|
+
handler: async ({ a }) => {
|
|
4796
|
+
receivedBody = { a };
|
|
4797
|
+
return { ok: true };
|
|
4798
|
+
}
|
|
4799
|
+
});
|
|
4800
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-multipart-within", {
|
|
4801
|
+
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
|
4802
|
+
body: multipart([{
|
|
4803
|
+
name: "a",
|
|
4804
|
+
value: "ok"
|
|
4805
|
+
}])
|
|
4806
|
+
});
|
|
4807
|
+
expect(response.status).toBe(200);
|
|
4808
|
+
expect(receivedBody).toEqual({ a: "ok" });
|
|
4809
|
+
await cleanupApp?.(wiredApp);
|
|
4810
|
+
});
|
|
4811
|
+
it("passes a JSON body within the limit without Content-Length to the handler", async () => {
|
|
4812
|
+
const mastra = new Mastra$1({});
|
|
4813
|
+
const app = createApp();
|
|
4814
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
|
|
4815
|
+
maxSize,
|
|
4816
|
+
onError: () => ({ error: "Request body too large" })
|
|
4817
|
+
});
|
|
4818
|
+
let receivedBody;
|
|
4819
|
+
await registerRoute(adapter, wiredApp, {
|
|
4820
|
+
method: "POST",
|
|
4821
|
+
path: "/test/body-limit-no-content-length-within",
|
|
4822
|
+
responseType: "json",
|
|
4823
|
+
handler: async ({ message }) => {
|
|
4824
|
+
receivedBody = { message };
|
|
4825
|
+
return { ok: true };
|
|
4826
|
+
}
|
|
4827
|
+
});
|
|
4828
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-within", {
|
|
4829
|
+
headers: { "Content-Type": "application/json" },
|
|
4830
|
+
body: JSON.stringify({ message: "hello" })
|
|
4831
|
+
});
|
|
4832
|
+
expect(response.status).toBe(200);
|
|
4833
|
+
expect(receivedBody).toEqual({ message: "hello" });
|
|
4834
|
+
await cleanupApp?.(wiredApp);
|
|
4835
|
+
});
|
|
4836
|
+
}
|
|
3960
4837
|
it.each(["POST", "DELETE"])("rejects an oversized %s body with 413", async (method) => {
|
|
3961
4838
|
const mastra = new Mastra$1({});
|
|
3962
4839
|
const app = createApp();
|
|
@@ -3980,6 +4857,97 @@ function createBodyLimitTestSuite(config) {
|
|
|
3980
4857
|
});
|
|
3981
4858
|
}
|
|
3982
4859
|
//#endregion
|
|
3983
|
-
|
|
4860
|
+
//#region src/custom-route-fga-test-suite.ts
|
|
4861
|
+
/**
|
|
4862
|
+
* Custom `registerApiRoute()` handlers read path params from the URL. FGA must
|
|
4863
|
+
* authorize that same resource, so a query or body field named like a path
|
|
4864
|
+
* param must not change which resource is checked.
|
|
4865
|
+
*/
|
|
4866
|
+
function createCustomRouteFGATestSuite(config) {
|
|
4867
|
+
const { suiteName = "Custom route FGA", startServer } = config;
|
|
4868
|
+
describe(suiteName, () => {
|
|
4869
|
+
let close;
|
|
4870
|
+
afterEach(async () => {
|
|
4871
|
+
await close?.();
|
|
4872
|
+
close = void 0;
|
|
4873
|
+
});
|
|
4874
|
+
const setup = async () => {
|
|
4875
|
+
const check = vi.fn(async (_user, { resource }) => resource.id === "open");
|
|
4876
|
+
const fga = {
|
|
4877
|
+
check,
|
|
4878
|
+
require: vi.fn(),
|
|
4879
|
+
filterAccessible: vi.fn()
|
|
4880
|
+
};
|
|
4881
|
+
const routeFGA = {
|
|
4882
|
+
resourceType: "doc",
|
|
4883
|
+
resourceIdParam: "docId",
|
|
4884
|
+
permission: "docs:read"
|
|
4885
|
+
};
|
|
4886
|
+
const handler = async (c) => c.json({ docId: c.req.param("docId") });
|
|
4887
|
+
const mastra = new Mastra({
|
|
4888
|
+
logger: false,
|
|
4889
|
+
server: {
|
|
4890
|
+
fga,
|
|
4891
|
+
apiRoutes: [registerApiRoute("/docs/:docId", {
|
|
4892
|
+
method: "GET",
|
|
4893
|
+
fga: routeFGA,
|
|
4894
|
+
handler
|
|
4895
|
+
}), registerApiRoute("/docs/:docId", {
|
|
4896
|
+
method: "POST",
|
|
4897
|
+
fga: routeFGA,
|
|
4898
|
+
handler
|
|
4899
|
+
})]
|
|
4900
|
+
}
|
|
4901
|
+
});
|
|
4902
|
+
const originalGetServer = mastra.getServer.bind(mastra);
|
|
4903
|
+
mastra.getServer = () => ({
|
|
4904
|
+
...originalGetServer(),
|
|
4905
|
+
auth: {
|
|
4906
|
+
authenticateToken: async (token) => token === "valid-token" ? { id: "user-1" } : null,
|
|
4907
|
+
authorize: async () => true
|
|
4908
|
+
}
|
|
4909
|
+
});
|
|
4910
|
+
const server = await startServer(mastra);
|
|
4911
|
+
close = server.close;
|
|
4912
|
+
const send = (path, init = {}) => server.request(path, {
|
|
4913
|
+
...init,
|
|
4914
|
+
headers: {
|
|
4915
|
+
authorization: "Bearer valid-token",
|
|
4916
|
+
...init.headers
|
|
4917
|
+
}
|
|
4918
|
+
});
|
|
4919
|
+
return {
|
|
4920
|
+
check,
|
|
4921
|
+
send
|
|
4922
|
+
};
|
|
4923
|
+
};
|
|
4924
|
+
const checkedIds = (check) => check.mock.calls.map(([, input]) => input.resource.id);
|
|
4925
|
+
it("allows the permitted resource and denies the forbidden one", async () => {
|
|
4926
|
+
const { send } = await setup();
|
|
4927
|
+
const allowed = await send("/docs/open");
|
|
4928
|
+
expect(allowed.status).toBe(200);
|
|
4929
|
+
expect(await allowed.json()).toEqual({ docId: "open" });
|
|
4930
|
+
expect((await send("/docs/locked")).status).toBe(403);
|
|
4931
|
+
});
|
|
4932
|
+
it("checks the path param when the query has a field with the same name", async () => {
|
|
4933
|
+
const { check, send } = await setup();
|
|
4934
|
+
const response = await send("/docs/locked?docId=open");
|
|
4935
|
+
expect(response.status).toBe(403);
|
|
4936
|
+
expect(checkedIds(check)).toEqual(["locked"]);
|
|
4937
|
+
});
|
|
4938
|
+
it("checks the path param when the JSON body has a field with the same name", async () => {
|
|
4939
|
+
const { check, send } = await setup();
|
|
4940
|
+
const response = await send("/docs/locked", {
|
|
4941
|
+
method: "POST",
|
|
4942
|
+
headers: { "content-type": "application/json" },
|
|
4943
|
+
body: JSON.stringify({ docId: "open" })
|
|
4944
|
+
});
|
|
4945
|
+
expect(response.status).toBe(403);
|
|
4946
|
+
expect(checkedIds(check)).toEqual(["locked"]);
|
|
4947
|
+
});
|
|
4948
|
+
});
|
|
4949
|
+
}
|
|
4950
|
+
//#endregion
|
|
4951
|
+
export { consumeSSEStream, createBodyLimitTestSuite, createCustomRouteFGATestSuite, createDefaultTestContext, createDurableDisconnectTestSuite, createHttpLoggingTestSuite, createMCPRouteTestSuite, createMCPTransportTestSuite, createMultipartTestSuite, createRouteAdapterTestSuite, createStreamWithSensitiveData, createStreamWithUnserializableChunk, expectSerializedStreamChunks };
|
|
3984
4952
|
|
|
3985
4953
|
//# sourceMappingURL=index.js.map
|