@dbx-tools/appkit-mastra 0.1.5 → 0.1.10

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 (53) hide show
  1. package/README.md +394 -0
  2. package/index.ts +46 -37
  3. package/package.json +58 -47
  4. package/src/agents.ts +233 -62
  5. package/src/chart.ts +555 -285
  6. package/src/config.ts +282 -18
  7. package/src/filesystems.ts +1090 -0
  8. package/src/genie.ts +1004 -601
  9. package/src/history.ts +178 -79
  10. package/src/mcp.ts +105 -0
  11. package/src/memory.ts +129 -74
  12. package/src/mlflow.ts +149 -0
  13. package/src/model.ts +80 -408
  14. package/src/observability.ts +144 -0
  15. package/src/pagination.ts +34 -0
  16. package/src/plugin.ts +573 -59
  17. package/src/processors.ts +168 -0
  18. package/src/rest.ts +67 -0
  19. package/src/server.ts +243 -45
  20. package/src/serving-sanitize.ts +167 -0
  21. package/src/serving.ts +27 -224
  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 -19
  30. package/dist/index.js +0 -19
  31. package/dist/src/agents.d.ts +0 -306
  32. package/dist/src/agents.js +0 -393
  33. package/dist/src/chart.d.ts +0 -104
  34. package/dist/src/chart.js +0 -375
  35. package/dist/src/config.d.ts +0 -170
  36. package/dist/src/config.js +0 -12
  37. package/dist/src/genie.d.ts +0 -116
  38. package/dist/src/genie.js +0 -594
  39. package/dist/src/history.d.ts +0 -67
  40. package/dist/src/history.js +0 -158
  41. package/dist/src/memory.d.ts +0 -79
  42. package/dist/src/memory.js +0 -197
  43. package/dist/src/model.d.ts +0 -159
  44. package/dist/src/model.js +0 -423
  45. package/dist/src/plugin.d.ts +0 -130
  46. package/dist/src/plugin.js +0 -255
  47. package/dist/src/render-chart-route.d.ts +0 -33
  48. package/dist/src/render-chart-route.js +0 -120
  49. package/dist/src/server.d.ts +0 -46
  50. package/dist/src/server.js +0 -113
  51. package/dist/src/serving.d.ts +0 -156
  52. package/dist/src/serving.js +0 -214
  53. package/src/render-chart-route.ts +0 -141
package/src/plugin.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * AppKit plugin that builds one or more Mastra `Agent` instances and
3
- * mounts the `@mastra/express` server plus `@mastra/ai-sdk` `chatRoute`
4
- * handlers. The UI message stream matches what `chatRoute()` emits, so
5
- * the client can use `useChat()` from `@ai-sdk/react` without custom
6
- * parsing.
3
+ * mounts the `@mastra/express` server. Clients drive the conversation
4
+ * over the standard Mastra agent stream (`@mastra/client-js`'s
5
+ * `getAgent(id).stream()`), so there's no bespoke chat transport to
6
+ * keep in sync.
7
7
  *
8
8
  * - Agents: registered through `config.agents` at plugin creation
9
9
  * ({@link MastraAgentDefinition}). Each entry's `tools` field accepts
@@ -18,13 +18,25 @@
18
18
  * from `./memory.js`. Both auto-default to `true` when the
19
19
  * `lakebase` plugin is registered (unless the caller passed
20
20
  * `false` or a custom config). Storage namespaces per agent via
21
- * `schemaName: "mastra_<agentId>"`; the vector store is a single
21
+ * {@link agentStorageSchemaName} per agent; the vector store is a single
22
22
  * shared singleton across every agent.
23
23
  * - Server: the Express subapp wiring lives in `./server.js`.
24
- * - HTTP: AppKit mounts this plugin under `/api/mastra`. `chatRoute`
25
- * is registered at `/route/chat` (bound to `config.defaultAgent` or
26
- * the first registered id) and `/route/chat/:agentId`, so the
27
- * AI SDK transport URL is `/api/mastra/route/chat/<agentId>`.
24
+ * - HTTP: AppKit mounts this plugin under `/api/mastra`. Alongside the
25
+ * Mastra agent routes, the plugin registers `/route/history`
26
+ * (load + clear a thread's messages), `/route/threads` (list the
27
+ * caller's conversations + delete one), `/models`, `/suggestions`,
28
+ * `/route/feedback` (log a thumbs / comment to MLflow when feedback
29
+ * is enabled), and the generic `/embed/:type/:id` resolver for inline
30
+ * chart / data markers. The stock `@mastra/express` surface is gated
31
+ * by `config.apiAccess` (default `"scoped"`): only agent inference,
32
+ * read-only agent metadata, the `/route/*` routes, and (when enabled)
33
+ * MCP are dispatched to Mastra; admin / mutating / bulk-export routes
34
+ * are refused with `403`. See {@link isMastraRequestAllowed}.
35
+ * - MCP: opt in with `config.mcp` to expose the agents (and optionally
36
+ * tools) as a Mastra `MCPServer`. It is registered on the `Mastra`
37
+ * instance via `mcpServers`, so `@mastra/express` serves the stock
38
+ * MCP transport routes (`/mcp/<serverId>/...`) under the mount. See
39
+ * `./mcp.js`.
28
40
  */
29
41
 
30
42
  import {
@@ -37,28 +49,38 @@ import {
37
49
  type PluginManifest,
38
50
  type ResourceRequirement,
39
51
  } from "@databricks/appkit";
40
- import { logUtils, pluginUtils } from "@dbx-tools/appkit-shared";
41
- import { chatRoute } from "@mastra/ai-sdk";
42
52
  import type { Agent } from "@mastra/core/agent";
43
53
  import { Mastra } from "@mastra/core/mastra";
44
54
  import express from "express";
55
+ import type { Pool } from "pg";
45
56
 
46
- import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents.js";
47
- import type { MastraClientConfig } from "@dbx-tools/appkit-mastra-shared";
48
- import type { MastraPluginConfig } from "./config.js";
49
- import { historyRoute } from "./history.js";
50
- import { renderChartRoute } from "./render-chart-route.js";
51
- import { createMemoryBuilder, needsLakebase } from "./memory.js";
52
- import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
53
57
  import {
54
- clearServingEndpointsCache,
55
- listServingEndpoints,
56
- resolveServingConfig,
57
- type ServingEndpointSummary,
58
- } from "./serving.js";
58
+ feedback,
59
+ routes,
60
+ type MastraClientConfig,
61
+ type MastraFeedbackRequest,
62
+ type StatementData,
63
+ } from "@dbx-tools/shared-mastra";
64
+ import { serving as nodeServing } from "@dbx-tools/model";
65
+ import { type ServingEndpointSummary } from "@dbx-tools/shared-model";
66
+ import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents";
67
+ import { fetchChart } from "./chart";
68
+ import type { MastraPluginConfig } from "./config";
69
+ import { collectSpaceSuggestions, resolveGenieSpaces } from "./genie";
70
+ import { historyRoute } from "./history";
71
+ import { buildMcpServer, type ResolvedMcp } from "./mcp";
72
+ import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory";
73
+ import { logFeedback, resolveFeedbackEnabled } from "./mlflow";
74
+ import { buildObservability } from "./observability";
75
+ import { attachRoutePatchMiddleware, isMastraRequestAllowed, MastraServer } from "./server";
76
+ import { resolveServingConfig } from "./serving";
77
+ import { fetchStatementData, STATEMENT_ROW_CAP } from "./statement";
78
+ import { threadsRoute } from "./threads";
79
+ import { error, log } from "@dbx-tools/shared-core";
80
+ import { plugin } from "@dbx-tools/appkit";
59
81
 
60
- const GENIE_MANIFEST = pluginUtils.data(genie).plugin.manifest;
61
- const LAKEBASE_MANIFEST = pluginUtils.data(lakebase).plugin.manifest;
82
+ const GENIE_MANIFEST = plugin.data(genie).plugin.manifest;
83
+ const LAKEBASE_MANIFEST = plugin.data(lakebase).plugin.manifest;
62
84
 
63
85
  /**
64
86
  * AppKit plugin (registered name: `mastra`) that hosts Mastra agents
@@ -76,6 +98,14 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
76
98
  resources: {
77
99
  required: [],
78
100
  optional: [
101
+ // Surface the Genie resource binding (space id) declared by
102
+ // AppKit's `genie` plugin manifest. The Mastra plugin no
103
+ // longer uses the genie plugin's tools at runtime - the
104
+ // built-in Genie agent talks to Genie directly via
105
+ // `@dbx-tools/genie` - but reusing the manifest keeps the
106
+ // resource-binding shape identical to AppKit's so existing
107
+ // `app.yaml` configs and `genie({ spaces })` wiring keep
108
+ // working without change.
79
109
  ...GENIE_MANIFEST.resources.required,
80
110
  ...LAKEBASE_MANIFEST.resources.required,
81
111
  ],
@@ -102,18 +132,32 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
102
132
  return resources;
103
133
  }
104
134
 
105
- private log = logUtils.logger(this);
135
+ private logger = log.logger(this);
106
136
  private built: BuiltAgents | null = null;
107
137
  private mastra: Mastra | null = null;
108
138
  private mastraApp: express.Express | null = null;
109
139
  private mastraServer: MastraServer | null = null;
140
+ /**
141
+ * The optional MCP server exposing this plugin's agents / tools, or
142
+ * `null` when `config.mcp` is disabled (the default). Built in
143
+ * {@link buildAgentAndServer} and registered on the Mastra instance.
144
+ */
145
+ private mcp: ResolvedMcp | null = null;
146
+ /**
147
+ * Dedicated service-principal Lakebase pool backing Mastra memory /
148
+ * storage. Built once in {@link buildAgentAndServer} (outside any
149
+ * `asUser` scope, so it never inherits a request's OBO identity) and
150
+ * drained in {@link abortActiveOperations}. `null` until setup runs
151
+ * or when Lakebase isn't needed.
152
+ */
153
+ private servicePrincipalPool: Pool | null = null;
110
154
 
111
155
  override async setup(): Promise<void> {
112
156
  // Wait until sibling plugins (e.g. `lakebase`) finish `setup()` so
113
157
  // the lakebase pool is valid when storage/memory are enabled.
114
158
  this.context?.onLifecycle("setup:complete", async () => {
115
159
  this.applyLakebaseAutoDefaults();
116
- this.log.info("setup:complete");
160
+ this.logger.info("setup:complete");
117
161
  await this.buildAgentAndServer();
118
162
  });
119
163
  }
@@ -126,12 +170,32 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
126
170
  * already in the registry by the time this fires.
127
171
  */
128
172
  private applyLakebaseAutoDefaults(): void {
129
- const hasLakebase = pluginUtils.instance(this.context, lakebase) !== undefined;
173
+ const hasLakebase = plugin.instance(this.context, lakebase) !== undefined;
130
174
  if (!hasLakebase) return;
131
175
  if (this.config.storage === undefined) this.config.storage = true;
132
176
  if (this.config.memory === undefined) this.config.memory = true;
133
177
  }
134
178
 
179
+ /**
180
+ * Drain the memory service-principal pool on shutdown. AppKit calls
181
+ * this during teardown; the lakebase plugin closes its own SP / OBO
182
+ * pools the same way. Fire-and-forget so shutdown isn't blocked on a
183
+ * slow drain, and clear the handle so a re-`setup()` rebuilds it.
184
+ */
185
+ override abortActiveOperations(): void {
186
+ super.abortActiveOperations();
187
+ if (this.servicePrincipalPool) {
188
+ this.logger.info("closing memory SP pool");
189
+ const pool = this.servicePrincipalPool;
190
+ this.servicePrincipalPool = null;
191
+ pool.end().catch((err) => {
192
+ this.logger.error("error closing memory SP pool", {
193
+ error: error.errorMessage(err),
194
+ });
195
+ });
196
+ }
197
+ }
198
+
135
199
  override exports() {
136
200
  return {
137
201
  /**
@@ -147,14 +211,34 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
147
211
  */
148
212
  get: (id: string): Agent | null => this.built?.agents[id] ?? null,
149
213
  /**
150
- * The agent `chatRoute` binds to when the client doesn't name
151
- * one. Resolves to `config.defaultAgent`, the first registered
152
- * id, or the built-in `default` fallback.
214
+ * The agent the client converses with when it doesn't name one.
215
+ * Resolves to `config.defaultAgent`, the first registered id, or
216
+ * the built-in `default` fallback.
153
217
  */
154
218
  getDefault: (): Agent | null =>
155
219
  (this.built && this.built.agents[this.built.defaultAgentId]) ?? null,
156
220
  /** Underlying Mastra instance for advanced use (custom routes etc.). */
157
221
  getMastra: () => this.mastra,
222
+ /**
223
+ * MCP endpoint info when `config.mcp` is enabled, else `null`.
224
+ * Paths are absolute (under the plugin mount), ready to hand to an
225
+ * MCP client. Streamable HTTP is `http`; the SSE pair is the
226
+ * legacy transport.
227
+ */
228
+ getMcp: (): {
229
+ serverId: string;
230
+ http: string;
231
+ sse: string;
232
+ messages: string;
233
+ } | null =>
234
+ this.mcp
235
+ ? {
236
+ serverId: this.mcp.serverId,
237
+ http: `/api/${this.name}${this.mcp.httpPath}`,
238
+ sse: `/api/${this.name}${this.mcp.ssePath}`,
239
+ messages: `/api/${this.name}${this.mcp.messagePath}`,
240
+ }
241
+ : null,
158
242
  /** Express subapp Mastra is mounted on; mostly for tests. */
159
243
  getMastraServer: () => this.mastraServer,
160
244
  /**
@@ -172,51 +256,348 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
172
256
  * Returns the underlying `CacheManager.delete`/`clear` promise.
173
257
  */
174
258
  clearModelsCache: (host?: string): Promise<void> =>
175
- clearServingEndpointsCache(host),
259
+ nodeServing.clearServingEndpointsCache(host),
176
260
  };
177
261
  }
178
262
 
179
263
  override clientConfig(): Record<string, unknown> {
180
264
  // AppKit mounts every plugin at `/api/<plugin.name>`. `this.name`
181
- // honors `config.name` overrides, so the published paths stay
182
- // accurate if someone remounts the plugin under a custom id.
265
+ // honors `config.name` overrides, so publishing `basePath` is
266
+ // enough for the client to stay correct under a custom mount id -
267
+ // the per-route segments are fixed (`routes.MASTRA_ROUTES`) and the
268
+ // client (`MastraPluginClient`) derives every endpoint from
269
+ // `basePath`.
183
270
  // Return widens to `Record<string, unknown>` to satisfy the
184
271
  // base-class signature; consumers read it through the typed
185
272
  // `MastraClientConfig` shape via `usePluginClientConfig<...>(...)`.
186
- const basePath = `/api/${this.name}`;
187
273
  const config: MastraClientConfig = {
188
- basePath,
189
- chatPath: `${basePath}/route/chat`,
190
- chatPathTemplate: `${basePath}/route/chat/:agentId`,
191
- modelsPath: `${basePath}/models`,
192
- historyPath: `${basePath}/route/history`,
193
- historyPathTemplate: `${basePath}/route/history/:agentId`,
194
- renderChartPath: `${basePath}/route/render-chart`,
274
+ basePath: `/api/${this.name}`,
195
275
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
196
276
  agents: Object.keys(this.built?.agents ?? {}),
277
+ feedbackEnabled: this.feedbackEnabled(),
197
278
  };
198
279
  return config as unknown as Record<string, unknown>;
199
280
  }
200
281
 
282
+ /**
283
+ * Whether user feedback can be logged to MLflow. Delegates to
284
+ * {@link resolveFeedbackEnabled} so the client-config flag and the
285
+ * feedback route share the same gate as the server's trace-id header.
286
+ */
287
+ private feedbackEnabled(): boolean {
288
+ return resolveFeedbackEnabled(this.config.feedback);
289
+ }
290
+
201
291
  override injectRoutes(router: IAppRouter): void {
292
+ // Expose the MCP transport at the clean `/mcp` (plus the legacy
293
+ // `/sse` + `/messages`) under the plugin mount. `@mastra/express`
294
+ // mounts MCP under `/mcp/<serverId>/<transport>`, and the serverId
295
+ // defaults to the plugin name, so the raw route reads
296
+ // `/api/mastra/mcp/mastra/mcp` (doubled segment). This runs before
297
+ // the catch-all and rewrites the alias to the underlying route, so
298
+ // the public path is just `/api/<plugin>/mcp`; the query string
299
+ // (e.g. the SSE `sessionId`) is preserved.
300
+ router.use((req, _res, next) => {
301
+ const target = this.mcpRouteAlias(req.path);
302
+ if (target) {
303
+ const q = req.url.indexOf("?");
304
+ req.url = q >= 0 ? target + req.url.slice(q) : target;
305
+ }
306
+ next();
307
+ });
308
+
202
309
  // `GET /models` exposes the cached endpoint list so clients can
203
310
  // populate model pickers, validate `?model=` choices, etc. Must
204
311
  // be registered before the catch-all that forwards everything to
205
312
  // the Mastra subapp. Errors propagate to Express's default error
206
313
  // handler via `next(err)` so callers see the real SDK message.
207
- router.get("/models", (req, res, next) => {
314
+ router.get(routes.MASTRA_ROUTES.models, (req, res, next) => {
208
315
  this.userScopedSelf(req)
209
316
  .listModels()
210
317
  .then((endpoints) => res.json({ endpoints }))
211
318
  .catch(next);
212
319
  });
213
320
 
214
- router.use("", (req, res, next) => {
321
+ // `GET /embed/:type/:id` is the single resolver for every embed
322
+ // marker the agent emits in prose (`[chart:<id>]`,
323
+ // `[data:<id>]`, ...). `:type` selects a resolver from the
324
+ // registry below; `:id` is that resolver's lookup key. The
325
+ // grammar (see `marker.ts`) is type-agnostic on purpose - new
326
+ // embed kinds are added by registering a resolver here, with no
327
+ // client or grammar change.
328
+ //
329
+ // Status codes:
330
+ // - 200 with the resolver's JSON body when the id resolves.
331
+ // - 404 when `:type` isn't registered (unsupported embed
332
+ // type) OR a registered resolver can't find `:id` (unknown
333
+ // / expired - e.g. a chart past its 1h TTL or a fabricated
334
+ // id the model never minted).
335
+ // - 400 when `:id` is empty.
336
+ //
337
+ // Per-type query knobs and behavior:
338
+ // - `chart`: long-polls the chart cache until the entry
339
+ // settles (`result` / `error`) or the budget elapses (then
340
+ // returns the still-processing entry to poll again).
341
+ // `?timeoutMs=<n>` (default 60s, capped 5min) tunes it.
342
+ // - `data`: one OBO-scoped Statement Execution fetch.
343
+ // `?limit=<n>` caps rows (clamped to STATEMENT_ROW_CAP).
344
+ //
345
+ // Built once (this handler is registered once) and keyed by the
346
+ // raw `:type` token. Each resolver gets the request (for query
347
+ // parsing + OBO scoping) and an `AbortSignal` bridged off the
348
+ // connection `close` event so a long-poll unblocks the instant
349
+ // the client disconnects. `undefined` from a resolver maps to a
350
+ // clean 404; thrown errors bubble through `next(err)`.
351
+ const embedResolvers: Record<string, EmbedResolver> = {
352
+ chart: (req, id, signal) => {
353
+ const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
354
+ return fetchChart(id, {
355
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
356
+ signal,
357
+ });
358
+ },
359
+ data: (req, id, signal) => {
360
+ const limit = parseStatementLimit(req.query["limit"]);
361
+ return this.userScopedSelf(req).fetchStatement(id, {
362
+ ...(limit !== undefined ? { limit } : {}),
363
+ signal,
364
+ });
365
+ },
366
+ };
367
+
368
+ router.get(`${routes.MASTRA_ROUTES.embed}/:type/:id`, (req, res, next) => {
369
+ const type = req.params["type"] ?? "";
370
+ const id = req.params["id"];
371
+ const resolve = embedResolvers[type];
372
+ if (!resolve) {
373
+ res.status(404).json({ error: `unsupported embed type: ${type}` });
374
+ return;
375
+ }
376
+ if (!id) {
377
+ res.status(400).json({ error: "id is required" });
378
+ return;
379
+ }
380
+ // Express's `req` predates `AbortSignal`; bridge the `close`
381
+ // event onto an `AbortController` so a closed connection
382
+ // unblocks any long-poll immediately and frees the request
383
+ // thread. The listener is GC'd with the request on normal
384
+ // completion.
385
+ const controller = new AbortController();
386
+ req.on("close", () => controller.abort());
387
+ resolve(req, id, controller.signal)
388
+ .then((entry) => {
389
+ if (entry === undefined) {
390
+ res.status(404).json({ error: `${type} not found` });
391
+ return;
392
+ }
393
+ res.json(entry);
394
+ })
395
+ .catch(next);
396
+ });
397
+
398
+ // `GET /suggestions` (and `/suggestions/:agentId`) returns the
399
+ // curated starter questions for the agent's Genie space(s) - the
400
+ // author-configured `sample_questions`, surfaced as one-tap
401
+ // prompts on the chat empty state. Returns `{ questions: [] }`
402
+ // when no Genie space is wired so the client renders a bare
403
+ // empty state (no built-in example prompts). The `:agentId`
404
+ // segment is accepted for URL symmetry with the chat / history
405
+ // routes; Genie spaces are resolved per-plugin, not per-agent,
406
+ // so it doesn't change the result. OBO-scoped like the other
407
+ // data routes so the space lookup runs as the calling user.
408
+ const handleSuggestions = (req: express.Request, res: express.Response): void => {
409
+ const controller = new AbortController();
410
+ req.on("close", () => controller.abort());
411
+ this.userScopedSelf(req)
412
+ .fetchSuggestions(controller.signal)
413
+ .then((questions) => res.json({ questions }))
414
+ .catch((err: unknown) => {
415
+ // Suggestions are a non-critical enhancement; a lookup
416
+ // failure should leave the chat usable with a bare empty
417
+ // state rather than surfacing a 500. Log and degrade.
418
+ this.logger.warn("suggestions:error", {
419
+ error: error.errorMessage(err),
420
+ });
421
+ res.json({ questions: [] });
422
+ });
423
+ };
424
+ router.get(routes.MASTRA_ROUTES.suggestions, handleSuggestions);
425
+ router.get(`${routes.MASTRA_ROUTES.suggestions}/:agentId`, handleSuggestions);
426
+
427
+ // `POST /route/feedback` logs a thumbs / comment assessment against
428
+ // a turn's MLflow trace (the `traceId` the client captured from the
429
+ // stream response's trace-id header). Registered on the AppKit
430
+ // router (like `/models`) rather than the Mastra subapp so it runs
431
+ // under the same OBO scope - the feedback is attributed to the
432
+ // signed-in user. Returns 404 when feedback is disabled so the
433
+ // client treats the capability as absent; 400 on a malformed body.
434
+ // A recorded assessment yields `{ ok: true }`; a soft failure (most
435
+ // often the trace hasn't finished exporting to MLflow yet) yields
436
+ // `{ ok: false }` without a 5xx so the UI can prompt a retry.
437
+ router.post(routes.MASTRA_ROUTES.feedback, (req, res, next) => {
438
+ if (!this.feedbackEnabled()) {
439
+ res.status(404).json({ ok: false });
440
+ return;
441
+ }
442
+ const parsed = feedback.MastraFeedbackRequestSchema.safeParse(req.body);
443
+ if (!parsed.success) {
444
+ res.status(400).json({ ok: false, error: parsed.error.message });
445
+ return;
446
+ }
447
+ this.userScopedSelf(req)
448
+ .logFeedback(parsed.data)
449
+ .then((assessmentId) =>
450
+ res.json({
451
+ ok: assessmentId !== undefined,
452
+ ...(assessmentId ? { assessmentId } : {}),
453
+ }),
454
+ )
455
+ .catch(next);
456
+ });
457
+
458
+ router.use((req, res, next) => {
215
459
  if (!this.mastraApp) return res.status(503).end();
216
- return this.userScopedSelf(req).mastraApp!(req, res, next);
460
+ // Gate the stock Mastra surface before dispatch. In the default
461
+ // "scoped" mode only agent inference, read-only agent metadata, this
462
+ // plugin's own `/route/*` routes, and (when enabled) MCP reach Mastra;
463
+ // admin / mutating / bulk-export routes are refused here. `req.path`
464
+ // is mount-relative under the plugin mount. See `server.ts`.
465
+ if (
466
+ !isMastraRequestAllowed(req.method, req.path, {
467
+ access: this.config.apiAccess ?? "scoped",
468
+ // Reflect the *resolved* MCP state, not raw `config.mcp`: MCP is
469
+ // on by default (`config.mcp` undefined), so gate on whether the
470
+ // server was actually built and mounted.
471
+ mcpEnabled: this.mcp !== null,
472
+ })
473
+ ) {
474
+ res.status(403).json({ error: "Endpoint not exposed to the client (apiAccess=scoped)" });
475
+ return;
476
+ }
477
+ // Dispatch through a real method, NOT the `mastraApp` property. The
478
+ // AppKit `asUser(req)` proxy wraps function-valued props with
479
+ // `value.bind(target)`. `mastraApp` is an express app whose `.bind` is
480
+ // the HTTP BIND route registrar (express defines a method per HTTP verb,
481
+ // and BIND is one), not `Function.prototype.bind` - so binding it through
482
+ // the proxy registers a bogus route and crashes `pathToRegexp`
483
+ // ("path must be a string ..."). This only manifests in production where
484
+ // an OBO token makes `userScopedSelf` return the proxy. `dispatchMastra`
485
+ // is a plain method (its `.bind` is the normal one) and invokes
486
+ // `this.mastraApp` off the real target, keeping the OBO scope active.
487
+ return this.userScopedSelf(req).dispatchMastra(req, res, next);
488
+ });
489
+ }
490
+
491
+ /**
492
+ * Invoke the Mastra express sub-app. Exists as a method (instead of reading
493
+ * `this.mastraApp` through the `asUser(req)` proxy at the call site) so the
494
+ * proxy binds this plain method - whose `.bind` is `Function.prototype.bind`
495
+ * - rather than the express app, whose `.bind` is the HTTP BIND route
496
+ * registrar (see the note in `injectRoutes`). Runs inside the user scope so
497
+ * `getExecutionContext()` returns the OBO client for the agent/model
498
+ * resolvers.
499
+ */
500
+ private dispatchMastra(
501
+ req: express.Request,
502
+ res: express.Response,
503
+ next: express.NextFunction,
504
+ ): void {
505
+ this.mastraApp!(req, res, next);
506
+ }
507
+
508
+ /**
509
+ * Map a clean, mount-relative MCP alias path to the underlying
510
+ * `@mastra/express` route. Returns `null` when MCP is off or the path
511
+ * isn't an alias. Collapses the stock `/mcp/<serverId>/<transport>`
512
+ * layout (serverId defaults to the plugin name) down to `/mcp`,
513
+ * `/sse`, and `/messages`.
514
+ */
515
+ private mcpRouteAlias(path: string): string | null {
516
+ if (!this.mcp) return null;
517
+ const id = this.mcp.serverId;
518
+ if (path === "/mcp") return `/mcp/${id}/mcp`;
519
+ if (path === "/sse") return `/mcp/${id}/sse`;
520
+ if (path === "/messages") return `/mcp/${id}/messages`;
521
+ return null;
522
+ }
523
+
524
+ /**
525
+ * Implementation backing the `/suggestions` route. Runs inside the
526
+ * AppKit user-context proxy so `getExecutionContext()` returns the
527
+ * OBO-scoped client. Resolves the plugin's Genie spaces and merges
528
+ * their curated `sample_questions` (see {@link collectSpaceSuggestions}).
529
+ * Returns `[]` when no Genie space is configured so the client
530
+ * shows a bare empty state instead of built-in example prompts.
531
+ */
532
+ private async fetchSuggestions(signal?: AbortSignal): Promise<string[]> {
533
+ const spaces = resolveGenieSpaces(this.config, this.context);
534
+ if (Object.keys(spaces).length === 0) return [];
535
+ const client = getExecutionContext().client;
536
+ return collectSpaceSuggestions({
537
+ spaces,
538
+ client,
539
+ ...(signal ? { signal } : {}),
540
+ });
541
+ }
542
+
543
+ /**
544
+ * Implementation backing the `/route/feedback` route. Runs inside the
545
+ * AppKit user-context proxy so `getExecutionContext()` returns the
546
+ * OBO-scoped client and the assessment is attributed to the signed-in
547
+ * user (their email / id as the assessment source). Returns the
548
+ * created assessment id on success, or `undefined` on a soft failure
549
+ * (see {@link logFeedback} in `./mlflow.js`).
550
+ */
551
+ private async logFeedback(feedback: MastraFeedbackRequest): Promise<string | undefined> {
552
+ const ctx = getExecutionContext();
553
+ const sourceId =
554
+ "userEmail" in ctx && ctx.userEmail
555
+ ? ctx.userEmail
556
+ : "userId" in ctx
557
+ ? ctx.userId
558
+ : ctx.serviceUserId;
559
+ return logFeedback(ctx.client, {
560
+ ...feedback,
561
+ ...(sourceId ? { sourceId } : {}),
217
562
  });
218
563
  }
219
564
 
565
+ /**
566
+ * Implementation backing the `data` embed resolver
567
+ * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
568
+ * `getExecutionContext()` returns the OBO-scoped workspace
569
+ * client, then reuses the same `fetchStatementData` pipeline
570
+ * the `get_statement` tool runs so the LLM and the UI see the
571
+ * exact same shape for the same statement.
572
+ *
573
+ * Returns `undefined` for upstream 404s so the route can map
574
+ * them to a clean HTTP 404; any other failure bubbles up.
575
+ */
576
+ private async fetchStatement(
577
+ statementId: string,
578
+ options: { limit?: number; signal?: AbortSignal } = {},
579
+ ): Promise<StatementData | undefined> {
580
+ const client = getExecutionContext().client;
581
+ const limit = Math.min(options.limit ?? STATEMENT_ROW_CAP, STATEMENT_ROW_CAP);
582
+ try {
583
+ const data = await fetchStatementData(client, statementId, {
584
+ limit,
585
+ ...(options.signal ? { signal: options.signal } : {}),
586
+ });
587
+ return {
588
+ columns: data.columns,
589
+ rows: data.rows,
590
+ rowCount: data.rowCount,
591
+ truncated: data.rows.length < data.rowCount,
592
+ };
593
+ } catch (err) {
594
+ // The Databricks SDK throws on 404; surface as `undefined`
595
+ // so the route maps to a clean HTTP 404 instead of a 500.
596
+ if (error.errorContext(err).notAccessible) return undefined;
597
+ throw err;
598
+ }
599
+ }
600
+
220
601
  /**
221
602
  * Return `this.asUser(req)` when the request carries an OBO token,
222
603
  * otherwise return `this` directly. Prevents the noisy AppKit warn
@@ -239,18 +620,36 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
239
620
  const client = getExecutionContext().client;
240
621
  const host = (await client.config.getHost()).toString();
241
622
  const serving = resolveServingConfig(this.config);
242
- return listServingEndpoints(client, host, { ttlMs: serving.ttlMs });
623
+ return nodeServing.listServingEndpoints(client, host, { ttlMs: serving.ttlMs });
243
624
  }
244
625
 
245
626
  private async buildAgentAndServer(): Promise<void> {
246
- // Per-agent memory factory. The builder resolves the Lakebase pool
247
- // lazily (on first agent that actually needs storage / vector) and
248
- // caches both the pool and the shared `PgVector` singleton so
249
- // registering N agents stays cheap. See `./memory.js`.
250
- const memoryBuilder = needsLakebase(this.config)
251
- ? createMemoryBuilder(this.config, this.context)
627
+ // Per-agent memory factory. When any storage / memory setting needs
628
+ // Postgres, stand up a dedicated service-principal pool first so
629
+ // memory acts as the app SP (owner of the `mastra_*` schemas),
630
+ // never the per-request OBO identity the chat turn runs under.
631
+ // `getPgConfig()` is read here, outside any `asUser` scope, so it
632
+ // returns the SP connection target + token refresh plus any
633
+ // `lakebase({ pool })` overrides; `require` turns a missing
634
+ // sibling into a clear wiring error. The builder caches the shared
635
+ // `PgVector` singleton so registering N agents stays cheap. See
636
+ // `./memory.js`.
637
+ if (needsLakebase(this.config)) {
638
+ const spPgConfig = plugin
639
+ .require(this.context, lakebase, this.config)
640
+ .exports()
641
+ .getPgConfig();
642
+ this.servicePrincipalPool = await createServicePrincipalPool(spPgConfig);
643
+ }
644
+ const memoryBuilder = this.servicePrincipalPool
645
+ ? createMemoryBuilder(this.config, this.servicePrincipalPool)
252
646
  : undefined;
253
647
 
648
+ this.logger.debug("build:start", {
649
+ lakebase: memoryBuilder !== undefined,
650
+ stripStaleCharts: this.config.stripStaleCharts !== false,
651
+ });
652
+
254
653
  // Build every agent declared in `config.agents` (or the built-in
255
654
  // fallback when none are declared). Each agent's `model` resolves
256
655
  // workspace URL + bearer at call time so concurrent requests get
@@ -261,14 +660,49 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
261
660
  config: this.config,
262
661
  context: this.context,
263
662
  memoryBuilder,
264
- log: this.log,
663
+ log: this.logger,
265
664
  });
266
665
 
267
666
  // `mastra.server.apiRoutes` is only honored by Mastra's standalone
268
667
  // dev server. Since we're hosting Mastra inside our own Express
269
668
  // subapp via `@mastra/express`, custom routes must be passed to
270
669
  // the `MastraServer` constructor directly.
271
- this.mastra = new Mastra({ agents: this.built.agents });
670
+ //
671
+ // `storage` here is *Mastra-instance-level* and persists workflow
672
+ // snapshots (where suspended `requireApproval` tool calls live).
673
+ // It's separate from each agent's `Memory.storage`, which only
674
+ // covers thread / message history. Without it,
675
+ // `agent.resumeStream()` errors with "could not find a suspended
676
+ // run" and the approval UI hangs after the user clicks Approve.
677
+ const instanceStorage = memoryBuilder?.instanceStorage();
678
+ // Wire Mastra's tracer into AppKit's global OTel pipeline via
679
+ // `@mastra/otel-bridge`. Mastra spans become native OTel spans on
680
+ // whatever tracer provider `TelemetryManager` registered during
681
+ // `createApp`, so the OTLP endpoint / headers / sampling are
682
+ // env-driven and shared with every other AppKit plugin.
683
+ const observability = await buildObservability({
684
+ serviceName: this.name,
685
+ enabled: this.config.observability,
686
+ });
687
+ // Optional MCP exposure: build a Mastra MCP server from the
688
+ // registered agents (and, opt-in, the ambient tools) and register
689
+ // it on the Mastra instance. `@mastra/express` serves the stock MCP
690
+ // transport routes (`/mcp/<serverId>/...`) off `mcpServers`, so the
691
+ // catch-all dispatch below already routes MCP requests under OBO -
692
+ // no bespoke route needed. See `./mcp.js`.
693
+ this.mcp = buildMcpServer({
694
+ config: this.config,
695
+ pluginName: this.name,
696
+ displayName: MastraPlugin.manifest.displayName,
697
+ agents: this.built.agents,
698
+ ambientTools: this.built.ambientTools,
699
+ });
700
+ this.mastra = new Mastra({
701
+ agents: this.built.agents,
702
+ ...(instanceStorage ? { storage: instanceStorage } : {}),
703
+ ...(observability ? { observability } : {}),
704
+ ...(this.mcp ? { mcpServers: { [this.mcp.serverId]: this.mcp.server } } : {}),
705
+ });
272
706
  this.mastraApp = express();
273
707
  attachRoutePatchMiddleware(this.mastraApp);
274
708
  this.mastraServer = new MastraServer(this.config, {
@@ -276,15 +710,95 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
276
710
  mastra: this.mastra,
277
711
  prefix: "",
278
712
  customApiRoutes: [
279
- chatRoute({ path: "/route/chat", agent: this.built.defaultAgentId }),
280
- chatRoute({ path: "/route/chat/:agentId" }),
281
- historyRoute({ path: "/route/history", agent: this.built.defaultAgentId }),
282
- historyRoute({ path: "/route/history/:agentId" }),
283
- renderChartRoute({ path: "/route/render-chart", config: this.config }),
713
+ // `historyRoute` registers both GET (load) and DELETE
714
+ // (clear) on the same path, so it returns an array we
715
+ // splice in.
716
+ ...historyRoute({
717
+ path: routes.MASTRA_ROUTES.history,
718
+ agent: this.built.defaultAgentId,
719
+ }),
720
+ // Assert the `:agentId` template type: the per-package build's
721
+ // NodeNext resolution widens the imported `routes.MASTRA_ROUTES.history`
722
+ // to `string` (the source/bundler typecheck keeps it a literal),
723
+ // which would otherwise drop this out of the dynamic-agent
724
+ // overload and demand a fixed `agent`.
725
+ ...historyRoute({
726
+ path: `${routes.MASTRA_ROUTES.history}/:agentId` as `${string}:agentId`,
727
+ }),
728
+ // `threadsRoute` registers GET (list the caller's conversation
729
+ // threads) and DELETE (remove the targeted thread) on the same
730
+ // path; both the default-agent and dynamic-agent mounts are
731
+ // spliced in, mirroring the history routes above.
732
+ ...threadsRoute({
733
+ path: routes.MASTRA_ROUTES.threads,
734
+ agent: this.built.defaultAgentId,
735
+ }),
736
+ ...threadsRoute({
737
+ path: `${routes.MASTRA_ROUTES.threads}/:agentId` as `${string}:agentId`,
738
+ }),
284
739
  ],
285
740
  });
286
741
  await this.mastraServer.init();
742
+ this.logger.debug("build:done", {
743
+ agents: Object.keys(this.built.agents),
744
+ defaultAgent: this.built.defaultAgentId,
745
+ routes: [
746
+ "/route/history",
747
+ "/route/threads",
748
+ "/models",
749
+ "/suggestions",
750
+ "/route/feedback",
751
+ "/embed/:type/:id",
752
+ ],
753
+ instanceStorage: instanceStorage !== undefined,
754
+ observability: observability !== undefined ? "mlflow" : "off",
755
+ mcp: this.mcp ? `/api/${this.name}${this.mcp.httpPath}` : "off",
756
+ });
287
757
  }
288
758
  }
289
759
 
760
+ /**
761
+ * Resolver for one embed `<type>` behind the generic
762
+ * `GET /embed/:type/:id` route. Returns the JSON body to send on
763
+ * success, or `undefined` to signal a 404 (unknown / expired id).
764
+ * `signal` aborts when the client disconnects so long-polling
765
+ * resolvers (e.g. `chart`) unblock immediately.
766
+ */
767
+ type EmbedResolver = (
768
+ req: express.Request,
769
+ id: string,
770
+ signal: AbortSignal,
771
+ ) => Promise<unknown | undefined>;
772
+
773
+ /**
774
+ * Parse the optional `?timeoutMs=<n>` query parameter from a
775
+ * `GET /embed/chart/:id` request. Accepts a positive integer up
776
+ * to 5 minutes (clamped) and rejects everything else as
777
+ * `undefined` so {@link fetchChart} falls back to its default.
778
+ * Express produces `string | string[] | undefined`; we normalize
779
+ * to the first scalar before parsing.
780
+ */
781
+ function parseTimeoutMs(raw: unknown): number | undefined {
782
+ const v = Array.isArray(raw) ? raw[0] : raw;
783
+ if (typeof v !== "string") return undefined;
784
+ const n = Number(v);
785
+ if (!Number.isFinite(n) || n <= 0) return undefined;
786
+ return Math.min(Math.floor(n), 5 * 60_000);
787
+ }
788
+
789
+ /**
790
+ * Parse the optional `?limit=<n>` query parameter from a
791
+ * `GET /embed/data/:id` request. Accepts a non-negative
792
+ * integer and lets the route clamp to `STATEMENT_ROW_CAP`;
793
+ * rejects anything else as `undefined` so the route falls back
794
+ * to the server-side cap.
795
+ */
796
+ function parseStatementLimit(raw: unknown): number | undefined {
797
+ const v = Array.isArray(raw) ? raw[0] : raw;
798
+ if (typeof v !== "string") return undefined;
799
+ const n = Number(v);
800
+ if (!Number.isFinite(n) || n < 0) return undefined;
801
+ return Math.floor(n);
802
+ }
803
+
290
804
  export const mastra = toPlugin(MastraPlugin);