@dbx-tools/appkit-mastra 0.1.112 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts ADDED
@@ -0,0 +1,819 @@
1
+ /**
2
+ * AppKit plugin that builds one or more Mastra `Agent` instances and
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
+ *
8
+ * - Agents: registered through `config.agents` at plugin creation
9
+ * ({@link MastraAgentDefinition}). Each entry's `tools` field accepts
10
+ * either a plain record or a `(plugins) => tools` callback that gets
11
+ * a typed sibling-plugin index ({@link MastraPlugins}). Omit
12
+ * `config.agents` to get a single built-in `default` analyst.
13
+ * - Model: each agent call resolves a `MastraModelConfig` via
14
+ * {@link buildModel} from `./model.js`. Per-agent `model` overrides
15
+ * (`AgentConfig["model"]` or a `modelId` string) flow through
16
+ * {@link buildAgents}.
17
+ * - Memory / storage: per-agent, built by {@link createMemoryBuilder}
18
+ * from `./memory.js`. Both auto-default to `true` when the
19
+ * `lakebase` plugin is registered (unless the caller passed
20
+ * `false` or a custom config). Storage namespaces per agent via
21
+ * {@link agentStorageSchemaName} per agent; the vector store is a single
22
+ * shared singleton across every agent.
23
+ * - Server: the Express subapp wiring lives in `./server.js`.
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`.
40
+ */
41
+
42
+ import {
43
+ genie,
44
+ getExecutionContext,
45
+ lakebase,
46
+ Plugin,
47
+ toPlugin,
48
+ type IAppRouter,
49
+ type PluginManifest,
50
+ type ResourceRequirement,
51
+ } from "@databricks/appkit";
52
+ import type { Agent } from "@mastra/core/agent";
53
+ import { Mastra } from "@mastra/core/mastra";
54
+ import express from "express";
55
+ import type { Pool } from "pg";
56
+
57
+ import {
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, string } from "@dbx-tools/shared-core";
80
+ import { plugin } from "@dbx-tools/appkit";
81
+
82
+ const GENIE_MANIFEST = plugin.data(genie).plugin.manifest;
83
+ const LAKEBASE_MANIFEST = plugin.data(lakebase).plugin.manifest;
84
+
85
+ /**
86
+ * AppKit plugin (registered name: `mastra`) that hosts Mastra agents
87
+ * with optional Lakebase-backed memory and AI SDK chat routes under
88
+ * the plugin mount (typically `/api/mastra`).
89
+ */
90
+ export class MastraPlugin extends Plugin<MastraPluginConfig> {
91
+ static manifest = {
92
+ name: "mastra",
93
+ displayName: "Mastra",
94
+ description:
95
+ "Builds a Mastra Agent with user-scoped workspace auth (asUser) " +
96
+ "and optional Postgres-backed Mastra Memory via the `lakebase` plugin.",
97
+ stability: "beta",
98
+ resources: {
99
+ required: [],
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.
109
+ ...GENIE_MANIFEST.resources.required,
110
+ ...LAKEBASE_MANIFEST.resources.required,
111
+ ],
112
+ },
113
+ } satisfies PluginManifest<"mastra">;
114
+
115
+ /**
116
+ * Tighten resource requirements based on which features are enabled.
117
+ * AppKit calls this at registration time (config-aware) so disabled
118
+ * features don't surface their resource asks to the host app.
119
+ */
120
+ static getResourceRequirements(config: MastraPluginConfig): ResourceRequirement[] {
121
+ const resources: ResourceRequirement[] = [];
122
+ const enabledManifests: PluginManifest<string>[] = [];
123
+
124
+ if (needsLakebase(config)) {
125
+ enabledManifests.push(LAKEBASE_MANIFEST);
126
+ }
127
+ for (const m of enabledManifests) {
128
+ for (const resource of m.resources.required) {
129
+ resources.push({ ...resource, required: true } as ResourceRequirement);
130
+ }
131
+ }
132
+ return resources;
133
+ }
134
+
135
+ private logger = log.logger(this);
136
+ private built: BuiltAgents | null = null;
137
+ private mastra: Mastra | null = null;
138
+ private mastraApp: express.Express | null = null;
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;
154
+
155
+ override async setup(): Promise<void> {
156
+ // Wait until sibling plugins (e.g. `lakebase`) finish `setup()` so
157
+ // the lakebase pool is valid when storage/memory are enabled.
158
+ this.context?.onLifecycle("setup:complete", async () => {
159
+ this.applyLakebaseAutoDefaults();
160
+ this.logger.info("setup:complete");
161
+ await this.buildAgentAndServer();
162
+ });
163
+ }
164
+
165
+ /**
166
+ * When the `lakebase` plugin is registered, auto-enable `storage`
167
+ * and `memory` unless the caller opted out explicitly (`false` or a
168
+ * custom config object). Run after `setup:complete` so the lookup
169
+ * is reliable: any plugin that registers itself synchronously is
170
+ * already in the registry by the time this fires.
171
+ */
172
+ private applyLakebaseAutoDefaults(): void {
173
+ const hasLakebase = plugin.instance(this.context, lakebase) !== undefined;
174
+ if (!hasLakebase) return;
175
+ if (this.config.storage === undefined) this.config.storage = true;
176
+ if (this.config.memory === undefined) this.config.memory = true;
177
+ }
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
+
199
+ override exports() {
200
+ return {
201
+ /**
202
+ * Ids of every registered agent in registration order. Matches
203
+ * AppKit `agents.list()` so callers can iterate the registry the
204
+ * same way under both plugins.
205
+ */
206
+ list: (): string[] => Object.keys(this.built?.agents ?? {}),
207
+ /**
208
+ * Look up a registered agent by id. Returns `null` (not
209
+ * undefined) when unknown so call sites can early-return without
210
+ * a separate `in` check.
211
+ */
212
+ get: (id: string): Agent | null => this.built?.agents[id] ?? null,
213
+ /**
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.
217
+ */
218
+ getDefault: (): Agent | null =>
219
+ (this.built && this.built.agents[this.built.defaultAgentId]) ?? null,
220
+ /** Underlying Mastra instance for advanced use (custom routes etc.). */
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,
242
+ /** Express subapp Mastra is mounted on; mostly for tests. */
243
+ getMastraServer: () => this.mastraServer,
244
+ /**
245
+ * Fetch the workspace's Model Serving endpoints (cached). Same
246
+ * payload the `GET /models` route returns; surfaced here so
247
+ * other plugins / scripts can introspect the catalogue without
248
+ * an HTTP round-trip. AppKit wraps this with `asUser(req)` for
249
+ * OBO scoping automatically.
250
+ */
251
+ listModels: (): Promise<ServingEndpointSummary[]> => this.listModels(),
252
+ /**
253
+ * Force-evict cached endpoint listings via AppKit's
254
+ * `CacheManager`. Useful in tests or right after an admin
255
+ * deploys a new endpoint and doesn't want to wait for the TTL.
256
+ * Returns the underlying `CacheManager.delete`/`clear` promise.
257
+ */
258
+ clearModelsCache: (host?: string): Promise<void> =>
259
+ nodeServing.clearServingEndpointsCache(host),
260
+ };
261
+ }
262
+
263
+ override clientConfig(): Record<string, unknown> {
264
+ // AppKit mounts every plugin at `/api/<plugin.name>`. `this.name`
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`.
270
+ // Return widens to `Record<string, unknown>` to satisfy the
271
+ // base-class signature; consumers read it through the typed
272
+ // `MastraClientConfig` shape via `usePluginClientConfig<...>(...)`.
273
+ const config: MastraClientConfig = {
274
+ basePath: `/api/${this.name}`,
275
+ defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
276
+ agents: Object.keys(this.built?.agents ?? {}),
277
+ feedbackEnabled: this.feedbackEnabled(),
278
+ };
279
+ return config as unknown as Record<string, unknown>;
280
+ }
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
+
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
+
309
+ // `GET /models` exposes the cached endpoint list so clients can
310
+ // populate model pickers, validate `?model=` choices, etc. Must
311
+ // be registered before the catch-all that forwards everything to
312
+ // the Mastra subapp. Errors propagate to Express's default error
313
+ // handler via `next(err)` so callers see the real SDK message.
314
+ router.get(routes.MASTRA_ROUTES.models, (req, res, next) => {
315
+ this.userScopedSelf(req)
316
+ .listModels()
317
+ .then((endpoints) => res.json({ endpoints }))
318
+ .catch(next);
319
+ });
320
+
321
+ // `GET /default-model[?agentId=]` reports the static serving-endpoint id
322
+ // an agent falls back to when the client pins no model, so the picker can
323
+ // name its "Server default" option. Agent-scoped (defaults to the default
324
+ // agent); `model` is null when the agent has no static default (a dynamic,
325
+ // call-time model) or the agent id is unknown. Registered before the
326
+ // catch-all, same as `/models`.
327
+ router.get(routes.MASTRA_ROUTES.defaultModel, (req, res) => {
328
+ const requested = string.firstNonEmpty(req.query["agentId"]);
329
+ const agentId = requested ?? this.built?.defaultAgentId ?? FALLBACK_AGENT_ID;
330
+ const model = this.built?.defaultModels[agentId];
331
+ // `"<dynamic>"` (a call-time function) has no fixed id to advertise.
332
+ res.json({ agentId, model: model && model !== "<dynamic>" ? model : null });
333
+ });
334
+
335
+ // `GET /embed/:type/:id` is the single resolver for every embed
336
+ // marker the agent emits in prose (`[chart:<id>]`,
337
+ // `[data:<id>]`, ...). `:type` selects a resolver from the
338
+ // registry below; `:id` is that resolver's lookup key. The
339
+ // grammar (see `marker.ts`) is type-agnostic on purpose - new
340
+ // embed kinds are added by registering a resolver here, with no
341
+ // client or grammar change.
342
+ //
343
+ // Status codes:
344
+ // - 200 with the resolver's JSON body when the id resolves.
345
+ // - 404 when `:type` isn't registered (unsupported embed
346
+ // type) OR a registered resolver can't find `:id` (unknown
347
+ // / expired - e.g. a chart past its 1h TTL or a fabricated
348
+ // id the model never minted).
349
+ // - 400 when `:id` is empty.
350
+ //
351
+ // Per-type query knobs and behavior:
352
+ // - `chart`: long-polls the chart cache until the entry
353
+ // settles (`result` / `error`) or the budget elapses (then
354
+ // returns the still-processing entry to poll again).
355
+ // `?timeoutMs=<n>` (default 60s, capped 5min) tunes it.
356
+ // - `data`: one OBO-scoped Statement Execution fetch.
357
+ // `?limit=<n>` caps rows (clamped to STATEMENT_ROW_CAP).
358
+ //
359
+ // Built once (this handler is registered once) and keyed by the
360
+ // raw `:type` token. Each resolver gets the request (for query
361
+ // parsing + OBO scoping) and an `AbortSignal` bridged off the
362
+ // connection `close` event so a long-poll unblocks the instant
363
+ // the client disconnects. `undefined` from a resolver maps to a
364
+ // clean 404; thrown errors bubble through `next(err)`.
365
+ const embedResolvers: Record<string, EmbedResolver> = {
366
+ chart: (req, id, signal) => {
367
+ const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
368
+ return fetchChart(id, {
369
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
370
+ signal,
371
+ });
372
+ },
373
+ data: (req, id, signal) => {
374
+ const limit = parseStatementLimit(req.query["limit"]);
375
+ return this.userScopedSelf(req).fetchStatement(id, {
376
+ ...(limit !== undefined ? { limit } : {}),
377
+ signal,
378
+ });
379
+ },
380
+ };
381
+
382
+ router.get(`${routes.MASTRA_ROUTES.embed}/:type/:id`, (req, res, next) => {
383
+ const type = req.params["type"] ?? "";
384
+ const id = req.params["id"];
385
+ const resolve = embedResolvers[type];
386
+ if (!resolve) {
387
+ res.status(404).json({ error: `unsupported embed type: ${type}` });
388
+ return;
389
+ }
390
+ if (!id) {
391
+ res.status(400).json({ error: "id is required" });
392
+ return;
393
+ }
394
+ // Express's `req` predates `AbortSignal`; bridge the `close`
395
+ // event onto an `AbortController` so a closed connection
396
+ // unblocks any long-poll immediately and frees the request
397
+ // thread. The listener is GC'd with the request on normal
398
+ // completion.
399
+ const controller = new AbortController();
400
+ req.on("close", () => controller.abort());
401
+ resolve(req, id, controller.signal)
402
+ .then((entry) => {
403
+ if (entry === undefined) {
404
+ res.status(404).json({ error: `${type} not found` });
405
+ return;
406
+ }
407
+ res.json(entry);
408
+ })
409
+ .catch(next);
410
+ });
411
+
412
+ // `GET /suggestions` (and `/suggestions/:agentId`) returns the
413
+ // curated starter questions for the agent's Genie space(s) - the
414
+ // author-configured `sample_questions`, surfaced as one-tap
415
+ // prompts on the chat empty state. Returns `{ questions: [] }`
416
+ // when no Genie space is wired so the client renders a bare
417
+ // empty state (no built-in example prompts). The `:agentId`
418
+ // segment is accepted for URL symmetry with the chat / history
419
+ // routes; Genie spaces are resolved per-plugin, not per-agent,
420
+ // so it doesn't change the result. OBO-scoped like the other
421
+ // data routes so the space lookup runs as the calling user.
422
+ const handleSuggestions = (req: express.Request, res: express.Response): void => {
423
+ const controller = new AbortController();
424
+ req.on("close", () => controller.abort());
425
+ this.userScopedSelf(req)
426
+ .fetchSuggestions(controller.signal)
427
+ .then((questions) => res.json({ questions }))
428
+ .catch((err: unknown) => {
429
+ // Suggestions are a non-critical enhancement; a lookup
430
+ // failure should leave the chat usable with a bare empty
431
+ // state rather than surfacing a 500. Log and degrade.
432
+ this.logger.warn("suggestions:error", {
433
+ error: error.errorMessage(err),
434
+ });
435
+ res.json({ questions: [] });
436
+ });
437
+ };
438
+ router.get(routes.MASTRA_ROUTES.suggestions, handleSuggestions);
439
+ router.get(`${routes.MASTRA_ROUTES.suggestions}/:agentId`, handleSuggestions);
440
+
441
+ // `POST /route/feedback` logs a thumbs / comment assessment against
442
+ // a turn's MLflow trace (the `traceId` the client captured from the
443
+ // stream response's trace-id header). Registered on the AppKit
444
+ // router (like `/models`) rather than the Mastra subapp so it runs
445
+ // under the same OBO scope - the feedback is attributed to the
446
+ // signed-in user. Returns 404 when feedback is disabled so the
447
+ // client treats the capability as absent; 400 on a malformed body.
448
+ // A recorded assessment yields `{ ok: true }`; a soft failure (most
449
+ // often the trace hasn't finished exporting to MLflow yet) yields
450
+ // `{ ok: false }` without a 5xx so the UI can prompt a retry.
451
+ router.post(routes.MASTRA_ROUTES.feedback, (req, res, next) => {
452
+ if (!this.feedbackEnabled()) {
453
+ res.status(404).json({ ok: false });
454
+ return;
455
+ }
456
+ const parsed = feedback.MastraFeedbackRequestSchema.safeParse(req.body);
457
+ if (!parsed.success) {
458
+ res.status(400).json({ ok: false, error: parsed.error.message });
459
+ return;
460
+ }
461
+ this.userScopedSelf(req)
462
+ .logFeedback(parsed.data)
463
+ .then((assessmentId) =>
464
+ res.json({
465
+ ok: assessmentId !== undefined,
466
+ ...(assessmentId ? { assessmentId } : {}),
467
+ }),
468
+ )
469
+ .catch(next);
470
+ });
471
+
472
+ router.use((req, res, next) => {
473
+ if (!this.mastraApp) return res.status(503).end();
474
+ // Gate the stock Mastra surface before dispatch. In the default
475
+ // "scoped" mode only agent inference, read-only agent metadata, this
476
+ // plugin's own `/route/*` routes, and (when enabled) MCP reach Mastra;
477
+ // admin / mutating / bulk-export routes are refused here. `req.path`
478
+ // is mount-relative under the plugin mount. See `server.ts`.
479
+ if (
480
+ !isMastraRequestAllowed(req.method, req.path, {
481
+ access: this.config.apiAccess ?? "scoped",
482
+ // Reflect the *resolved* MCP state, not raw `config.mcp`: MCP is
483
+ // on by default (`config.mcp` undefined), so gate on whether the
484
+ // server was actually built and mounted.
485
+ mcpEnabled: this.mcp !== null,
486
+ })
487
+ ) {
488
+ res.status(403).json({ error: "Endpoint not exposed to the client (apiAccess=scoped)" });
489
+ return;
490
+ }
491
+ // Dispatch through a real method, NOT the `mastraApp` property. The
492
+ // AppKit `asUser(req)` proxy wraps function-valued props with
493
+ // `value.bind(target)`. `mastraApp` is an express app whose `.bind` is
494
+ // the HTTP BIND route registrar (express defines a method per HTTP verb,
495
+ // and BIND is one), not `Function.prototype.bind` - so binding it through
496
+ // the proxy registers a bogus route and crashes `pathToRegexp`
497
+ // ("path must be a string ..."). This only manifests in production where
498
+ // an OBO token makes `userScopedSelf` return the proxy. `dispatchMastra`
499
+ // is a plain method (its `.bind` is the normal one) and invokes
500
+ // `this.mastraApp` off the real target, keeping the OBO scope active.
501
+ return this.userScopedSelf(req).dispatchMastra(req, res, next);
502
+ });
503
+ }
504
+
505
+ /**
506
+ * Invoke the Mastra express sub-app. Exists as a method (instead of reading
507
+ * `this.mastraApp` through the `asUser(req)` proxy at the call site) so the
508
+ * proxy binds this plain method - whose `.bind` is `Function.prototype.bind`
509
+ * - rather than the express app, whose `.bind` is the HTTP BIND route
510
+ * registrar (see the note in `injectRoutes`). Runs inside the user scope so
511
+ * `getExecutionContext()` returns the OBO client for the agent/model
512
+ * resolvers.
513
+ */
514
+ private dispatchMastra(
515
+ req: express.Request,
516
+ res: express.Response,
517
+ next: express.NextFunction,
518
+ ): void {
519
+ this.mastraApp!(req, res, next);
520
+ }
521
+
522
+ /**
523
+ * Map a clean, mount-relative MCP alias path to the underlying
524
+ * `@mastra/express` route. Returns `null` when MCP is off or the path
525
+ * isn't an alias. Collapses the stock `/mcp/<serverId>/<transport>`
526
+ * layout (serverId defaults to the plugin name) down to `/mcp`,
527
+ * `/sse`, and `/messages`.
528
+ */
529
+ private mcpRouteAlias(path: string): string | null {
530
+ if (!this.mcp) return null;
531
+ const id = this.mcp.serverId;
532
+ if (path === "/mcp") return `/mcp/${id}/mcp`;
533
+ if (path === "/sse") return `/mcp/${id}/sse`;
534
+ if (path === "/messages") return `/mcp/${id}/messages`;
535
+ return null;
536
+ }
537
+
538
+ /**
539
+ * Implementation backing the `/suggestions` route. Runs inside the
540
+ * AppKit user-context proxy so `getExecutionContext()` returns the
541
+ * OBO-scoped client. Resolves the plugin's Genie spaces and merges
542
+ * their curated `sample_questions` (see {@link collectSpaceSuggestions}).
543
+ * Returns `[]` when no Genie space is configured so the client
544
+ * shows a bare empty state instead of built-in example prompts.
545
+ */
546
+ private async fetchSuggestions(signal?: AbortSignal): Promise<string[]> {
547
+ const spaces = resolveGenieSpaces(this.config, this.context);
548
+ if (Object.keys(spaces).length === 0) return [];
549
+ const client = getExecutionContext().client;
550
+ return collectSpaceSuggestions({
551
+ spaces,
552
+ client,
553
+ ...(signal ? { signal } : {}),
554
+ });
555
+ }
556
+
557
+ /**
558
+ * Implementation backing the `/route/feedback` route. Runs inside the
559
+ * AppKit user-context proxy so `getExecutionContext()` returns the
560
+ * OBO-scoped client and the assessment is attributed to the signed-in
561
+ * user (their email / id as the assessment source). Returns the
562
+ * created assessment id on success, or `undefined` on a soft failure
563
+ * (see {@link logFeedback} in `./mlflow.js`).
564
+ */
565
+ private async logFeedback(feedback: MastraFeedbackRequest): Promise<string | undefined> {
566
+ const ctx = getExecutionContext();
567
+ const sourceId =
568
+ "userEmail" in ctx && ctx.userEmail
569
+ ? ctx.userEmail
570
+ : "userId" in ctx
571
+ ? ctx.userId
572
+ : ctx.serviceUserId;
573
+ return logFeedback(ctx.client, {
574
+ ...feedback,
575
+ ...(sourceId ? { sourceId } : {}),
576
+ });
577
+ }
578
+
579
+ /**
580
+ * Implementation backing the `data` embed resolver
581
+ * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
582
+ * `getExecutionContext()` returns the OBO-scoped workspace
583
+ * client, then reuses the same `fetchStatementData` pipeline
584
+ * the `get_statement` tool runs so the LLM and the UI see the
585
+ * exact same shape for the same statement.
586
+ *
587
+ * Returns `undefined` for upstream 404s so the route can map
588
+ * them to a clean HTTP 404; any other failure bubbles up.
589
+ */
590
+ private async fetchStatement(
591
+ statementId: string,
592
+ options: { limit?: number; signal?: AbortSignal } = {},
593
+ ): Promise<StatementData | undefined> {
594
+ const client = getExecutionContext().client;
595
+ const limit = Math.min(options.limit ?? STATEMENT_ROW_CAP, STATEMENT_ROW_CAP);
596
+ try {
597
+ const data = await fetchStatementData(client, statementId, {
598
+ limit,
599
+ ...(options.signal ? { signal: options.signal } : {}),
600
+ });
601
+ return {
602
+ columns: data.columns,
603
+ rows: data.rows,
604
+ rowCount: data.rowCount,
605
+ truncated: data.rows.length < data.rowCount,
606
+ };
607
+ } catch (err) {
608
+ // The Databricks SDK throws on 404; surface as `undefined`
609
+ // so the route maps to a clean HTTP 404 instead of a 500.
610
+ if (error.errorContext(err).notAccessible) return undefined;
611
+ throw err;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Return `this.asUser(req)` when the request carries an OBO token,
617
+ * otherwise return `this` directly. Prevents the noisy AppKit warn
618
+ * (`asUser() called without user token in development mode. Skipping
619
+ * user impersonation.`) on every request in local dev where the
620
+ * browser never sends `x-forwarded-access-token`. Behavior is
621
+ * unchanged in production: a missing token always means a real OBO
622
+ * proxy call (and AppKit will throw upstream if that's wrong).
623
+ */
624
+ private userScopedSelf(req: express.Request): this {
625
+ return req.header("x-forwarded-access-token") ? (this.asUser(req) as this) : this;
626
+ }
627
+
628
+ /**
629
+ * Implementation backing both the `/models` route and the
630
+ * `listModels` export. Runs inside the AppKit user-context proxy so
631
+ * `getExecutionContext()` returns the OBO-scoped client.
632
+ */
633
+ private async listModels(): Promise<ServingEndpointSummary[]> {
634
+ const client = getExecutionContext().client;
635
+ const host = (await client.config.getHost()).toString();
636
+ const serving = resolveServingConfig(this.config);
637
+ return nodeServing.listServingEndpoints(client, host, { ttlMs: serving.ttlMs });
638
+ }
639
+
640
+ private async buildAgentAndServer(): Promise<void> {
641
+ // Per-agent memory factory. When any storage / memory setting needs
642
+ // Postgres, stand up a dedicated service-principal pool first so
643
+ // memory acts as the app SP (owner of the `mastra_*` schemas),
644
+ // never the per-request OBO identity the chat turn runs under.
645
+ // `getPgConfig()` is read here, outside any `asUser` scope, so it
646
+ // returns the SP connection target + token refresh plus any
647
+ // `lakebase({ pool })` overrides; `require` turns a missing
648
+ // sibling into a clear wiring error. The builder caches the shared
649
+ // `PgVector` singleton so registering N agents stays cheap. See
650
+ // `./memory.js`.
651
+ if (needsLakebase(this.config)) {
652
+ const spPgConfig = plugin
653
+ .require(this.context, lakebase, this.config)
654
+ .exports()
655
+ .getPgConfig();
656
+ this.servicePrincipalPool = await createServicePrincipalPool(spPgConfig);
657
+ }
658
+ const memoryBuilder = this.servicePrincipalPool
659
+ ? createMemoryBuilder(this.config, this.servicePrincipalPool)
660
+ : undefined;
661
+
662
+ this.logger.debug("build:start", {
663
+ lakebase: memoryBuilder !== undefined,
664
+ stripStaleCharts: this.config.stripStaleCharts !== false,
665
+ });
666
+
667
+ // Build every agent declared in `config.agents` (or the built-in
668
+ // fallback when none are declared). Each agent's `model` resolves
669
+ // workspace URL + bearer at call time so concurrent requests get
670
+ // distinct user identities; the `asUser(req)` scope around
671
+ // `handleChat` is what lets `getExecutionContext()` return the
672
+ // right user inside the resolver.
673
+ this.built = await buildAgents({
674
+ config: this.config,
675
+ context: this.context,
676
+ memoryBuilder,
677
+ log: this.logger,
678
+ });
679
+
680
+ // `mastra.server.apiRoutes` is only honored by Mastra's standalone
681
+ // dev server. Since we're hosting Mastra inside our own Express
682
+ // subapp via `@mastra/express`, custom routes must be passed to
683
+ // the `MastraServer` constructor directly.
684
+ //
685
+ // `storage` here is *Mastra-instance-level* and persists workflow
686
+ // snapshots (where suspended `requireApproval` tool calls live).
687
+ // It's separate from each agent's `Memory.storage`, which only
688
+ // covers thread / message history. Without it,
689
+ // `agent.resumeStream()` errors with "could not find a suspended
690
+ // run" and the approval UI hangs after the user clicks Approve.
691
+ const instanceStorage = memoryBuilder?.instanceStorage();
692
+ // Wire Mastra's tracer into AppKit's global OTel pipeline via
693
+ // `@mastra/otel-bridge`. Mastra spans become native OTel spans on
694
+ // whatever tracer provider `TelemetryManager` registered during
695
+ // `createApp`, so the OTLP endpoint / headers / sampling are
696
+ // env-driven and shared with every other AppKit plugin.
697
+ const observability = await buildObservability({
698
+ serviceName: this.name,
699
+ enabled: this.config.observability,
700
+ });
701
+ // Optional MCP exposure: build a Mastra MCP server from the
702
+ // registered agents (and, opt-in, the ambient tools) and register
703
+ // it on the Mastra instance. `@mastra/express` serves the stock MCP
704
+ // transport routes (`/mcp/<serverId>/...`) off `mcpServers`, so the
705
+ // catch-all dispatch below already routes MCP requests under OBO -
706
+ // no bespoke route needed. See `./mcp.js`.
707
+ this.mcp = buildMcpServer({
708
+ config: this.config,
709
+ pluginName: this.name,
710
+ displayName: MastraPlugin.manifest.displayName,
711
+ agents: this.built.agents,
712
+ ambientTools: this.built.ambientTools,
713
+ });
714
+ this.mastra = new Mastra({
715
+ agents: this.built.agents,
716
+ ...(instanceStorage ? { storage: instanceStorage } : {}),
717
+ ...(observability ? { observability } : {}),
718
+ ...(this.mcp ? { mcpServers: { [this.mcp.serverId]: this.mcp.server } } : {}),
719
+ });
720
+ this.mastraApp = express();
721
+ attachRoutePatchMiddleware(this.mastraApp);
722
+ this.mastraServer = new MastraServer(this.config, {
723
+ app: this.mastraApp,
724
+ mastra: this.mastra,
725
+ prefix: "",
726
+ customApiRoutes: [
727
+ // `historyRoute` registers both GET (load) and DELETE
728
+ // (clear) on the same path, so it returns an array we
729
+ // splice in.
730
+ ...historyRoute({
731
+ path: routes.MASTRA_ROUTES.history,
732
+ agent: this.built.defaultAgentId,
733
+ }),
734
+ // Assert the `:agentId` template type: the per-package build's
735
+ // NodeNext resolution widens the imported `routes.MASTRA_ROUTES.history`
736
+ // to `string` (the source/bundler typecheck keeps it a literal),
737
+ // which would otherwise drop this out of the dynamic-agent
738
+ // overload and demand a fixed `agent`.
739
+ ...historyRoute({
740
+ path: `${routes.MASTRA_ROUTES.history}/:agentId` as `${string}:agentId`,
741
+ }),
742
+ // `threadsRoute` registers GET (list the caller's conversation
743
+ // threads) and DELETE (remove the targeted thread) on the same
744
+ // path; both the default-agent and dynamic-agent mounts are
745
+ // spliced in, mirroring the history routes above.
746
+ ...threadsRoute({
747
+ path: routes.MASTRA_ROUTES.threads,
748
+ agent: this.built.defaultAgentId,
749
+ }),
750
+ ...threadsRoute({
751
+ path: `${routes.MASTRA_ROUTES.threads}/:agentId` as `${string}:agentId`,
752
+ }),
753
+ ],
754
+ });
755
+ await this.mastraServer.init();
756
+ this.logger.debug("build:done", {
757
+ agents: Object.keys(this.built.agents),
758
+ defaultAgent: this.built.defaultAgentId,
759
+ routes: [
760
+ "/route/history",
761
+ "/route/threads",
762
+ "/models",
763
+ "/default-model",
764
+ "/suggestions",
765
+ "/route/feedback",
766
+ "/embed/:type/:id",
767
+ ],
768
+ instanceStorage: instanceStorage !== undefined,
769
+ observability: observability !== undefined ? "mlflow" : "off",
770
+ mcp: this.mcp ? `/api/${this.name}${this.mcp.httpPath}` : "off",
771
+ });
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Resolver for one embed `<type>` behind the generic
777
+ * `GET /embed/:type/:id` route. Returns the JSON body to send on
778
+ * success, or `undefined` to signal a 404 (unknown / expired id).
779
+ * `signal` aborts when the client disconnects so long-polling
780
+ * resolvers (e.g. `chart`) unblock immediately.
781
+ */
782
+ type EmbedResolver = (
783
+ req: express.Request,
784
+ id: string,
785
+ signal: AbortSignal,
786
+ ) => Promise<unknown | undefined>;
787
+
788
+ /**
789
+ * Parse the optional `?timeoutMs=<n>` query parameter from a
790
+ * `GET /embed/chart/:id` request. Accepts a positive integer up
791
+ * to 5 minutes (clamped) and rejects everything else as
792
+ * `undefined` so {@link fetchChart} falls back to its default.
793
+ * Express produces `string | string[] | undefined`; we normalize
794
+ * to the first scalar before parsing.
795
+ */
796
+ function parseTimeoutMs(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.min(Math.floor(n), 5 * 60_000);
802
+ }
803
+
804
+ /**
805
+ * Parse the optional `?limit=<n>` query parameter from a
806
+ * `GET /embed/data/:id` request. Accepts a non-negative
807
+ * integer and lets the route clamp to `STATEMENT_ROW_CAP`;
808
+ * rejects anything else as `undefined` so the route falls back
809
+ * to the server-side cap.
810
+ */
811
+ function parseStatementLimit(raw: unknown): number | undefined {
812
+ const v = Array.isArray(raw) ? raw[0] : raw;
813
+ if (typeof v !== "string") return undefined;
814
+ const n = Number(v);
815
+ if (!Number.isFinite(n) || n < 0) return undefined;
816
+ return Math.floor(n);
817
+ }
818
+
819
+ export const mastra = toPlugin(MastraPlugin);