@dbx-tools/appkit-mastra 0.1.111 → 0.3.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.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Mastra stream processors wired onto the plugin's agents.
3
+ *
4
+ * Two complementary processors live here:
5
+ *
6
+ * - An **input** processor ({@link stripStaleChartsProcessor}) that
7
+ * scrubs turn-scoped `chartId` fields out of prior tool results
8
+ * replayed from Memory, so the model can't copy a stale id into the
9
+ * new turn's `[chart:<id>]` markers.
10
+ * - An **output** processor ({@link ResultProcessor}) that trims the
11
+ * bulky, redundant payload fields off targeted outbound stream frames
12
+ * before they're serialized to the SSE client.
13
+ */
14
+
15
+ import { log } from "@dbx-tools/shared-core";
16
+ import type { InputProcessor, ProcessInputArgs, Processor } from "@mastra/core/processors";
17
+
18
+ const logger = log.logger("mastra/processors");
19
+
20
+ /**
21
+ * Recursively clone `value`, omitting any property whose key is
22
+ * `chartId`. Arrays are mapped element-wise; primitives are
23
+ * returned as-is. The result is structurally identical to the
24
+ * input minus chartIds, so downstream message-shape consumers
25
+ * keep working.
26
+ */
27
+ function stripChartIds(value: unknown): unknown {
28
+ if (Array.isArray(value)) {
29
+ return value.map(stripChartIds);
30
+ }
31
+ if (value && typeof value === "object") {
32
+ const obj = value as Record<string, unknown>;
33
+ const out: Record<string, unknown> = {};
34
+ for (const [key, val] of Object.entries(obj)) {
35
+ if (key === "chartId") continue;
36
+ out[key] = stripChartIds(val);
37
+ }
38
+ return out;
39
+ }
40
+ return value;
41
+ }
42
+
43
+ /**
44
+ * Mastra input processor that strips `chartId` fields from every
45
+ * tool-invocation result in prior assistant messages before they
46
+ * reach the model.
47
+ *
48
+ * Why: chartIds are turn-scoped from the model's point of view -
49
+ * each `prepare_chart` / `render_data` call mints a fresh id and
50
+ * the host UI binds it to that turn's reply. Mastra Memory
51
+ * replays prior tool results into the prompt; if old chartIds
52
+ * leak through, the model is tempted to copy them verbatim into
53
+ * the new turn's `[chart:<id>]` markers and the host UI ends up
54
+ * rendering an unrelated chart from the chart cache (or
55
+ * a 404 once the 1h TTL elapsed). This processor removes the
56
+ * temptation by deleting `chartId` keys from every assistant
57
+ * message's tool results before the prompt is built. The current
58
+ * turn's tool results don't exist yet at `processInput` time, so
59
+ * they pass through unmodified.
60
+ *
61
+ * The strip is recursive - any nested `chartId` field is removed,
62
+ * regardless of which tool produced the result. This covers
63
+ * `prepare_chart` / `render_data` top-level chartIds and any
64
+ * legacy `datasets[].chartId` payloads uniformly without coupling
65
+ * to specific tool ids.
66
+ *
67
+ * Wired onto every agent by default via `buildAgents`; opt out with
68
+ * `MastraPluginConfig.stripStaleCharts: false`.
69
+ */
70
+ export const stripStaleChartsProcessor: InputProcessor = {
71
+ id: "strip-stale-charts",
72
+ description:
73
+ "Removes chartId fields from prior tool-invocation results so the model can't reuse turn-scoped ids from memory.",
74
+ processInput(args: ProcessInputArgs) {
75
+ let stripped = 0;
76
+ for (const message of args.messages) {
77
+ if (message.role !== "assistant") continue;
78
+ const parts = message.content?.parts;
79
+ if (!Array.isArray(parts)) continue;
80
+ for (const part of parts) {
81
+ // Tool-invocation parts hold the persisted tool result.
82
+ // We don't scrub the input args (`rawInput` / `args`) because
83
+ // the chartId there is the model's outgoing claim, not
84
+ // anything it could re-reference; only `result` carries
85
+ // ids that subsequent turns might copy.
86
+ if ((part as { type?: unknown }).type !== "tool-invocation") {
87
+ continue;
88
+ }
89
+ const inv = (part as { toolInvocation?: { result?: unknown } }).toolInvocation;
90
+ if (!inv || inv.result === undefined) continue;
91
+ const before = inv.result;
92
+ const after = stripChartIds(before);
93
+ // Cheap structural check via JSON length - the actual
94
+ // strip writes a fresh object only when chartId keys
95
+ // existed, so different stringification length is a
96
+ // reliable signal that something was removed.
97
+ if (
98
+ typeof before === "object" &&
99
+ before !== null &&
100
+ JSON.stringify(before).length !== JSON.stringify(after).length
101
+ ) {
102
+ inv.result = after;
103
+ stripped += 1;
104
+ }
105
+ }
106
+ }
107
+ if (stripped > 0) {
108
+ logger.debug("stripped", { results: stripped });
109
+ }
110
+ return args.messages;
111
+ },
112
+ };
113
+
114
+ /**
115
+ * Mastra output processor that trims the bulky, redundant payload
116
+ * fields off targeted outbound stream frames.
117
+ *
118
+ * Mastra emits step / finish / tool-result frames whose payloads
119
+ * echo the full tool `output` / `result` / assistant `messages` /
120
+ * `response` - data the chat client already received inline and
121
+ * doesn't need re-sent. This processor empties those keys on the
122
+ * targeted frame types so the outbound SSE stream stays lean;
123
+ * every other frame passes through untouched.
124
+ */
125
+ export class ResultProcessor implements Processor {
126
+ id = "result-processor";
127
+
128
+ // Tell Mastra to also route tool/data parts to this processor method
129
+ processDataParts = true;
130
+
131
+ async processOutputStream({ part }: { part: any }): Promise<any | null> {
132
+ // 1. Guard clause: Ensure the chunk is a valid object
133
+ if (!part || typeof part !== "object") {
134
+ return part;
135
+ }
136
+
137
+ // 2. Filter for the targeted frame types
138
+ const targetedTypes = ["step-finish", "finish", "tool-result", "data-tool-agent"];
139
+ if (!targetedTypes.includes(part.type)) {
140
+ return part; // Return unchanged to pass-through
141
+ }
142
+
143
+ // 3. Check for the presence of a payload object
144
+ const payload = part.payload;
145
+ if (!payload || typeof payload !== "object") {
146
+ return part;
147
+ }
148
+
149
+ // 4. Safely delete the unwanted keys from the payload reference
150
+ const keysToDelete = ["output", "messages", "response", "result"];
151
+ for (const key of keysToDelete) {
152
+ if (key in payload) {
153
+ const value = payload[key];
154
+ if (typeof value === "object") {
155
+ payload[key] = {};
156
+ } else if (Array.isArray(value)) {
157
+ payload[key] = [];
158
+ } else {
159
+ delete payload[key];
160
+ }
161
+ }
162
+ }
163
+
164
+ // 5. Return the modified part object. Mastra handles re-serialization
165
+ // for the outbound SSE client stream automatically.
166
+ return part;
167
+ }
168
+ }
package/src/rest.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { appkit } from "@dbx-tools/appkit";
2
+ /**
3
+ * Minimal authenticated Databricks REST helper. Pulls the workspace
4
+ * host and a fresh bearer header off an OBO-scoped `WorkspaceClient`
5
+ * (`client.config.getHost()` + `authenticate()`), then issues a plain
6
+ * `fetch`. Used by modules that hit REST surfaces without a typed SDK
7
+ * method (e.g. the MLflow assessments API); returns the raw `Response`
8
+ * so callers decide how to treat status codes. Also carries the
9
+ * defensive `Response` body readers those callers share.
10
+ */
11
+
12
+ /** Workspace client carried on an AppKit execution context. */
13
+ type WorkspaceClient = appkit.WorkspaceClientLike;
14
+
15
+ /** Request options for {@link databricksFetch}. */
16
+ export interface DatabricksFetchInit {
17
+ method: string;
18
+ /** JSON-serialized as the request body when present. */
19
+ body?: unknown;
20
+ /** Extra headers merged over the default `Content-Type: application/json`. */
21
+ headers?: Record<string, string>;
22
+ signal?: AbortSignal;
23
+ }
24
+
25
+ /**
26
+ * Resolve the workspace host + an authenticated header set off the
27
+ * client and issue a `fetch` against `path` (mounted on the host).
28
+ * Runs as whatever identity the client carries - the per-request OBO
29
+ * user when called from a request scope, the service principal
30
+ * otherwise.
31
+ */
32
+ export async function databricksFetch(
33
+ client: WorkspaceClient,
34
+ path: string,
35
+ init: DatabricksFetchInit,
36
+ ): Promise<Response> {
37
+ const host = (await client.config.getHost()).toString();
38
+ const headers = new Headers({ "Content-Type": "application/json", ...init.headers });
39
+ await client.config.authenticate(headers);
40
+ const url = new URL(path, host).toString();
41
+ return fetch(url, {
42
+ method: init.method,
43
+ headers,
44
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
45
+ ...(init.signal ? { signal: init.signal } : {}),
46
+ });
47
+ }
48
+
49
+ /** Read a response body as text, swallowing read errors (returns `""`). */
50
+ export async function readResponseText(res: Response): Promise<string> {
51
+ try {
52
+ return await res.text();
53
+ } catch {
54
+ return "";
55
+ }
56
+ }
57
+
58
+ /** Parse a response body as JSON, returning `{}` on empty / invalid bodies. */
59
+ export async function readResponseJson(res: Response): Promise<unknown> {
60
+ const text = await readResponseText(res);
61
+ if (!text) return {};
62
+ try {
63
+ return JSON.parse(text);
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
package/src/server.ts ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Express-layer plumbing for the Mastra plugin: a `MastraServer` that
3
+ * stamps the per-request `RequestContext`, and a route-patch middleware
4
+ * that lets the plugin's custom API routes (e.g. `historyRoute`) work
5
+ * behind an Express mount point.
6
+ */
7
+
8
+ import { getExecutionContext } from "@databricks/appkit";
9
+ import { feedback, thread } from "@dbx-tools/shared-mastra";
10
+ import {
11
+ MASTRA_RESOURCE_ID_KEY,
12
+ MASTRA_THREAD_ID_KEY,
13
+ type RequestContext,
14
+ } from "@mastra/core/request-context";
15
+ import { MastraServer as MastraServerExpress } from "@mastra/express";
16
+ import { trace } from "@opentelemetry/api";
17
+ import type express from "express";
18
+ import { randomUUID } from "node:crypto";
19
+
20
+ import { resolveFeedbackEnabled } from "./mlflow";
21
+
22
+ import {
23
+ MASTRA_REQUEST_ID_KEY,
24
+ MASTRA_SCOPES_KEY,
25
+ MASTRA_USER_EMAIL_KEY,
26
+ MASTRA_USER_KEY,
27
+ MASTRA_USER_NAME_KEY,
28
+ type MastraPluginConfig,
29
+ type User,
30
+ } from "./config";
31
+ import { http, log, object, string, token } from "@dbx-tools/shared-core";
32
+ import { extractModelOverride, MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig } from "./serving";
33
+ /**
34
+ * OpenTelemetry's sentinel for "no valid trace" - 32 zero hex chars.
35
+ * `trace.getActiveSpan()` returns a non-recording span with this id
36
+ * when no SDK is registered, which must never be surfaced as a trace to
37
+ * attach feedback to.
38
+ */
39
+ const INVALID_TRACE_ID = "0".repeat(32);
40
+
41
+ /**
42
+ * `@mastra/express` subclass that stamps `RequestContext` with the
43
+ * AppKit user, resource id, and a thread id backed by an HTTP-only
44
+ * session cookie (`appkit_<plugin-name>_session_id`).
45
+ */
46
+ export class MastraServer extends MastraServerExpress {
47
+ private log: log.Logger;
48
+ /**
49
+ * Whether to stamp the MLflow trace-id header on responses. Shares the
50
+ * plugin's feedback gate via {@link resolveFeedbackEnabled}.
51
+ */
52
+ private feedbackEnabled: boolean;
53
+
54
+ constructor(
55
+ private config: MastraPluginConfig,
56
+ ...args: ConstructorParameters<typeof MastraServerExpress>
57
+ ) {
58
+ super(...args);
59
+ this.log = log.logger(config);
60
+ this.feedbackEnabled = resolveFeedbackEnabled(config.feedback);
61
+ }
62
+
63
+ override registerAuthMiddleware(): void {
64
+ super.registerAuthMiddleware();
65
+ this.app.use(async (req, res, next) => {
66
+ const requestContext = res.locals.requestContext! as RequestContext;
67
+ await this.configureRequestContextUser(requestContext);
68
+ this.configureRequestContextThreadId(req, res, requestContext);
69
+ this.configureRequestContextModelOverride(req, requestContext);
70
+ this.configureRequestContextRequestId(req, res, requestContext);
71
+ this.configureRequestContextScopes(req, requestContext);
72
+
73
+ this.configureMlflowTraceId(res);
74
+ this.log.debug("auth:middleware", {
75
+ method: req.method,
76
+ path: req.path,
77
+ requestId: requestContext.get(MASTRA_REQUEST_ID_KEY),
78
+ threadId: requestContext.get(MASTRA_THREAD_ID_KEY),
79
+ resourceId: requestContext.get(MASTRA_RESOURCE_ID_KEY),
80
+ userName: requestContext.get(MASTRA_USER_NAME_KEY),
81
+ userEmail: requestContext.get(MASTRA_USER_EMAIL_KEY),
82
+ modelOverride: requestContext.get(
83
+ // imported below; logged so a misrouted request shows
84
+ // up alongside its model selection in `LOG_LEVEL=debug`.
85
+ "mastra__model_override",
86
+ ),
87
+ });
88
+ next();
89
+ });
90
+ }
91
+
92
+ async configureRequestContextUser(requestContext: RequestContext) {
93
+ if ([MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))) return;
94
+ const executionContext = getExecutionContext();
95
+ const user: User = {
96
+ id: "userId" in executionContext ? executionContext.userId : executionContext.serviceUserId,
97
+ executionContext,
98
+ };
99
+ requestContext.set(MASTRA_USER_KEY, user);
100
+ requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
101
+ // AppKit's `UserContext` surfaces display name / email only on
102
+ // OBO requests. Service-context calls (background tasks, server
103
+ // start-up) leave these undefined and we skip the stamp so
104
+ // downstream trace metadata stays absent rather than empty.
105
+ let userName: string | undefined;
106
+ let email: string | undefined;
107
+ if ("isUserContext" in executionContext) {
108
+ userName = executionContext.userName;
109
+ email = executionContext.userEmail;
110
+ } else if (process.env.NODE_ENV === "development") {
111
+ const currentUser = await executionContext.client.currentUser.me();
112
+ userName = currentUser?.userName;
113
+ email = currentUser?.emails?.filter((email) => email.primary).find((email) => email.value)
114
+ ?.value as string;
115
+ }
116
+ if (userName) {
117
+ requestContext.set(MASTRA_USER_NAME_KEY, userName);
118
+ }
119
+ if (email) {
120
+ requestContext.set(MASTRA_USER_EMAIL_KEY, email);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Stamp a per-request id and echo it on the response so an upstream
126
+ * proxy / curl client / browser-side log line can pair its view of
127
+ * the request with the matching trace span. Reuses `X-Request-Id`
128
+ * when the upstream already supplies one so multi-hop traces stay
129
+ * joined; otherwise mints a UUIDv4.
130
+ *
131
+ * The id is surfaced as `mastra__requestId` span metadata via
132
+ * {@link TRACE_REQUEST_CONTEXT_KEYS} and as the `X-Request-Id`
133
+ * response header so dev tools can copy it from either side.
134
+ */
135
+ configureRequestContextRequestId(
136
+ req: express.Request,
137
+ res: express.Response,
138
+ requestContext: RequestContext,
139
+ ) {
140
+ if (requestContext.get(MASTRA_REQUEST_ID_KEY)) return;
141
+ const headerValue = req.headers["x-request-id"];
142
+ const upstream = Array.isArray(headerValue) ? headerValue[0] : headerValue;
143
+ const requestId = upstream?.trim() || randomUUID();
144
+ requestContext.set(MASTRA_REQUEST_ID_KEY, requestId);
145
+ res.setHeader("X-Request-Id", requestId);
146
+ }
147
+
148
+ /**
149
+ * Stamp OAuth scopes from the forwarded access token on
150
+ * {@link MASTRA_SCOPES_KEY} for workspace mount gating.
151
+ */
152
+ configureRequestContextScopes(req: express.Request, requestContext: RequestContext) {
153
+ const scopes = [
154
+ ...object
155
+ .sequence(
156
+ token.getAccessTokenScopes(req),
157
+ token.getAccessTokenScopes(req, "authorization"),
158
+ )
159
+ .distinct(),
160
+ ];
161
+ requestContext.set(MASTRA_SCOPES_KEY, scopes);
162
+ }
163
+
164
+ /**
165
+ * Stamp the turn's MLflow trace id on the response so the chat client
166
+ * can attach thumbs / comment feedback to it later. MLflow derives
167
+ * its trace id from the OpenTelemetry trace id (`tr-<hex>`), and every
168
+ * Mastra span for this request inherits the ambient OTel context (see
169
+ * `observability.ts`), so the active span's trace id here is the id
170
+ * MLflow will record for the turn.
171
+ *
172
+ * No-op unless feedback is enabled, and when no live OTel span is
173
+ * active (e.g. the OTLP SDK isn't registered): in that case the header
174
+ * is simply absent and the client hides feedback for that message,
175
+ * degrading gracefully rather than emitting a bogus trace id.
176
+ */
177
+ configureMlflowTraceId(res: express.Response) {
178
+ if (!this.feedbackEnabled || res.headersSent) return;
179
+ const traceId = trace.getActiveSpan()?.spanContext().traceId;
180
+ if (!traceId || traceId === INVALID_TRACE_ID) return;
181
+ res.setHeader(feedback.MLFLOW_TRACE_ID_HEADER, `tr-${traceId}`);
182
+ }
183
+
184
+ /**
185
+ * Resolve the thread id this request targets and pin it on
186
+ * `RequestContext` (consumed by the agent stream for persistence and
187
+ * by the history / threads routes). Resolution order:
188
+ *
189
+ * 1. A client-supplied thread id (the thread-selection header /
190
+ * `?threadId=` query). This is how the chat UI references a
191
+ * specific conversation among the many a user owns - it picks a
192
+ * thread id from the `/threads` listing (or mints one for a new
193
+ * conversation) and stamps it here. The id is scoped to the
194
+ * caller's resource by the recall / list routes, so a client
195
+ * can only ever read or write its own threads.
196
+ * 2. The per-session cookie (`appkit_<plugin-name>_session_id`),
197
+ * minted on first contact. This is the default single-thread
198
+ * fallback for clients that don't manage threads explicitly, so
199
+ * existing embeds keep one stable conversation per session with
200
+ * no client changes.
201
+ */
202
+ configureRequestContextThreadId(
203
+ req: express.Request,
204
+ res: express.Response,
205
+ requestContext: RequestContext,
206
+ ) {
207
+ if (requestContext.get(MASTRA_THREAD_ID_KEY)) return;
208
+ const requested = this.readRequestedThreadId(req);
209
+ if (requested) {
210
+ requestContext.set(MASTRA_THREAD_ID_KEY, requested);
211
+ return;
212
+ }
213
+ const cookies = http.parseCookies(req.headers.cookie);
214
+ const cookieName = string.toIdentifierWithOptions(
215
+ { delimiter: "_", distinct: true },
216
+ "appkit",
217
+ this.config.name!,
218
+ "sessionId",
219
+ );
220
+ let sessionId = cookies[cookieName];
221
+ if (!sessionId) {
222
+ sessionId = randomUUID();
223
+ res.cookie(cookieName, sessionId, {
224
+ httpOnly: true,
225
+ sameSite: "lax",
226
+ secure: req.secure,
227
+ path: "/",
228
+ });
229
+ }
230
+ requestContext.set(MASTRA_THREAD_ID_KEY, sessionId);
231
+ }
232
+
233
+ /**
234
+ * Read the client-selected thread id from the request, preferring
235
+ * the thread-selection header over the `?threadId=` query. Returns
236
+ * `null` when neither carries a non-empty value so the caller falls
237
+ * back to the session cookie.
238
+ */
239
+ private readRequestedThreadId(req: express.Request): string | null {
240
+ const headerValue = req.headers[thread.THREAD_ID_HEADER];
241
+ const queryValue = req.query[thread.THREAD_ID_QUERY];
242
+ return (
243
+ string.trimToNull(Array.isArray(headerValue) ? headerValue[0] : headerValue) ??
244
+ string.trimToNull(Array.isArray(queryValue) ? queryValue[0] : queryValue)
245
+ );
246
+ }
247
+
248
+ configureRequestContextModelOverride(req: express.Request, requestContext: RequestContext) {
249
+ // Per-request model override: only honored when the plugin
250
+ // opts in (default). Sources, in priority order, are
251
+ // `X-Mastra-Model` header, `?model=` query, and `model` /
252
+ // `modelId` body field; see `serving.ts`.
253
+ const serving = resolveServingConfig(this.config);
254
+ if (serving.allowOverride) {
255
+ const override = extractModelOverride({
256
+ headers: req.headers as Record<string, string | string[] | undefined>,
257
+ query: req.query as Record<string, unknown>,
258
+ body: req.body,
259
+ });
260
+ if (override) requestContext.set(MASTRA_MODEL_OVERRIDE_KEY, override);
261
+ }
262
+ }
263
+ }
264
+
265
+ /** Inputs for {@link isMastraRequestAllowed}. */
266
+ export interface MastraApiGateOptions {
267
+ /** `config.apiAccess`; `"full"` short-circuits to allow everything. */
268
+ access: "scoped" | "full";
269
+ /** Whether the MCP transport is mounted (so `/mcp/*` is legitimate). */
270
+ mcpEnabled: boolean;
271
+ }
272
+
273
+ /**
274
+ * Mount-relative agent inference paths (`/agents/:id/<verb>...`). Matched
275
+ * by prefix on the verb so future variants (`streamVNext`, `stream/vnext`,
276
+ * `generateVNext`) stay covered without a code change.
277
+ *
278
+ * Covers the plain inference verbs (`stream` / `generate` / `network`) plus
279
+ * the human-in-the-loop resume verbs a paused `requireApproval` tool needs
280
+ * to continue: `approve-tool-call` / `decline-tool-call` (streaming, and via
281
+ * prefix their `-generate` non-streaming variants), the `-network-` variants,
282
+ * and `resume-stream` (covers `resume-stream-until-idle`). Without these an
283
+ * approval-gated tool (e.g. `send_email`) can be requested but never approved
284
+ * from the browser - the resume `POST` 403s under scoped mode. These are the
285
+ * only *writes* the browser client is allowed to make against stock Mastra.
286
+ */
287
+ const AGENT_INFERENCE =
288
+ /^\/agents\/[^/]+\/(stream|generate|network|resume-stream|approve-tool-call|decline-tool-call|approve-network-tool-call|decline-network-tool-call)/i;
289
+
290
+ /** Mount-relative read-only agent metadata (`/agents`, `/agents/:id`). */
291
+ const AGENT_METADATA = /^\/agents(\/[^/]+)?$/;
292
+
293
+ /**
294
+ * Whether a request to the stock `@mastra/express` sub-app should be
295
+ * dispatched, given the configured {@link MastraApiGateOptions.access}.
296
+ *
297
+ * `path` is mount-relative (what the plugin's catch-all sees, e.g.
298
+ * `/agents/x/stream`, `/route/history/x`, `/mcp/...`). In `"scoped"`
299
+ * mode the allowlist is deliberately tight - the chat client only ever
300
+ * needs agent inference, read-only agent metadata, this plugin's own
301
+ * OBO/resource-scoped `/route/*` routes, and (when enabled) MCP - so the
302
+ * whole admin / mutating / bulk-export surface Mastra also exposes is
303
+ * denied by default rather than enumerated.
304
+ */
305
+ export function isMastraRequestAllowed(
306
+ method: string,
307
+ path: string,
308
+ opts: MastraApiGateOptions,
309
+ ): boolean {
310
+ if (opts.access === "full") return true;
311
+ const p = path.startsWith("/") ? path : `/${path}`;
312
+ // This plugin's own custom routes are individually OBO- and
313
+ // resource-scoped (see history.ts / threads.ts), so every method is safe.
314
+ if (p === "/route" || p.startsWith("/route/")) return true;
315
+ if (opts.mcpEnabled && (p === "/mcp" || p.startsWith("/mcp/"))) return true;
316
+ const m = method.toUpperCase();
317
+ if (m === "POST" && AGENT_INFERENCE.test(p)) return true;
318
+ if (m === "GET" && AGENT_METADATA.test(p)) return true;
319
+ return false;
320
+ }
321
+
322
+ /**
323
+ * Patches around `@mastra/express`'s custom-route dispatcher so the
324
+ * plugin's custom API routes (e.g. `historyRoute`) work when
325
+ * `MastraServer` is hosted on an Express subapp mounted under a parent
326
+ * path (e.g. `/api/mastra`).
327
+ *
328
+ * The adapter's `registerCustomApiRoutes` matches against `req.path`
329
+ * (mount-relative, correct) but dispatches to its internal Hono
330
+ * mini-app using `req.originalUrl`, which still contains the parent
331
+ * mount prefix. The Hono app registers the literal route paths
332
+ * (for example `/route/history`), so the absolute URL never matches
333
+ * until we overwrite `originalUrl` for `/route` and `/route/*` to the
334
+ * mount-relative path.
335
+ */
336
+ export function attachRoutePatchMiddleware(app: express.Express): void {
337
+ app.use((req, _res, next) => {
338
+ const isCustomRoute = req.path === "/route" || req.path.startsWith("/route/");
339
+ if (!isCustomRoute) return next();
340
+ req.originalUrl = req.path;
341
+ next();
342
+ });
343
+ }