@dbx-tools/appkit-mastra 0.1.12 → 0.1.14

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.
Files changed (57) hide show
  1. package/README.md +303 -637
  2. package/index.ts +46 -38
  3. package/package.json +58 -43
  4. package/src/agents.ts +224 -66
  5. package/src/chart.ts +531 -429
  6. package/src/config.ts +270 -19
  7. package/src/filesystems.ts +1090 -0
  8. package/src/genie.ts +1000 -660
  9. package/src/history.ts +166 -79
  10. package/src/mcp.ts +105 -0
  11. package/src/memory.ts +119 -81
  12. package/src/mlflow.ts +149 -0
  13. package/src/model.ts +75 -408
  14. package/src/observability.ts +144 -0
  15. package/src/pagination.ts +34 -0
  16. package/src/plugin.ts +566 -59
  17. package/src/processors.ts +168 -0
  18. package/src/rest.ts +67 -0
  19. package/src/server.ts +232 -45
  20. package/src/serving-sanitize.ts +167 -0
  21. package/src/serving.ts +27 -243
  22. package/src/statement.ts +89 -0
  23. package/src/storage-schema.ts +41 -0
  24. package/src/summarize.ts +176 -0
  25. package/src/threads.ts +338 -0
  26. package/src/workspaces.ts +346 -0
  27. package/src/writer.ts +44 -0
  28. package/tsconfig.json +41 -0
  29. package/dist/index.d.ts +0 -20
  30. package/dist/index.js +0 -20
  31. package/dist/src/agents.d.ts +0 -306
  32. package/dist/src/agents.js +0 -403
  33. package/dist/src/chart.d.ts +0 -170
  34. package/dist/src/chart.js +0 -491
  35. package/dist/src/config.d.ts +0 -183
  36. package/dist/src/config.js +0 -12
  37. package/dist/src/genie.d.ts +0 -131
  38. package/dist/src/genie.js +0 -630
  39. package/dist/src/history.d.ts +0 -67
  40. package/dist/src/history.js +0 -172
  41. package/dist/src/memory.d.ts +0 -79
  42. package/dist/src/memory.js +0 -210
  43. package/dist/src/model.d.ts +0 -159
  44. package/dist/src/model.js +0 -427
  45. package/dist/src/plugin.d.ts +0 -130
  46. package/dist/src/plugin.js +0 -261
  47. package/dist/src/processors/strip-stale-charts.d.ts +0 -29
  48. package/dist/src/processors/strip-stale-charts.js +0 -96
  49. package/dist/src/server.d.ts +0 -46
  50. package/dist/src/server.js +0 -123
  51. package/dist/src/serving.d.ts +0 -156
  52. package/dist/src/serving.js +0 -231
  53. package/dist/src/tools/email.d.ts +0 -74
  54. package/dist/src/tools/email.js +0 -122
  55. package/dist/tsconfig.build.tsbuildinfo +0 -1
  56. package/src/processors/strip-stale-charts.ts +0 -105
  57. package/src/tools/email.ts +0 -147
@@ -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 CHANGED
@@ -1,27 +1,42 @@
1
1
  /**
2
2
  * Express-layer plumbing for the Mastra plugin: a `MastraServer` that
3
3
  * stamps the per-request `RequestContext`, and a route-patch middleware
4
- * that lets `@mastra/ai-sdk` `chatRoute` work behind an Express mount
5
- * point.
4
+ * that lets the plugin's custom API routes (e.g. `historyRoute`) work
5
+ * behind an Express mount point.
6
6
  */
7
7
 
8
8
  import { getExecutionContext } from "@databricks/appkit";
9
- import { httpUtils, logUtils, stringUtils } from "@dbx-tools/appkit-shared";
9
+ import { feedback, thread } from "@dbx-tools/shared-mastra";
10
10
  import {
11
11
  MASTRA_RESOURCE_ID_KEY,
12
12
  MASTRA_THREAD_ID_KEY,
13
13
  type RequestContext,
14
14
  } from "@mastra/core/request-context";
15
15
  import { MastraServer as MastraServerExpress } from "@mastra/express";
16
+ import { trace } from "@opentelemetry/api";
16
17
  import type express from "express";
17
18
  import { randomUUID } from "node:crypto";
18
19
 
19
- import { MASTRA_USER_KEY, type MastraPluginConfig, type User } from "./config.js";
20
+ import { resolveFeedbackEnabled } from "./mlflow";
21
+
20
22
  import {
21
- extractModelOverride,
22
- MASTRA_MODEL_OVERRIDE_KEY,
23
- resolveServingConfig,
24
- } from "./serving.js";
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, 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);
25
40
 
26
41
  /**
27
42
  * `@mastra/express` subclass that stamps `RequestContext` with the
@@ -29,28 +44,41 @@ import {
29
44
  * session cookie (`appkit_<plugin-name>_session_id`).
30
45
  */
31
46
  export class MastraServer extends MastraServerExpress {
32
- private log: logUtils.Logger;
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;
33
53
 
34
54
  constructor(
35
55
  private config: MastraPluginConfig,
36
56
  ...args: ConstructorParameters<typeof MastraServerExpress>
37
57
  ) {
38
58
  super(...args);
39
- this.log = logUtils.logger(config);
59
+ this.log = log.logger(config);
60
+ this.feedbackEnabled = resolveFeedbackEnabled(config.feedback);
40
61
  }
41
62
 
42
63
  override registerAuthMiddleware(): void {
43
64
  super.registerAuthMiddleware();
44
- this.app.use((req, res, next) => {
65
+ this.app.use(async (req, res, next) => {
45
66
  const requestContext = res.locals.requestContext! as RequestContext;
46
- this.configureRequestContextUser(requestContext);
67
+ await this.configureRequestContextUser(requestContext);
47
68
  this.configureRequestContextThreadId(req, res, requestContext);
48
69
  this.configureRequestContextModelOverride(req, requestContext);
70
+ this.configureRequestContextRequestId(req, res, requestContext);
71
+ this.configureRequestContextScopes(req, requestContext);
72
+
73
+ this.configureMlflowTraceId(res);
49
74
  this.log.debug("auth:middleware", {
50
75
  method: req.method,
51
76
  path: req.path,
77
+ requestId: requestContext.get(MASTRA_REQUEST_ID_KEY),
52
78
  threadId: requestContext.get(MASTRA_THREAD_ID_KEY),
53
79
  resourceId: requestContext.get(MASTRA_RESOURCE_ID_KEY),
80
+ userName: requestContext.get(MASTRA_USER_NAME_KEY),
81
+ userEmail: requestContext.get(MASTRA_USER_EMAIL_KEY),
54
82
  modelOverride: requestContext.get(
55
83
  // imported below; logged so a misrouted request shows
56
84
  // up alongside its model selection in `LOG_LEVEL=debug`.
@@ -61,31 +89,128 @@ export class MastraServer extends MastraServerExpress {
61
89
  });
62
90
  }
63
91
 
64
- configureRequestContextUser(requestContext: RequestContext) {
65
- if (
66
- [MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))
67
- )
68
- return;
92
+ async configureRequestContextUser(requestContext: RequestContext) {
93
+ if ([MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))) return;
69
94
  const executionContext = getExecutionContext();
70
95
  const user: User = {
71
- id:
72
- "userId" in executionContext
73
- ? executionContext.userId
74
- : executionContext.serviceUserId,
96
+ id: "userId" in executionContext ? executionContext.userId : executionContext.serviceUserId,
75
97
  executionContext,
76
98
  };
77
99
  requestContext.set(MASTRA_USER_KEY, user);
78
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 = new Set<string>();
154
+ for (const scope of token.getAccessTokenScopes(req)) {
155
+ scopes.add(scope);
156
+ }
157
+ for (const scope of token.getAccessTokenScopes(req, "authorization")) {
158
+ scopes.add(scope);
159
+ }
160
+ requestContext.set(MASTRA_SCOPES_KEY, [...scopes]);
161
+ }
162
+
163
+ /**
164
+ * Stamp the turn's MLflow trace id on the response so the chat client
165
+ * can attach thumbs / comment feedback to it later. MLflow derives
166
+ * its trace id from the OpenTelemetry trace id (`tr-<hex>`), and every
167
+ * Mastra span for this request inherits the ambient OTel context (see
168
+ * `observability.ts`), so the active span's trace id here is the id
169
+ * MLflow will record for the turn.
170
+ *
171
+ * No-op unless feedback is enabled, and when no live OTel span is
172
+ * active (e.g. the OTLP SDK isn't registered): in that case the header
173
+ * is simply absent and the client hides feedback for that message,
174
+ * degrading gracefully rather than emitting a bogus trace id.
175
+ */
176
+ configureMlflowTraceId(res: express.Response) {
177
+ if (!this.feedbackEnabled || res.headersSent) return;
178
+ const traceId = trace.getActiveSpan()?.spanContext().traceId;
179
+ if (!traceId || traceId === INVALID_TRACE_ID) return;
180
+ res.setHeader(feedback.MLFLOW_TRACE_ID_HEADER, `tr-${traceId}`);
79
181
  }
80
182
 
183
+ /**
184
+ * Resolve the thread id this request targets and pin it on
185
+ * `RequestContext` (consumed by the agent stream for persistence and
186
+ * by the history / threads routes). Resolution order:
187
+ *
188
+ * 1. A client-supplied thread id (the thread-selection header /
189
+ * `?threadId=` query). This is how the chat UI references a
190
+ * specific conversation among the many a user owns - it picks a
191
+ * thread id from the `/threads` listing (or mints one for a new
192
+ * conversation) and stamps it here. The id is scoped to the
193
+ * caller's resource by the recall / list routes, so a client
194
+ * can only ever read or write its own threads.
195
+ * 2. The per-session cookie (`appkit_<plugin-name>_session_id`),
196
+ * minted on first contact. This is the default single-thread
197
+ * fallback for clients that don't manage threads explicitly, so
198
+ * existing embeds keep one stable conversation per session with
199
+ * no client changes.
200
+ */
81
201
  configureRequestContextThreadId(
82
202
  req: express.Request,
83
203
  res: express.Response,
84
204
  requestContext: RequestContext,
85
205
  ) {
86
206
  if (requestContext.get(MASTRA_THREAD_ID_KEY)) return;
87
- const cookies = httpUtils.parseCookies(req.headers.cookie);
88
- const cookieName = stringUtils.toIdentifierWithOptions(
207
+ const requested = this.readRequestedThreadId(req);
208
+ if (requested) {
209
+ requestContext.set(MASTRA_THREAD_ID_KEY, requested);
210
+ return;
211
+ }
212
+ const cookies = http.parseCookies(req.headers.cookie);
213
+ const cookieName = string.toIdentifierWithOptions(
89
214
  { delimiter: "_", distinct: true },
90
215
  "appkit",
91
216
  this.config.name!,
@@ -104,10 +229,22 @@ export class MastraServer extends MastraServerExpress {
104
229
  requestContext.set(MASTRA_THREAD_ID_KEY, sessionId);
105
230
  }
106
231
 
107
- configureRequestContextModelOverride(
108
- req: express.Request,
109
- requestContext: RequestContext,
110
- ) {
232
+ /**
233
+ * Read the client-selected thread id from the request, preferring
234
+ * the thread-selection header over the `?threadId=` query. Returns
235
+ * `null` when neither carries a non-empty value so the caller falls
236
+ * back to the session cookie.
237
+ */
238
+ private readRequestedThreadId(req: express.Request): string | null {
239
+ const headerValue = req.headers[thread.THREAD_ID_HEADER];
240
+ const queryValue = req.query[thread.THREAD_ID_QUERY];
241
+ return (
242
+ string.trimToNull(Array.isArray(headerValue) ? headerValue[0] : headerValue) ??
243
+ string.trimToNull(Array.isArray(queryValue) ? queryValue[0] : queryValue)
244
+ );
245
+ }
246
+
247
+ configureRequestContextModelOverride(req: express.Request, requestContext: RequestContext) {
111
248
  // Per-request model override: only honored when the plugin
112
249
  // opts in (default). Sources, in priority order, are
113
250
  // `X-Mastra-Model` header, `?model=` query, and `model` /
@@ -124,31 +261,81 @@ export class MastraServer extends MastraServerExpress {
124
261
  }
125
262
  }
126
263
 
264
+ /** Inputs for {@link isMastraRequestAllowed}. */
265
+ export interface MastraApiGateOptions {
266
+ /** `config.apiAccess`; `"full"` short-circuits to allow everything. */
267
+ access: "scoped" | "full";
268
+ /** Whether the MCP transport is mounted (so `/mcp/*` is legitimate). */
269
+ mcpEnabled: boolean;
270
+ }
271
+
127
272
  /**
128
- * Patches around `@mastra/express`'s custom-route dispatcher so
129
- * `chatRoute` works when `MastraServer` is hosted on an Express subapp
130
- * mounted under a parent path (e.g. `/api/mastra`).
273
+ * Mount-relative agent inference paths (`/agents/:id/<verb>...`). Matched
274
+ * by prefix on the verb so future variants (`streamVNext`, `stream/vnext`,
275
+ * `generateVNext`) stay covered without a code change.
131
276
  *
132
- * Two concerns:
277
+ * Covers the plain inference verbs (`stream` / `generate` / `network`) plus
278
+ * the human-in-the-loop resume verbs a paused `requireApproval` tool needs
279
+ * to continue: `approve-tool-call` / `decline-tool-call` (streaming, and via
280
+ * prefix their `-generate` non-streaming variants), the `-network-` variants,
281
+ * and `resume-stream` (covers `resume-stream-until-idle`). Without these an
282
+ * approval-gated tool (e.g. `send_email`) can be requested but never approved
283
+ * from the browser - the resume `POST` 403s under scoped mode. These are the
284
+ * only *writes* the browser client is allowed to make against stock Mastra.
285
+ */
286
+ const AGENT_INFERENCE =
287
+ /^\/agents\/[^/]+\/(stream|generate|network|resume-stream|approve-tool-call|decline-tool-call|approve-network-tool-call|decline-network-tool-call)/i;
288
+
289
+ /** Mount-relative read-only agent metadata (`/agents`, `/agents/:id`). */
290
+ const AGENT_METADATA = /^\/agents(\/[^/]+)?$/;
291
+
292
+ /**
293
+ * Whether a request to the stock `@mastra/express` sub-app should be
294
+ * dispatched, given the configured {@link MastraApiGateOptions.access}.
133
295
  *
134
- * 1. The adapter's `registerCustomApiRoutes` matches against `req.path`
135
- * (mount-relative, correct) but dispatches to its internal Hono
136
- * mini-app using `req.originalUrl`, which still contains the parent
137
- * mount prefix. The Hono app registers the literal `chatRoute` paths
138
- * (for example `/route/chat`), so the absolute URL never matches
139
- * until we overwrite `originalUrl` for `/route` and `/route/*` to
140
- * the mount-relative path.
296
+ * `path` is mount-relative (what the plugin's catch-all sees, e.g.
297
+ * `/agents/x/stream`, `/route/history/x`, `/mcp/...`). In `"scoped"`
298
+ * mode the allowlist is deliberately tight - the chat client only ever
299
+ * needs agent inference, read-only agent metadata, this plugin's own
300
+ * OBO/resource-scoped `/route/*` routes, and (when enabled) MCP - so the
301
+ * whole admin / mutating / bulk-export surface Mastra also exposes is
302
+ * denied by default rather than enumerated.
303
+ */
304
+ export function isMastraRequestAllowed(
305
+ method: string,
306
+ path: string,
307
+ opts: MastraApiGateOptions,
308
+ ): boolean {
309
+ if (opts.access === "full") return true;
310
+ const p = path.startsWith("/") ? path : `/${path}`;
311
+ // This plugin's own custom routes are individually OBO- and
312
+ // resource-scoped (see history.ts / threads.ts), so every method is safe.
313
+ if (p === "/route" || p.startsWith("/route/")) return true;
314
+ if (opts.mcpEnabled && (p === "/mcp" || p.startsWith("/mcp/"))) return true;
315
+ const m = method.toUpperCase();
316
+ if (m === "POST" && AGENT_INFERENCE.test(p)) return true;
317
+ if (m === "GET" && AGENT_METADATA.test(p)) return true;
318
+ return false;
319
+ }
320
+
321
+ /**
322
+ * Patches around `@mastra/express`'s custom-route dispatcher so the
323
+ * plugin's custom API routes (e.g. `historyRoute`) work when
324
+ * `MastraServer` is hosted on an Express subapp mounted under a parent
325
+ * path (e.g. `/api/mastra`).
141
326
  *
142
- * 2. `memory.resource` must be the authenticated user, not whatever the
143
- * client posts. The custom-route forwarder re-serializes `req.body`
144
- * into the Request body it hands Hono, so mutating the parsed body
145
- * here would propagate into `handleChatStream`'s params (kept for
146
- * future use; `express.json()` runs first so `req.body` is parsed).
327
+ * The adapter's `registerCustomApiRoutes` matches against `req.path`
328
+ * (mount-relative, correct) but dispatches to its internal Hono
329
+ * mini-app using `req.originalUrl`, which still contains the parent
330
+ * mount prefix. The Hono app registers the literal route paths
331
+ * (for example `/route/history`), so the absolute URL never matches
332
+ * until we overwrite `originalUrl` for `/route` and `/route/*` to the
333
+ * mount-relative path.
147
334
  */
148
335
  export function attachRoutePatchMiddleware(app: express.Express): void {
149
336
  app.use((req, _res, next) => {
150
- const isChat = req.path === "/route" || req.path.startsWith("/route/");
151
- if (!isChat) return next();
337
+ const isCustomRoute = req.path === "/route" || req.path.startsWith("/route/");
338
+ if (!isCustomRoute) return next();
152
339
  req.originalUrl = req.path;
153
340
  next();
154
341
  });