@dbx-tools/appkit-mastra 0.1.74 → 0.1.75

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/README.md CHANGED
@@ -13,10 +13,14 @@ is a one-line import change.
13
13
 
14
14
  The implementation lives under `src/`: agent registration in
15
15
  [`agents.ts`](src/agents.ts), the plugin + routes in
16
- [`plugin.ts`](src/plugin.ts) / [`server.ts`](src/server.ts), Model
17
- Serving resolution in [`model.ts`](src/model.ts) / [`serving.ts`](src/serving.ts),
18
- Genie tooling in [`genie.ts`](src/genie.ts), and the chart pipeline in
19
- [`chart.ts`](src/chart.ts).
16
+ [`plugin.ts`](src/plugin.ts) / [`server.ts`](src/server.ts), thread
17
+ history / listing in [`history.ts`](src/history.ts) /
18
+ [`threads.ts`](src/threads.ts), Model Serving resolution in
19
+ [`model.ts`](src/model.ts) / [`serving.ts`](src/serving.ts), Genie
20
+ tooling in [`genie.ts`](src/genie.ts), the chart pipeline in
21
+ [`chart.ts`](src/chart.ts), and MLflow feedback logging in
22
+ [`mlflow.ts`](src/mlflow.ts) (over the shared REST helper in
23
+ [`rest.ts`](src/rest.ts)).
20
24
 
21
25
  ## Quick start
22
26
 
@@ -267,6 +271,35 @@ entry settles. Cache entries carry a 1h TTL; after expiry the route
267
271
  placement contract are in [`chart.ts`](src/chart.ts); the wire shapes
268
272
  are in [`@dbx-tools/appkit-mastra-shared`](../appkit-mastra-shared).
269
273
 
274
+ ## Feedback (MLflow)
275
+
276
+ When MLflow tracing is wired for the deployment, the plugin lets users
277
+ rate an assistant turn (thumbs up / down) and leave a comment, logged as
278
+ a HUMAN [assessment](https://mlflow.org/docs/latest/genai/assessments/)
279
+ on that turn's trace and attributed to the signed-in (OBO) user.
280
+
281
+ Enablement is auto-detected: it turns on when an OTLP exporter endpoint
282
+ (`OTEL_EXPORTER_OTLP_ENDPOINT` / `..._TRACES_ENDPOINT`) **and** an MLflow
283
+ experiment (`MLFLOW_EXPERIMENT_ID` / `MLFLOW_EXPERIMENT_NAME`) are
284
+ configured - the two signals that traces actually materialize in MLflow.
285
+ Set `config.feedback` to `true` / `false` to force it on or off
286
+ regardless of the env.
287
+
288
+ ```ts
289
+ mastra({ agents: support, feedback: true }); // force-enable
290
+ ```
291
+
292
+ Under the hood the server derives MLflow's trace id from the active
293
+ OpenTelemetry span (`tr-<hex(otelTraceId)>`) and stamps it on each
294
+ turn's response (`MLFLOW_TRACE_ID_HEADER`); the client sends it back to
295
+ `POST ${basePath}/route/feedback`, which posts the assessment to the
296
+ Databricks MLflow REST API. Trace export is asynchronous, so a
297
+ just-finished trace may not exist yet - the log call retries briefly on
298
+ "not found" and otherwise fails softly (the chat stays usable). The
299
+ current state is published to the client as
300
+ `clientConfig().feedbackEnabled`; see [`mlflow.ts`](src/mlflow.ts) and
301
+ the UI wiring in [`@dbx-tools/appkit-mastra-ui`](../appkit-mastra-ui).
302
+
270
303
  ## Model resolution
271
304
 
272
305
  Each agent call resolves a model lazily (so concurrent requests keep
@@ -338,6 +371,36 @@ resolves its model and tools as the calling user. The resolved endpoint
338
371
  paths are available in-process via
339
372
  `appkitUtils.require(this.context, mastra).exports().getMcp()`.
340
373
 
374
+ ## Client API surface (`apiAccess`)
375
+
376
+ `@mastra/express` registers its full management route table under the
377
+ plugin mount - agent inference *plus* admin / mutating routes (direct
378
+ tool execution, workflow control, raw memory read/write, telemetry,
379
+ logs, scores). AppKit authenticates every request as the OBO user, but
380
+ does not restrict *which* of those a browser client may call.
381
+
382
+ `config.apiAccess` gates that surface at the plugin's dispatch point:
383
+
384
+ - `"scoped"` (default): only the routes the chat client needs are
385
+ dispatched to Mastra - agent inference (`stream` / `generate` /
386
+ `network`), read-only agent metadata (`GET /agents`,
387
+ `GET /agents/:id`), this plugin's own OBO- and resource-scoped
388
+ `/route/*` routes (history / threads / feedback), and, when `mcp` is
389
+ enabled, the MCP transport. Everything else is refused with `403`
390
+ before it reaches Mastra.
391
+ - `"full"`: dispatch the entire stock Mastra API. Use only for a trusted
392
+ first-party console that genuinely needs the management surface.
393
+
394
+ ```ts
395
+ mastra({ agents: support }); // scoped by default
396
+ mastra({ agents: support, apiAccess: "full" }); // opt into the full API
397
+ ```
398
+
399
+ The gate is a pure allowlist (`isMastraRequestAllowed` in
400
+ [`server.ts`](src/server.ts)); the browser chat client only ever uses
401
+ the agent stream and the `/route/*` routes, so the default breaks
402
+ nothing.
403
+
341
404
  ## License
342
405
 
343
406
  Apache-2.0
package/dist/index.d.ts CHANGED
@@ -642,6 +642,28 @@ interface MastraPluginConfig extends BasePluginConfig {
642
642
  * calling user.
643
643
  */
644
644
  mcp?: boolean | MastraMcpConfig;
645
+ /**
646
+ * How much of the stock `@mastra/express` management API is reachable
647
+ * through the plugin mount. `@mastra/express` registers its full route
648
+ * table (agent inference plus admin / mutating routes: direct tool
649
+ * execution, workflow control, raw memory read/write, telemetry, logs,
650
+ * scores). AppKit already authenticates every request as the OBO user,
651
+ * but nothing there restricts *which* of those operations the browser
652
+ * client may invoke.
653
+ *
654
+ * - `"scoped"` (default): only the routes the chat client legitimately
655
+ * needs are dispatched to Mastra - agent inference
656
+ * (`stream` / `generate` / `network`), read-only agent metadata, this
657
+ * plugin's own OBO- and resource-scoped `/route/*` routes (history /
658
+ * threads), and, when {@link mcp} is enabled, the MCP transport.
659
+ * Everything else (tool execution, workflow control, raw memory,
660
+ * telemetry, logs, scores, and other mutations) is rejected with
661
+ * `403` before it reaches Mastra.
662
+ * - `"full"`: dispatch the entire stock Mastra API. Use only for a
663
+ * trusted first-party console that genuinely needs the management
664
+ * surface.
665
+ */
666
+ apiAccess?: "scoped" | "full";
645
667
  }
646
668
  //#endregion
647
669
  //#region packages/appkit-mastra/src/memory.d.ts
@@ -1310,13 +1332,17 @@ declare function buildMcpServer(opts: {
1310
1332
  }): ResolvedMcp | null;
1311
1333
  //#endregion
1312
1334
  //#region packages/appkit-mastra/src/server.d.ts
1335
+ /**
1336
+ * `@mastra/express` subclass that stamps `RequestContext` with the
1337
+ * AppKit user, resource id, and a thread id backed by an HTTP-only
1338
+ * session cookie (`appkit_<plugin-name>_session_id`).
1339
+ */
1313
1340
  declare class MastraServer$1 extends MastraServer {
1314
1341
  private config;
1315
1342
  private log;
1316
1343
  /**
1317
- * Whether to stamp the MLflow trace-id header on responses. Mirrors
1318
- * the plugin's feedback gate: the explicit `config.feedback` override
1319
- * wins, else auto-detect from the MLflow tracing env.
1344
+ * Whether to stamp the MLflow trace-id header on responses. Shares the
1345
+ * plugin's feedback gate via {@link resolveFeedbackEnabled}.
1320
1346
  */
1321
1347
  private feedbackEnabled;
1322
1348
  constructor(config: MastraPluginConfig, ...args: ConstructorParameters<typeof MastraServer>);
@@ -1486,10 +1512,9 @@ declare class MastraPlugin extends Plugin<MastraPluginConfig> {
1486
1512
  };
1487
1513
  clientConfig(): Record<string, unknown>;
1488
1514
  /**
1489
- * Whether user feedback can be logged to MLflow. The explicit
1490
- * `config.feedback` override wins; otherwise auto-detect from the
1491
- * MLflow tracing env ({@link mlflowEnabled}). Gates both the client
1492
- * config flag and the feedback route so the two never disagree.
1515
+ * Whether user feedback can be logged to MLflow. Delegates to
1516
+ * {@link resolveFeedbackEnabled} so the client-config flag and the
1517
+ * feedback route share the same gate as the server's trace-id header.
1493
1518
  */
1494
1519
  private feedbackEnabled;
1495
1520
  injectRoutes(router: IAppRouter): void;
package/dist/index.js CHANGED
@@ -849,8 +849,63 @@ function buildRenderDataTool(config) {
849
849
  });
850
850
  }
851
851
 
852
+ //#endregion
853
+ //#region packages/appkit-mastra/src/rest.ts
854
+ /**
855
+ * Resolve the workspace host + an authenticated header set off the
856
+ * client and issue a `fetch` against `path` (mounted on the host).
857
+ * Runs as whatever identity the client carries - the per-request OBO
858
+ * user when called from a request scope, the service principal
859
+ * otherwise.
860
+ */
861
+ async function databricksFetch(client, path, init) {
862
+ const host = (await client.config.getHost()).toString();
863
+ const headers = new Headers({
864
+ "Content-Type": "application/json",
865
+ ...init.headers
866
+ });
867
+ await client.config.authenticate(headers);
868
+ const url = new URL(path, host).toString();
869
+ return fetch(url, {
870
+ method: init.method,
871
+ headers,
872
+ ...init.body !== void 0 ? { body: JSON.stringify(init.body) } : {},
873
+ ...init.signal ? { signal: init.signal } : {}
874
+ });
875
+ }
876
+ /** Read a response body as text, swallowing read errors (returns `""`). */
877
+ async function readResponseText(res) {
878
+ try {
879
+ return await res.text();
880
+ } catch {
881
+ return "";
882
+ }
883
+ }
884
+ /** Parse a response body as JSON, returning `{}` on empty / invalid bodies. */
885
+ async function readResponseJson(res) {
886
+ const text = await readResponseText(res);
887
+ if (!text) return {};
888
+ try {
889
+ return JSON.parse(text);
890
+ } catch {
891
+ return {};
892
+ }
893
+ }
894
+
852
895
  //#endregion
853
896
  //#region packages/appkit-mastra/src/connectors/managed-memory/client.ts
897
+ /**
898
+ * Thin REST client over the Databricks Unity Catalog Managed Agent
899
+ * Memory API (Beta). There is no TS SDK for memory-stores yet, so this
900
+ * issues authenticated requests through the shared {@link databricksFetch}
901
+ * primitive (`rest.ts`) as whatever identity the caller's client carries
902
+ * - the per-request OBO user for tools / recall, the service principal
903
+ * for setup-time probes.
904
+ *
905
+ * Responses are parsed defensively: the Beta wire shape may still drift,
906
+ * so the search parser tolerates missing / renamed entry fields and the
907
+ * store probe maps a 404 to `null` rather than throwing.
908
+ */
854
909
  /** Base path for the Unity Catalog memory-stores REST surface. */
855
910
  const MEMORY_STORES_PATH = "/api/2.1/unity-catalog/memory-stores";
856
911
  /**
@@ -883,32 +938,14 @@ function parseStoreName(fullName) {
883
938
  };
884
939
  }
885
940
  /**
886
- * Resolve the workspace host and an authenticated header set off the
887
- * client, then issue a `fetch`. Returns the raw `Response` so callers
888
- * decide how to handle status codes (the store probe wants the 404, the
889
- * mutating calls want a throw).
890
- */
891
- async function rawFetch(client, path, init) {
892
- const host = (await client.config.getHost()).toString();
893
- const headers = new Headers({ "Content-Type": "application/json" });
894
- await client.config.authenticate(headers);
895
- const url = new URL(path, host).toString();
896
- return fetch(url, {
897
- method: init.method,
898
- headers,
899
- ...init.body !== void 0 ? { body: JSON.stringify(init.body) } : {},
900
- ...init.signal ? { signal: init.signal } : {}
901
- });
902
- }
903
- /**
904
941
  * Issue a request and parse the JSON body, throwing
905
942
  * {@link ManagedMemoryError} on any non-2xx status. Used by the calls
906
943
  * where anything but success is a real error (create / write / search).
907
944
  */
908
945
  async function requestJson(client, path, init) {
909
- const res = await rawFetch(client, path, init);
910
- if (!res.ok) throw new ManagedMemoryError(res.status, res.statusText, await safeText$1(res));
911
- return safeJson$1(res);
946
+ const res = await databricksFetch(client, path, init);
947
+ if (!res.ok) throw new ManagedMemoryError(res.status, res.statusText, await readResponseText(res));
948
+ return readResponseJson(res);
912
949
  }
913
950
  /**
914
951
  * Fetch a store by full name. Returns the raw store payload when it
@@ -917,13 +954,13 @@ async function requestJson(client, path, init) {
917
954
  * Any other non-2xx status throws.
918
955
  */
919
956
  async function getStore(client, fullName, signal) {
920
- const res = await rawFetch(client, `${MEMORY_STORES_PATH}/${fullName}`, {
957
+ const res = await databricksFetch(client, `${MEMORY_STORES_PATH}/${fullName}`, {
921
958
  method: "GET",
922
959
  ...signal ? { signal } : {}
923
960
  });
924
961
  if (res.status === 404) return null;
925
- if (!res.ok) throw new ManagedMemoryError(res.status, res.statusText, await safeText$1(res));
926
- return safeJson$1(res);
962
+ if (!res.ok) throw new ManagedMemoryError(res.status, res.statusText, await readResponseText(res));
963
+ return readResponseJson(res);
927
964
  }
928
965
  /**
929
966
  * Probe for the store and create it when missing. Returns `true` when
@@ -990,10 +1027,14 @@ function parseEntries(body) {
990
1027
  for (const raw of list) {
991
1028
  if (!raw || typeof raw !== "object") continue;
992
1029
  const obj = raw;
993
- const contents = firstString(obj["contents"], obj["content"], obj["text"]);
1030
+ const contents = stringUtils.firstNonEmpty([
1031
+ obj["contents"],
1032
+ obj["content"],
1033
+ obj["text"]
1034
+ ]);
994
1035
  if (!contents) continue;
995
- const path = firstString(obj["path"]);
996
- const description = firstString(obj["description"], obj["summary"]);
1036
+ const path = stringUtils.firstNonEmpty(obj["path"]);
1037
+ const description = stringUtils.firstNonEmpty([obj["description"], obj["summary"]]);
997
1038
  const score = typeof obj["score"] === "number" ? obj["score"] : void 0;
998
1039
  out.push({
999
1040
  contents,
@@ -1004,28 +1045,6 @@ function parseEntries(body) {
1004
1045
  }
1005
1046
  return out;
1006
1047
  }
1007
- /** Return the first argument that is a non-empty string, else undefined. */
1008
- function firstString(...values) {
1009
- for (const v of values) if (typeof v === "string" && v.trim() !== "") return v;
1010
- }
1011
- /** Parse a response body as JSON, returning `{}` on empty / invalid bodies. */
1012
- async function safeJson$1(res) {
1013
- const text = await safeText$1(res);
1014
- if (!text) return {};
1015
- try {
1016
- return JSON.parse(text);
1017
- } catch {
1018
- return {};
1019
- }
1020
- }
1021
- /** Read a response body as text, swallowing read errors. */
1022
- async function safeText$1(res) {
1023
- try {
1024
- return await res.text();
1025
- } catch {
1026
- return "";
1027
- }
1028
- }
1029
1048
 
1030
1049
  //#endregion
1031
1050
  //#region packages/appkit-mastra/src/connectors/managed-memory/context.ts
@@ -2898,6 +2917,27 @@ function buildMcpServer(opts) {
2898
2917
  };
2899
2918
  }
2900
2919
 
2920
+ //#endregion
2921
+ //#region packages/appkit-mastra/src/pagination.ts
2922
+ /** Coerce / clamp a `perPage` value, falling back to `bounds.fallback`. */
2923
+ function clampPerPage(value, bounds) {
2924
+ if (value === void 0 || Number.isNaN(value)) return bounds.fallback;
2925
+ const n = Math.trunc(value);
2926
+ if (n <= 0) return bounds.fallback;
2927
+ return Math.min(n, bounds.max);
2928
+ }
2929
+ /**
2930
+ * Coerce a Hono query value into a non-negative integer. Returns
2931
+ * `undefined` for empty / non-numeric / negative inputs so the caller
2932
+ * can apply its built-in defaults.
2933
+ */
2934
+ function parseIntParam(value) {
2935
+ if (!value) return void 0;
2936
+ const n = Number(value);
2937
+ if (!Number.isFinite(n) || n < 0) return void 0;
2938
+ return Math.trunc(n);
2939
+ }
2940
+
2901
2941
  //#endregion
2902
2942
  //#region packages/appkit-mastra/src/history.ts
2903
2943
  const log$4 = logUtils.logger("mastra/history");
@@ -2921,7 +2961,10 @@ const MAX_PER_PAGE$1 = 200;
2921
2961
  * without sorting locally.
2922
2962
  */
2923
2963
  async function loadHistory(opts) {
2924
- const perPage = clampPerPage$1(opts.perPage);
2964
+ const perPage = clampPerPage(opts.perPage, {
2965
+ fallback: DEFAULT_PER_PAGE$1,
2966
+ max: MAX_PER_PAGE$1
2967
+ });
2925
2968
  const page = Math.max(0, Math.trunc(opts.page ?? 0));
2926
2969
  const memory = await opts.agent.getMemory();
2927
2970
  if (!memory) {
@@ -3070,8 +3113,8 @@ function historyRoute(options) {
3070
3113
  agent: ctx.agent,
3071
3114
  threadId: ctx.threadId,
3072
3115
  ...ctx.resourceId ? { resourceId: ctx.resourceId } : {},
3073
- page: parseIntParam$1(c.req.query("page")),
3074
- perPage: parseIntParam$1(c.req.query("perPage"))
3116
+ page: parseIntParam(c.req.query("page")),
3117
+ perPage: parseIntParam(c.req.query("perPage"))
3075
3118
  });
3076
3119
  return c.json(payload);
3077
3120
  }
@@ -3094,13 +3137,6 @@ function historyRoute(options) {
3094
3137
  }
3095
3138
  })];
3096
3139
  }
3097
- /** Coerce / clamp `perPage`; falls back to the page-size default. */
3098
- function clampPerPage$1(value) {
3099
- if (value === void 0 || Number.isNaN(value)) return DEFAULT_PER_PAGE$1;
3100
- const n = Math.trunc(value);
3101
- if (n <= 0) return DEFAULT_PER_PAGE$1;
3102
- return Math.min(n, MAX_PER_PAGE$1);
3103
- }
3104
3140
  /**
3105
3141
  * Sort messages oldest-first by `createdAt`, falling back to whatever
3106
3142
  * order the storage returned them in. The native `recall` call honors
@@ -3120,155 +3156,6 @@ function toEpoch(value) {
3120
3156
  }
3121
3157
  return 0;
3122
3158
  }
3123
- /**
3124
- * Coerce a Hono query value into a non-negative integer. Returns
3125
- * `undefined` for empty / non-numeric / negative inputs so
3126
- * {@link loadHistory} can apply its built-in defaults.
3127
- */
3128
- function parseIntParam$1(value) {
3129
- if (!value) return void 0;
3130
- const n = Number(value);
3131
- if (!Number.isFinite(n) || n < 0) return void 0;
3132
- return Math.trunc(n);
3133
- }
3134
-
3135
- //#endregion
3136
- //#region packages/appkit-mastra/src/rest.ts
3137
- /**
3138
- * Resolve the workspace host + an authenticated header set off the
3139
- * client and issue a `fetch` against `path` (mounted on the host).
3140
- * Runs as whatever identity the client carries - the per-request OBO
3141
- * user when called from a request scope, the service principal
3142
- * otherwise.
3143
- */
3144
- async function databricksFetch(client, path, init) {
3145
- const host = (await client.config.getHost()).toString();
3146
- const headers = new Headers({
3147
- "Content-Type": "application/json",
3148
- ...init.headers
3149
- });
3150
- await client.config.authenticate(headers);
3151
- const url = new URL(path, host).toString();
3152
- return fetch(url, {
3153
- method: init.method,
3154
- headers,
3155
- ...init.body !== void 0 ? { body: JSON.stringify(init.body) } : {},
3156
- ...init.signal ? { signal: init.signal } : {}
3157
- });
3158
- }
3159
-
3160
- //#endregion
3161
- //#region packages/appkit-mastra/src/mlflow.ts
3162
- /**
3163
- * MLflow user-feedback logging: detect whether MLflow tracing is wired
3164
- * for this deployment, and log a thumbs / comment as a trace
3165
- * *assessment* via the Databricks MLflow REST API.
3166
- *
3167
- * Feedback attaches to a trace, and the plugin's spans reach MLflow
3168
- * through the same OTel pipeline as every other AppKit span (see
3169
- * `observability.ts`). MLflow derives its trace id from the OpenTelemetry
3170
- * trace id (`tr-<hex(otelTraceId)>`), so the server stamps the active
3171
- * trace id on each turn's response and the client sends it back here.
3172
- *
3173
- * There is no MLflow JS SDK, so this posts to the assessments REST
3174
- * endpoint directly using the OBO-scoped workspace client (the feedback
3175
- * is thus attributed to the signed-in user). Trace export is
3176
- * asynchronous, so the just-finished trace may not exist in MLflow yet
3177
- * when the user reacts; the log call retries briefly on "not found"
3178
- * before giving up softly.
3179
- */
3180
- const log$3 = logUtils.logger("mastra/mlflow");
3181
- /** Assessments REST path for a trace. `3.0` is the current MLflow API version. */
3182
- const assessmentsPath = (traceId) => `/api/3.0/mlflow/traces/${encodeURIComponent(traceId)}/assessments`;
3183
- /** Number of times to retry a "trace not found" response before giving up. */
3184
- const NOT_FOUND_RETRIES = 3;
3185
- /** Base backoff between "trace not found" retries, in ms (grows linearly). */
3186
- const NOT_FOUND_BACKOFF_MS = 1200;
3187
- /**
3188
- * Whether MLflow feedback logging is available for this deployment.
3189
- *
3190
- * Enabled when an OTLP exporter endpoint is configured (traces are
3191
- * actually shipped somewhere) AND an MLflow experiment is named - the
3192
- * two signals that the OTLP backend is MLflow and traces will
3193
- * materialize there. Both are standard env vars, so no plugin config is
3194
- * required; a deployment opts in simply by wiring MLflow tracing.
3195
- */
3196
- function mlflowEnabled() {
3197
- const hasExporter = Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() || process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim());
3198
- const hasExperiment = Boolean(process.env.MLFLOW_EXPERIMENT_ID?.trim() || process.env.MLFLOW_EXPERIMENT_NAME?.trim());
3199
- return hasExporter && hasExperiment;
3200
- }
3201
- /**
3202
- * Log a HUMAN feedback assessment to a trace. Returns the created
3203
- * assessment id on success, or `undefined` when the trace can't be
3204
- * found (even after retrying for export lag) or the request otherwise
3205
- * fails - callers surface that as a soft "not recorded" rather than an
3206
- * error, keeping the chat usable.
3207
- */
3208
- async function logFeedback(client, params) {
3209
- const hasValue = params.value !== void 0;
3210
- const name = params.name?.trim() || (hasValue ? DEFAULT_FEEDBACK_NAME : DEFAULT_COMMENT_NAME);
3211
- const value = hasValue ? params.value : params.comment;
3212
- const body = { assessment: {
3213
- trace_id: params.traceId,
3214
- assessment_name: name,
3215
- source: {
3216
- source_type: "HUMAN",
3217
- source_id: params.sourceId?.trim() || "user"
3218
- },
3219
- feedback: { value },
3220
- ...hasValue && params.comment?.trim() ? { rationale: params.comment } : {}
3221
- } };
3222
- for (let attempt = 0; attempt <= NOT_FOUND_RETRIES; attempt++) {
3223
- let res;
3224
- try {
3225
- res = await databricksFetch(client, assessmentsPath(params.traceId), {
3226
- method: "POST",
3227
- body
3228
- });
3229
- } catch (err) {
3230
- log$3.warn("feedback request failed", {
3231
- traceId: params.traceId,
3232
- error: commonUtils.errorMessage(err)
3233
- });
3234
- return;
3235
- }
3236
- if (res.ok) {
3237
- const parsed = await safeJson(res);
3238
- const assessmentId = parsed?.assessment?.assessment_id ?? parsed?.assessment_id;
3239
- return typeof assessmentId === "string" ? assessmentId : "";
3240
- }
3241
- if (res.status === 404 && attempt < NOT_FOUND_RETRIES) {
3242
- await delay(NOT_FOUND_BACKOFF_MS * (attempt + 1));
3243
- continue;
3244
- }
3245
- log$3.warn("feedback not recorded", {
3246
- traceId: params.traceId,
3247
- status: res.status,
3248
- body: await safeText(res)
3249
- });
3250
- return;
3251
- }
3252
- }
3253
- function delay(ms) {
3254
- return new Promise((resolve) => setTimeout(resolve, ms));
3255
- }
3256
- async function safeJson(res) {
3257
- const text = await safeText(res);
3258
- if (!text) return {};
3259
- try {
3260
- return JSON.parse(text);
3261
- } catch {
3262
- return {};
3263
- }
3264
- }
3265
- async function safeText(res) {
3266
- try {
3267
- return await res.text();
3268
- } catch {
3269
- return "";
3270
- }
3271
- }
3272
3159
 
3273
3160
  //#endregion
3274
3161
  //#region packages/appkit-mastra/src/memory.ts
@@ -3300,7 +3187,7 @@ async function safeText(res) {
3300
3187
  * (auto-defaulted to `true` in `plugin.ts` when the `lakebase` plugin
3301
3188
  * is registered); per-agent settings cascade on top of that.
3302
3189
  */
3303
- const log$2 = logUtils.logger("mastra/memory");
3190
+ const log$3 = logUtils.logger("mastra/memory");
3304
3191
  /**
3305
3192
  * Build a dedicated **service-principal** Lakebase pool for Mastra
3306
3193
  * memory from the lakebase plugin's resolved SP pg config.
@@ -3392,10 +3279,10 @@ var MemoryBuilder = class {
3392
3279
  const storage = this.buildStorage(agentId, storageSetting);
3393
3280
  const vector = this.buildVector(memorySetting);
3394
3281
  if (!storage && !vector) {
3395
- log$2.debug("agent:stateless", { agentId });
3282
+ log$3.debug("agent:stateless", { agentId });
3396
3283
  return;
3397
3284
  }
3398
- log$2.debug("agent:configured", {
3285
+ log$3.debug("agent:configured", {
3399
3286
  agentId,
3400
3287
  storage: storage !== void 0,
3401
3288
  vector: vector !== void 0,
@@ -3507,6 +3394,110 @@ function withId(value, fallback) {
3507
3394
  };
3508
3395
  }
3509
3396
 
3397
+ //#endregion
3398
+ //#region packages/appkit-mastra/src/mlflow.ts
3399
+ /**
3400
+ * MLflow user-feedback logging: detect whether MLflow tracing is wired
3401
+ * for this deployment, and log a thumbs / comment as a trace
3402
+ * *assessment* via the Databricks MLflow REST API.
3403
+ *
3404
+ * Feedback attaches to a trace, and the plugin's spans reach MLflow
3405
+ * through the same OTel pipeline as every other AppKit span (see
3406
+ * `observability.ts`). MLflow derives its trace id from the OpenTelemetry
3407
+ * trace id (`tr-<hex(otelTraceId)>`), so the server stamps the active
3408
+ * trace id on each turn's response and the client sends it back here.
3409
+ *
3410
+ * There is no MLflow JS SDK, so this posts to the assessments REST
3411
+ * endpoint directly using the OBO-scoped workspace client (the feedback
3412
+ * is thus attributed to the signed-in user). Trace export is
3413
+ * asynchronous, so the just-finished trace may not exist in MLflow yet
3414
+ * when the user reacts; the log call retries briefly on "not found"
3415
+ * before giving up softly.
3416
+ */
3417
+ const log$2 = logUtils.logger("mastra/mlflow");
3418
+ /** Assessments REST path for a trace. `3.0` is the current MLflow API version. */
3419
+ const assessmentsPath = (traceId) => `/api/3.0/mlflow/traces/${encodeURIComponent(traceId)}/assessments`;
3420
+ /** Number of times to retry a "trace not found" response before giving up. */
3421
+ const NOT_FOUND_RETRIES = 3;
3422
+ /** Base backoff between "trace not found" retries, in ms (grows linearly). */
3423
+ const NOT_FOUND_BACKOFF_MS = 1200;
3424
+ /**
3425
+ * Whether MLflow feedback logging is available for this deployment.
3426
+ *
3427
+ * Enabled when an OTLP exporter endpoint is configured (traces are
3428
+ * actually shipped somewhere) AND an MLflow experiment is named - the
3429
+ * two signals that the OTLP backend is MLflow and traces will
3430
+ * materialize there. Both are standard env vars, so no plugin config is
3431
+ * required; a deployment opts in simply by wiring MLflow tracing.
3432
+ */
3433
+ function mlflowEnabled() {
3434
+ const hasExporter = Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() || process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim());
3435
+ const hasExperiment = Boolean(process.env.MLFLOW_EXPERIMENT_ID?.trim() || process.env.MLFLOW_EXPERIMENT_NAME?.trim());
3436
+ return hasExporter && hasExperiment;
3437
+ }
3438
+ /**
3439
+ * Resolve whether user feedback is enabled from the plugin's optional
3440
+ * `config.feedback` override: the explicit boolean wins, otherwise fall
3441
+ * back to auto-detecting MLflow tracing ({@link mlflowEnabled}). Shared
3442
+ * by the plugin's client-config gate and the server's trace-id header so
3443
+ * the two never disagree.
3444
+ */
3445
+ function resolveFeedbackEnabled(explicit) {
3446
+ return explicit ?? mlflowEnabled();
3447
+ }
3448
+ /**
3449
+ * Log a HUMAN feedback assessment to a trace. Returns the created
3450
+ * assessment id on success, or `undefined` when the trace can't be
3451
+ * found (even after retrying for export lag) or the request otherwise
3452
+ * fails - callers surface that as a soft "not recorded" rather than an
3453
+ * error, keeping the chat usable.
3454
+ */
3455
+ async function logFeedback(client, params) {
3456
+ const hasValue = params.value !== void 0;
3457
+ const name = params.name?.trim() || (hasValue ? DEFAULT_FEEDBACK_NAME : DEFAULT_COMMENT_NAME);
3458
+ const value = hasValue ? params.value : params.comment;
3459
+ const body = { assessment: {
3460
+ trace_id: params.traceId,
3461
+ assessment_name: name,
3462
+ source: {
3463
+ source_type: "HUMAN",
3464
+ source_id: params.sourceId?.trim() || "user"
3465
+ },
3466
+ feedback: { value },
3467
+ ...hasValue && params.comment?.trim() ? { rationale: params.comment } : {}
3468
+ } };
3469
+ for (let attempt = 0; attempt <= NOT_FOUND_RETRIES; attempt++) {
3470
+ let res;
3471
+ try {
3472
+ res = await databricksFetch(client, assessmentsPath(params.traceId), {
3473
+ method: "POST",
3474
+ body
3475
+ });
3476
+ } catch (err) {
3477
+ log$2.warn("feedback request failed", {
3478
+ traceId: params.traceId,
3479
+ error: commonUtils.errorMessage(err)
3480
+ });
3481
+ return;
3482
+ }
3483
+ if (res.ok) {
3484
+ const parsed = await readResponseJson(res);
3485
+ const assessmentId = parsed?.assessment?.assessment_id ?? parsed?.assessment_id;
3486
+ return typeof assessmentId === "string" ? assessmentId : "";
3487
+ }
3488
+ if (res.status === 404 && attempt < NOT_FOUND_RETRIES) {
3489
+ await commonUtils.sleep(NOT_FOUND_BACKOFF_MS * (attempt + 1));
3490
+ continue;
3491
+ }
3492
+ log$2.warn("feedback not recorded", {
3493
+ traceId: params.traceId,
3494
+ status: res.status,
3495
+ body: await readResponseText(res)
3496
+ });
3497
+ return;
3498
+ }
3499
+ }
3500
+
3510
3501
  //#endregion
3511
3502
  //#region packages/appkit-mastra/src/observability.ts
3512
3503
  /**
@@ -3581,30 +3572,29 @@ async function buildObservability(options) {
3581
3572
  * behind an Express mount point.
3582
3573
  */
3583
3574
  /**
3584
- * `@mastra/express` subclass that stamps `RequestContext` with the
3585
- * AppKit user, resource id, and a thread id backed by an HTTP-only
3586
- * session cookie (`appkit_<plugin-name>_session_id`).
3587
- */
3588
- /**
3589
3575
  * OpenTelemetry's sentinel for "no valid trace" - 32 zero hex chars.
3590
3576
  * `trace.getActiveSpan()` returns a non-recording span with this id
3591
3577
  * when no SDK is registered, which must never be surfaced as a trace to
3592
3578
  * attach feedback to.
3593
3579
  */
3594
3580
  const INVALID_TRACE_ID = "0".repeat(32);
3581
+ /**
3582
+ * `@mastra/express` subclass that stamps `RequestContext` with the
3583
+ * AppKit user, resource id, and a thread id backed by an HTTP-only
3584
+ * session cookie (`appkit_<plugin-name>_session_id`).
3585
+ */
3595
3586
  var MastraServer$1 = class extends MastraServer {
3596
3587
  log;
3597
3588
  /**
3598
- * Whether to stamp the MLflow trace-id header on responses. Mirrors
3599
- * the plugin's feedback gate: the explicit `config.feedback` override
3600
- * wins, else auto-detect from the MLflow tracing env.
3589
+ * Whether to stamp the MLflow trace-id header on responses. Shares the
3590
+ * plugin's feedback gate via {@link resolveFeedbackEnabled}.
3601
3591
  */
3602
3592
  feedbackEnabled;
3603
3593
  constructor(config, ...args) {
3604
3594
  super(...args);
3605
3595
  this.config = config;
3606
3596
  this.log = logUtils.logger(config);
3607
- this.feedbackEnabled = config.feedback ?? mlflowEnabled();
3597
+ this.feedbackEnabled = resolveFeedbackEnabled(config.feedback);
3608
3598
  }
3609
3599
  registerAuthMiddleware() {
3610
3600
  super.registerAuthMiddleware();
@@ -3744,6 +3734,37 @@ var MastraServer$1 = class extends MastraServer {
3744
3734
  }
3745
3735
  };
3746
3736
  /**
3737
+ * Mount-relative agent inference paths (`/agents/:id/<verb>...`). Matched
3738
+ * by prefix on the verb so future variants (`streamVNext`, `stream/vnext`,
3739
+ * `generateVNext`) stay covered without a code change. These are the only
3740
+ * *writes* the browser client is allowed to make against stock Mastra.
3741
+ */
3742
+ const AGENT_INFERENCE = /^\/agents\/[^/]+\/(stream|generate|network)/i;
3743
+ /** Mount-relative read-only agent metadata (`/agents`, `/agents/:id`). */
3744
+ const AGENT_METADATA = /^\/agents(\/[^/]+)?$/;
3745
+ /**
3746
+ * Whether a request to the stock `@mastra/express` sub-app should be
3747
+ * dispatched, given the configured {@link MastraApiGateOptions.access}.
3748
+ *
3749
+ * `path` is mount-relative (what the plugin's catch-all sees, e.g.
3750
+ * `/agents/x/stream`, `/route/history/x`, `/mcp/...`). In `"scoped"`
3751
+ * mode the allowlist is deliberately tight - the chat client only ever
3752
+ * needs agent inference, read-only agent metadata, this plugin's own
3753
+ * OBO/resource-scoped `/route/*` routes, and (when enabled) MCP - so the
3754
+ * whole admin / mutating / bulk-export surface Mastra also exposes is
3755
+ * denied by default rather than enumerated.
3756
+ */
3757
+ function isMastraRequestAllowed(method, path, opts) {
3758
+ if (opts.access === "full") return true;
3759
+ const p = path.startsWith("/") ? path : `/${path}`;
3760
+ if (p === "/route" || p.startsWith("/route/")) return true;
3761
+ if (opts.mcpEnabled && (p === "/mcp" || p.startsWith("/mcp/"))) return true;
3762
+ const m = method.toUpperCase();
3763
+ if (m === "POST" && AGENT_INFERENCE.test(p)) return true;
3764
+ if (m === "GET" && AGENT_METADATA.test(p)) return true;
3765
+ return false;
3766
+ }
3767
+ /**
3747
3768
  * Patches around `@mastra/express`'s custom-route dispatcher so the
3748
3769
  * plugin's custom API routes (e.g. `historyRoute`) work when
3749
3770
  * `MastraServer` is hosted on an Express subapp mounted under a parent
@@ -3782,7 +3803,10 @@ const MAX_PER_PAGE = 200;
3782
3803
  * empty page so callers don't have to special-case stateless agents.
3783
3804
  */
3784
3805
  async function listThreads(opts) {
3785
- const perPage = clampPerPage(opts.perPage);
3806
+ const perPage = clampPerPage(opts.perPage, {
3807
+ fallback: DEFAULT_PER_PAGE,
3808
+ max: MAX_PER_PAGE
3809
+ });
3786
3810
  const page = Math.max(0, Math.trunc(opts.page ?? 0));
3787
3811
  const memory = await opts.agent.getMemory();
3788
3812
  if (!memory) {
@@ -3946,24 +3970,6 @@ function toIso(value) {
3946
3970
  const parsed = new Date(value);
3947
3971
  return Number.isNaN(parsed.getTime()) ? (/* @__PURE__ */ new Date(0)).toISOString() : parsed.toISOString();
3948
3972
  }
3949
- /** Coerce / clamp `perPage`; falls back to the page-size default. */
3950
- function clampPerPage(value) {
3951
- if (value === void 0 || Number.isNaN(value)) return DEFAULT_PER_PAGE;
3952
- const n = Math.trunc(value);
3953
- if (n <= 0) return DEFAULT_PER_PAGE;
3954
- return Math.min(n, MAX_PER_PAGE);
3955
- }
3956
- /**
3957
- * Coerce a Hono query value into a non-negative integer. Returns
3958
- * `undefined` for empty / non-numeric / negative inputs so
3959
- * {@link listThreads} can apply its built-in defaults.
3960
- */
3961
- function parseIntParam(value) {
3962
- if (!value) return void 0;
3963
- const n = Number(value);
3964
- if (!Number.isFinite(n) || n < 0) return void 0;
3965
- return Math.trunc(n);
3966
- }
3967
3973
 
3968
3974
  //#endregion
3969
3975
  //#region packages/appkit-mastra/src/plugin.ts
@@ -3994,8 +4000,13 @@ function parseIntParam(value) {
3994
4000
  * Mastra agent routes, the plugin registers `/route/history`
3995
4001
  * (load + clear a thread's messages), `/route/threads` (list the
3996
4002
  * caller's conversations + delete one), `/models`, `/suggestions`,
3997
- * and the generic `/embed/:type/:id` resolver for inline chart /
3998
- * data markers.
4003
+ * `/route/feedback` (log a thumbs / comment to MLflow when feedback
4004
+ * is enabled), and the generic `/embed/:type/:id` resolver for inline
4005
+ * chart / data markers. The stock `@mastra/express` surface is gated
4006
+ * by `config.apiAccess` (default `"scoped"`): only agent inference,
4007
+ * read-only agent metadata, the `/route/*` routes, and (when enabled)
4008
+ * MCP are dispatched to Mastra; admin / mutating / bulk-export routes
4009
+ * are refused with `403`. See {@link isMastraRequestAllowed}.
3999
4010
  * - MCP: opt in with `config.mcp` to expose the agents (and optionally
4000
4011
  * tools) as a Mastra `MCPServer`. It is registered on the `Mastra`
4001
4012
  * instance via `mcpServers`, so `@mastra/express` serves the stock
@@ -4116,13 +4127,12 @@ var MastraPlugin = class MastraPlugin extends Plugin {
4116
4127
  };
4117
4128
  }
4118
4129
  /**
4119
- * Whether user feedback can be logged to MLflow. The explicit
4120
- * `config.feedback` override wins; otherwise auto-detect from the
4121
- * MLflow tracing env ({@link mlflowEnabled}). Gates both the client
4122
- * config flag and the feedback route so the two never disagree.
4130
+ * Whether user feedback can be logged to MLflow. Delegates to
4131
+ * {@link resolveFeedbackEnabled} so the client-config flag and the
4132
+ * feedback route share the same gate as the server's trace-id header.
4123
4133
  */
4124
4134
  feedbackEnabled() {
4125
- return this.config.feedback ?? mlflowEnabled();
4135
+ return resolveFeedbackEnabled(this.config.feedback);
4126
4136
  }
4127
4137
  injectRoutes(router) {
4128
4138
  router.get(MASTRA_ROUTES.models, (req, res, next) => {
@@ -4196,6 +4206,13 @@ var MastraPlugin = class MastraPlugin extends Plugin {
4196
4206
  });
4197
4207
  router.use((req, res, next) => {
4198
4208
  if (!this.mastraApp) return res.status(503).end();
4209
+ if (!isMastraRequestAllowed(req.method, req.path, {
4210
+ access: this.config.apiAccess ?? "scoped",
4211
+ mcpEnabled: Boolean(this.config.mcp)
4212
+ })) {
4213
+ res.status(403).json({ error: "Endpoint not exposed to the client (apiAccess=scoped)" });
4214
+ return;
4215
+ }
4199
4216
  return this.userScopedSelf(req).dispatchMastra(req, res, next);
4200
4217
  });
4201
4218
  }
@@ -4402,6 +4419,7 @@ var MastraPlugin = class MastraPlugin extends Plugin {
4402
4419
  "/route/threads",
4403
4420
  "/models",
4404
4421
  "/suggestions",
4422
+ "/route/feedback",
4405
4423
  "/embed/:type/:id"
4406
4424
  ],
4407
4425
  instanceStorage: instanceStorage !== void 0,
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@dbx-tools/appkit-mastra",
3
- "version": "0.1.74",
3
+ "version": "0.1.75",
4
4
  "dependencies": {
5
5
  "@databricks/sdk-experimental": "^0.17",
6
- "@dbx-tools/appkit-mastra-shared": "0.1.74",
7
- "@dbx-tools/genie": "0.1.74",
8
- "@dbx-tools/genie-shared": "0.1.74",
9
- "@dbx-tools/model": "0.1.74",
10
- "@dbx-tools/shared": "0.1.74",
6
+ "@dbx-tools/appkit-mastra-shared": "0.1.75",
7
+ "@dbx-tools/genie": "0.1.75",
8
+ "@dbx-tools/genie-shared": "0.1.75",
9
+ "@dbx-tools/model": "0.1.75",
10
+ "@dbx-tools/shared": "0.1.75",
11
11
  "@mastra/ai-sdk": "^1",
12
12
  "@mastra/core": "^1",
13
13
  "@mastra/express": "^1",