@agent-native/dispatch 0.8.5 → 0.8.7

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 (97) hide show
  1. package/dist/actions/ask_app.d.ts +3 -0
  2. package/dist/actions/ask_app.d.ts.map +1 -0
  3. package/dist/actions/ask_app.js +12 -0
  4. package/dist/actions/ask_app.js.map +1 -0
  5. package/dist/actions/create_embed_session.d.ts +3 -0
  6. package/dist/actions/create_embed_session.d.ts.map +1 -0
  7. package/dist/actions/create_embed_session.js +28 -0
  8. package/dist/actions/create_embed_session.js.map +1 -0
  9. package/dist/actions/index.d.ts.map +1 -1
  10. package/dist/actions/index.js +12 -0
  11. package/dist/actions/index.js.map +1 -1
  12. package/dist/actions/list-mcp-app-access.d.ts +3 -0
  13. package/dist/actions/list-mcp-app-access.d.ts.map +1 -0
  14. package/dist/actions/list-mcp-app-access.js +25 -0
  15. package/dist/actions/list-mcp-app-access.js.map +1 -0
  16. package/dist/actions/list_apps.d.ts +3 -0
  17. package/dist/actions/list_apps.d.ts.map +1 -0
  18. package/dist/actions/list_apps.js +26 -0
  19. package/dist/actions/list_apps.js.map +1 -0
  20. package/dist/actions/open_app.d.ts +3 -0
  21. package/dist/actions/open_app.d.ts.map +1 -0
  22. package/dist/actions/open_app.js +62 -0
  23. package/dist/actions/open_app.js.map +1 -0
  24. package/dist/actions/set-mcp-app-access.d.ts +3 -0
  25. package/dist/actions/set-mcp-app-access.d.ts.map +1 -0
  26. package/dist/actions/set-mcp-app-access.js +46 -0
  27. package/dist/actions/set-mcp-app-access.js.map +1 -0
  28. package/dist/actions/start-workspace-app-creation.js +1 -1
  29. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  30. package/dist/actions/view-screen.d.ts.map +1 -1
  31. package/dist/actions/view-screen.js +8 -0
  32. package/dist/actions/view-screen.js.map +1 -1
  33. package/dist/components/create-app-popover.d.ts.map +1 -1
  34. package/dist/components/create-app-popover.js +1 -0
  35. package/dist/components/create-app-popover.js.map +1 -1
  36. package/dist/components/workspace-app-card.d.ts.map +1 -1
  37. package/dist/components/workspace-app-card.js +2 -1
  38. package/dist/components/workspace-app-card.js.map +1 -1
  39. package/dist/routes/pages/agents.d.ts.map +1 -1
  40. package/dist/routes/pages/agents.js +74 -3
  41. package/dist/routes/pages/agents.js.map +1 -1
  42. package/dist/routes/pages/apps.d.ts.map +1 -1
  43. package/dist/routes/pages/apps.js +23 -8
  44. package/dist/routes/pages/apps.js.map +1 -1
  45. package/dist/routes/pages/metrics.d.ts.map +1 -1
  46. package/dist/routes/pages/metrics.js +3 -1
  47. package/dist/routes/pages/metrics.js.map +1 -1
  48. package/dist/routes/pages/overview.d.ts.map +1 -1
  49. package/dist/routes/pages/overview.js +1 -3
  50. package/dist/routes/pages/overview.js.map +1 -1
  51. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  52. package/dist/server/lib/app-creation-store.js +104 -10
  53. package/dist/server/lib/app-creation-store.js.map +1 -1
  54. package/dist/server/lib/mcp-access-store.d.ts +16 -0
  55. package/dist/server/lib/mcp-access-store.d.ts.map +1 -0
  56. package/dist/server/lib/mcp-access-store.js +64 -0
  57. package/dist/server/lib/mcp-access-store.js.map +1 -0
  58. package/dist/server/lib/mcp-gateway.d.ts +47 -0
  59. package/dist/server/lib/mcp-gateway.d.ts.map +1 -0
  60. package/dist/server/lib/mcp-gateway.js +393 -0
  61. package/dist/server/lib/mcp-gateway.js.map +1 -0
  62. package/dist/server/lib/usage-metrics-store.d.ts +1 -0
  63. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  64. package/dist/server/lib/usage-metrics-store.js +1 -0
  65. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  66. package/dist/server/plugins/agent-chat.d.ts.map +1 -1
  67. package/dist/server/plugins/agent-chat.js +1 -0
  68. package/dist/server/plugins/agent-chat.js.map +1 -1
  69. package/dist/server/plugins/integrations.d.ts.map +1 -1
  70. package/dist/server/plugins/integrations.js +2 -1
  71. package/dist/server/plugins/integrations.js.map +1 -1
  72. package/package.json +1 -1
  73. package/src/actions/ask_app.ts +13 -0
  74. package/src/actions/create_embed_session.ts +29 -0
  75. package/src/actions/index.spec.ts +6 -0
  76. package/src/actions/index.ts +12 -0
  77. package/src/actions/list-mcp-app-access.ts +26 -0
  78. package/src/actions/list_apps.ts +27 -0
  79. package/src/actions/open_app.ts +68 -0
  80. package/src/actions/set-mcp-app-access.ts +59 -0
  81. package/src/actions/start-workspace-app-creation.ts +1 -1
  82. package/src/actions/view-screen.ts +8 -0
  83. package/src/components/create-app-popover.tsx +1 -0
  84. package/src/components/workspace-app-card.tsx +3 -2
  85. package/src/routes/pages/agents.tsx +187 -5
  86. package/src/routes/pages/apps.tsx +209 -67
  87. package/src/routes/pages/metrics.tsx +4 -1
  88. package/src/routes/pages/overview.tsx +16 -10
  89. package/src/server/lib/app-creation-store.spec.ts +240 -0
  90. package/src/server/lib/app-creation-store.ts +130 -11
  91. package/src/server/lib/mcp-access-store.spec.ts +58 -0
  92. package/src/server/lib/mcp-access-store.ts +104 -0
  93. package/src/server/lib/mcp-gateway.spec.ts +295 -0
  94. package/src/server/lib/mcp-gateway.ts +516 -0
  95. package/src/server/lib/usage-metrics-store.ts +2 -0
  96. package/src/server/plugins/agent-chat.ts +1 -0
  97. package/src/server/plugins/integrations.ts +2 -1
@@ -3,17 +3,104 @@ import { runWithRequestContext } from "@agent-native/core/server";
3
3
  import {
4
4
  generateWorkspaceAppDescription,
5
5
  listWorkspaceApps,
6
+ updateWorkspaceAppMetadata,
6
7
  } from "./app-creation-store.js";
7
8
 
8
9
  const originalFetch = globalThis.fetch;
10
+ const settingsKey = "dispatch-app-creation-settings:user:dev@example.test";
11
+
12
+ const mocks = vi.hoisted(() => {
13
+ const settings = new Map<string, unknown>();
14
+ const state = {
15
+ orgRole: "admin" as string | null,
16
+ };
17
+ return {
18
+ settings,
19
+ state,
20
+ getSetting: vi.fn(async (key: string) => settings.get(key) ?? null),
21
+ putSetting: vi.fn(async (key: string, value: unknown) => {
22
+ settings.set(key, value);
23
+ }),
24
+ getDbExec: vi.fn(() => ({
25
+ execute: vi.fn(async () => ({
26
+ rows: state.orgRole ? [{ role: state.orgRole }] : [],
27
+ })),
28
+ })),
29
+ };
30
+ });
31
+
32
+ vi.mock("@agent-native/core/db", async (importOriginal) => {
33
+ const actual = await importOriginal<typeof import("@agent-native/core/db")>();
34
+ return {
35
+ ...actual,
36
+ getDbExec: () => mocks.getDbExec(),
37
+ };
38
+ });
39
+
40
+ vi.mock("@agent-native/core/settings", () => ({
41
+ getSetting: (...args: any[]) => mocks.getSetting(...args),
42
+ putSetting: (...args: any[]) => mocks.putSetting(...args),
43
+ }));
44
+
45
+ vi.mock("./dispatch-store.js", async (importOriginal) => {
46
+ const actual = await importOriginal<typeof import("./dispatch-store.js")>();
47
+ return {
48
+ ...actual,
49
+ recordAudit: vi.fn(async () => {}),
50
+ };
51
+ });
9
52
 
10
53
  afterEach(() => {
11
54
  vi.unstubAllEnvs();
12
55
  vi.unstubAllGlobals();
56
+ vi.clearAllMocks();
57
+ mocks.settings.clear();
58
+ mocks.state.orgRole = "admin";
13
59
  globalThis.fetch = originalFetch;
14
60
  });
15
61
 
16
62
  describe("listWorkspaceApps", () => {
63
+ function stubNoPendingContext() {
64
+ for (const key of [
65
+ "BRANCH",
66
+ "HEAD",
67
+ "VERCEL_GIT_COMMIT_REF",
68
+ "CF_PAGES_BRANCH",
69
+ "RENDER_GIT_BRANCH",
70
+ "FLY_BRANCH",
71
+ "WORKSPACE_GATEWAY_URL",
72
+ "DEPLOY_PRIME_URL",
73
+ "DEPLOY_URL",
74
+ "URL",
75
+ "APP_URL",
76
+ "BETTER_AUTH_URL",
77
+ ]) {
78
+ vi.stubEnv(key, "");
79
+ }
80
+ }
81
+
82
+ function stubManifest(
83
+ apps = [{ id: "dispatch", name: "Dispatch", path: "/dispatch" }],
84
+ ) {
85
+ vi.stubEnv("AGENT_NATIVE_WORKSPACE_APPS_JSON", JSON.stringify(apps));
86
+ }
87
+
88
+ function pendingApp(id: string, overrides: Record<string, unknown> = {}) {
89
+ return {
90
+ id,
91
+ name: id.charAt(0).toUpperCase() + id.slice(1),
92
+ description: `${id} is being created`,
93
+ path: `/${id}`,
94
+ builderUrl: `https://builder.io/app/projects/project-123/branch/${id}`,
95
+ branchName: id,
96
+ projectId: "project-123",
97
+ createdAt: "2026-05-20T18:00:00.000Z",
98
+ updatedAt: "2026-05-20T18:00:00.000Z",
99
+ expiresAt: "2999-01-01T00:00:00.000Z",
100
+ ...overrides,
101
+ };
102
+ }
103
+
17
104
  it("prefers the live workspace gateway manifest when available", async () => {
18
105
  const fetchMock = vi.fn(async () => {
19
106
  return new Response(
@@ -69,6 +156,7 @@ describe("listWorkspaceApps", () => {
69
156
  });
70
157
 
71
158
  it("filters workspace apps by audience", async () => {
159
+ stubNoPendingContext();
72
160
  vi.stubEnv(
73
161
  "AGENT_NATIVE_WORKSPACE_APPS_JSON",
74
162
  JSON.stringify([
@@ -99,6 +187,158 @@ describe("listWorkspaceApps", () => {
99
187
  expect(apps.map((app) => app.id)).toEqual(["portal"]);
100
188
  });
101
189
 
190
+ it("shows current branch and legacy pending Builder app rows", async () => {
191
+ stubManifest();
192
+ vi.stubEnv("BRANCH", "feature-a");
193
+ mocks.settings.set(settingsKey, {
194
+ pendingApps: [
195
+ pendingApp("mail", {
196
+ builderUrl: "https://builder.io/app/projects/project-123/branch/old",
197
+ }),
198
+ pendingApp("mail", {
199
+ contextId: "branch:feature-a",
200
+ contextLabel: "Branch: feature-a",
201
+ builderUrl:
202
+ "https://builder.io/app/projects/project-123/branch/feature-a",
203
+ }),
204
+ pendingApp("calendar", {
205
+ contextId: "branch:feature-b",
206
+ contextLabel: "Branch: feature-b",
207
+ }),
208
+ pendingApp("legacy"),
209
+ ],
210
+ });
211
+
212
+ const apps = await runWithRequestContext(
213
+ { userEmail: "dev@example.test" },
214
+ () => listWorkspaceApps({ includeAgentCards: false }),
215
+ );
216
+
217
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "legacy", "mail"]);
218
+ expect(apps.find((app) => app.id === "mail")?.statusLabel).toBe(
219
+ "Pending Builder branch",
220
+ );
221
+ expect(apps.filter((app) => app.id === "mail")).toHaveLength(1);
222
+ expect(apps.find((app) => app.id === "mail")?.builderUrl).toContain(
223
+ "feature-a",
224
+ );
225
+ });
226
+
227
+ it("keeps unscoped legacy pending rows visible when there is no deploy context", async () => {
228
+ stubNoPendingContext();
229
+ stubManifest();
230
+ mocks.settings.set(settingsKey, {
231
+ pendingApps: [pendingApp("legacy")],
232
+ });
233
+
234
+ const apps = await runWithRequestContext(
235
+ { userEmail: "dev@example.test" },
236
+ () => listWorkspaceApps({ includeAgentCards: false }),
237
+ );
238
+
239
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "legacy"]);
240
+ });
241
+
242
+ it("hides expired pending Builder app rows", async () => {
243
+ stubManifest();
244
+ vi.stubEnv("BRANCH", "feature-a");
245
+ mocks.settings.set(settingsKey, {
246
+ pendingApps: [
247
+ pendingApp("old-app", {
248
+ contextId: "branch:feature-a",
249
+ expiresAt: "2000-01-01T00:00:00.000Z",
250
+ }),
251
+ pendingApp("fresh-app", {
252
+ contextId: "branch:feature-a",
253
+ }),
254
+ ],
255
+ });
256
+
257
+ const apps = await runWithRequestContext(
258
+ { userEmail: "dev@example.test" },
259
+ () => listWorkspaceApps({ includeAgentCards: false }),
260
+ );
261
+
262
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "fresh-app"]);
263
+ });
264
+
265
+ it("does not show a pending row after the app is present in the manifest", async () => {
266
+ stubNoPendingContext();
267
+ stubManifest([
268
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
269
+ { id: "mail", name: "Mail", path: "/mail" },
270
+ ]);
271
+ mocks.settings.set(settingsKey, {
272
+ pendingApps: [pendingApp("mail")],
273
+ });
274
+
275
+ const apps = await runWithRequestContext(
276
+ { userEmail: "dev@example.test" },
277
+ () => listWorkspaceApps({ includeAgentCards: false }),
278
+ );
279
+
280
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "mail"]);
281
+ expect(apps.find((app) => app.id === "mail")?.status).toBe("ready");
282
+ });
283
+
284
+ it("lets workspace admins update app display metadata", async () => {
285
+ stubNoPendingContext();
286
+ stubManifest([
287
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
288
+ {
289
+ id: "todo",
290
+ name: "Todo",
291
+ description: "Original description",
292
+ path: "/todo",
293
+ },
294
+ ]);
295
+
296
+ const updated = await runWithRequestContext(
297
+ { userEmail: "dev@example.test", orgId: "org-123" },
298
+ () =>
299
+ updateWorkspaceAppMetadata({
300
+ appId: "todo",
301
+ name: "Todo Board",
302
+ description: "Tracks team work.",
303
+ }),
304
+ );
305
+
306
+ expect(updated.name).toBe("Todo Board");
307
+ expect(updated.description).toBe("Tracks team work.");
308
+ expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
309
+ apps: {
310
+ todo: expect.objectContaining({
311
+ name: "Todo Board",
312
+ description: "Tracks team work.",
313
+ updatedBy: "dev@example.test",
314
+ }),
315
+ },
316
+ });
317
+ expect(mocks.getDbExec).toHaveBeenCalled();
318
+ });
319
+
320
+ it("blocks non-admin workspace members from updating app display metadata", async () => {
321
+ mocks.state.orgRole = "member";
322
+ stubNoPendingContext();
323
+ stubManifest([{ id: "todo", name: "Todo", path: "/todo" }]);
324
+
325
+ await expect(
326
+ runWithRequestContext(
327
+ { userEmail: "dev@example.test", orgId: "org-123" },
328
+ () =>
329
+ updateWorkspaceAppMetadata({
330
+ appId: "todo",
331
+ name: "Todo Board",
332
+ }),
333
+ ),
334
+ ).rejects.toThrow(
335
+ "Only organization owners and admins can update app creation settings.",
336
+ );
337
+ expect(
338
+ mocks.settings.get("workspace-app-metadata:org:org-123"),
339
+ ).toBeUndefined();
340
+ });
341
+
102
342
  it("generates a concise seed description from an app prompt", () => {
103
343
  expect(
104
344
  generateWorkspaceAppDescription(
@@ -34,6 +34,7 @@ const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
34
34
  const WORKSPACE_APPS_GATEWAY_PATH = "/_workspace/apps";
35
35
  const WORKSPACE_APPS_GATEWAY_TIMEOUT_MS = 1_000;
36
36
  const MAX_PENDING_APPS = 50;
37
+ const PENDING_WORKSPACE_APP_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
37
38
  const AGENT_CARD_PATH = "/.well-known/agent-card.json";
38
39
  const AGENT_CARD_FETCH_TIMEOUT_MS = 1_500;
39
40
  const DEFAULT_WORKSPACE_APP_AUDIENCE = "internal";
@@ -111,9 +112,12 @@ interface PendingWorkspaceApp {
111
112
  builderUrl: string | null;
112
113
  branchName: string | null;
113
114
  projectId: string | null;
115
+ contextId: string | null;
116
+ contextLabel: string | null;
114
117
  audience?: WorkspaceAppAudience;
115
118
  createdAt: string;
116
119
  updatedAt: string;
120
+ expiresAt: string | null;
117
121
  }
118
122
 
119
123
  interface WorkspaceAppMetadataOverride {
@@ -209,6 +213,48 @@ function workspaceAppMetadataSettingsKey(): string {
209
213
  return `${WORKSPACE_APP_METADATA_SETTINGS_KEY}:user:${currentOwnerEmail()}`;
210
214
  }
211
215
 
216
+ function cleanOptionalString(value: unknown): string | null {
217
+ return typeof value === "string" && value.trim() ? value.trim() : null;
218
+ }
219
+
220
+ function normalizeContextPart(value: string): string {
221
+ return value.trim().replace(/\s+/g, " ");
222
+ }
223
+
224
+ function pendingWorkspaceAppContext(): { id: string; label: string } | null {
225
+ const branch =
226
+ cleanOptionalString(process.env.BRANCH) ??
227
+ cleanOptionalString(process.env.HEAD) ??
228
+ cleanOptionalString(process.env.VERCEL_GIT_COMMIT_REF) ??
229
+ cleanOptionalString(process.env.CF_PAGES_BRANCH) ??
230
+ cleanOptionalString(process.env.RENDER_GIT_BRANCH) ??
231
+ cleanOptionalString(process.env.FLY_BRANCH);
232
+ if (branch) {
233
+ const normalized = normalizeContextPart(branch);
234
+ return { id: `branch:${normalized}`, label: `Branch: ${normalized}` };
235
+ }
236
+
237
+ const origin =
238
+ cleanOptionalString(process.env.DEPLOY_PRIME_URL) ??
239
+ cleanOptionalString(process.env.DEPLOY_URL) ??
240
+ cleanOptionalString(process.env.URL) ??
241
+ cleanOptionalString(process.env.APP_URL) ??
242
+ cleanOptionalString(process.env.BETTER_AUTH_URL) ??
243
+ cleanOptionalString(process.env.WORKSPACE_GATEWAY_URL);
244
+ if (!origin) return null;
245
+
246
+ try {
247
+ const parsed = new URL(origin);
248
+ return {
249
+ id: `origin:${parsed.origin}`,
250
+ label: parsed.hostname,
251
+ };
252
+ } catch {
253
+ const normalized = normalizeContextPart(origin);
254
+ return { id: `origin:${normalized}`, label: normalized };
255
+ }
256
+ }
257
+
212
258
  async function readSettingsRecord(): Promise<Record<string, any>> {
213
259
  const raw = await getSetting(scopedSettingsKey()).catch(() => null);
214
260
  return raw && typeof raw === "object" && !Array.isArray(raw)
@@ -476,6 +522,61 @@ function parseWorkspaceAppsManifest(parsed: any): WorkspaceAppSummary[] | null {
476
522
  return apps.length ? apps : null;
477
523
  }
478
524
 
525
+ function parseDateMs(value: string | null | undefined): number | null {
526
+ if (!value) return null;
527
+ const ms = Date.parse(value);
528
+ return Number.isFinite(ms) ? ms : null;
529
+ }
530
+
531
+ function pendingWorkspaceAppExpiresAt(createdAt: string): string {
532
+ const createdMs = parseDateMs(createdAt) ?? Date.now();
533
+ return new Date(createdMs + PENDING_WORKSPACE_APP_TTL_MS).toISOString();
534
+ }
535
+
536
+ function isPendingWorkspaceAppExpired(
537
+ app: Pick<PendingWorkspaceApp, "createdAt" | "expiresAt">,
538
+ ): boolean {
539
+ const expiresMs =
540
+ parseDateMs(app.expiresAt) ??
541
+ (parseDateMs(app.createdAt) ?? Date.now()) + PENDING_WORKSPACE_APP_TTL_MS;
542
+ return expiresMs <= Date.now();
543
+ }
544
+
545
+ function pendingWorkspaceAppMatchesCurrentContext(
546
+ app: Pick<PendingWorkspaceApp, "contextId" | "createdAt" | "expiresAt">,
547
+ ): boolean {
548
+ if (isPendingWorkspaceAppExpired(app)) return false;
549
+ const currentContext = pendingWorkspaceAppContext();
550
+ if (!app.contextId) return true;
551
+ return app.contextId === currentContext?.id;
552
+ }
553
+
554
+ function pendingWorkspaceAppContextRank(
555
+ app: Pick<PendingWorkspaceApp, "contextId">,
556
+ ): number {
557
+ const currentContext = pendingWorkspaceAppContext();
558
+ if (app.contextId && app.contextId === currentContext?.id) return 2;
559
+ if (!app.contextId) return 1;
560
+ return 0;
561
+ }
562
+
563
+ function dedupePendingWorkspaceAppsForCurrentContext(
564
+ apps: PendingWorkspaceApp[],
565
+ ): PendingWorkspaceApp[] {
566
+ const byId = new Map<string, PendingWorkspaceApp>();
567
+ for (const app of apps) {
568
+ const existing = byId.get(app.id);
569
+ if (
570
+ !existing ||
571
+ pendingWorkspaceAppContextRank(app) >
572
+ pendingWorkspaceAppContextRank(existing)
573
+ ) {
574
+ byId.set(app.id, app);
575
+ }
576
+ }
577
+ return Array.from(byId.values());
578
+ }
579
+
479
580
  function sortWorkspaceApps(a: WorkspaceAppSummary, b: WorkspaceAppSummary) {
480
581
  if (a.id === "dispatch") return -1;
481
582
  if (b.id === "dispatch") return 1;
@@ -495,6 +596,7 @@ function parsePendingWorkspaceApps(value: unknown): PendingWorkspaceApp[] {
495
596
  typeof record.path === "string" ? record.path.trim() : "";
496
597
  if (!id || !pathValue.startsWith("/")) return null;
497
598
  const now = new Date().toISOString();
599
+ const createdAt = cleanOptionalString(record.createdAt) ?? now;
498
600
  return {
499
601
  id,
500
602
  name:
@@ -518,17 +620,19 @@ function parsePendingWorkspaceApps(value: unknown): PendingWorkspaceApp[] {
518
620
  typeof record.projectId === "string" && record.projectId.trim()
519
621
  ? record.projectId.trim()
520
622
  : null,
623
+ contextId: cleanOptionalString(record.contextId),
624
+ contextLabel: cleanOptionalString(record.contextLabel),
521
625
  ...(record.audience === undefined
522
626
  ? {}
523
627
  : { audience: normalizeWorkspaceAppAudience(record.audience) }),
524
- createdAt:
525
- typeof record.createdAt === "string" && record.createdAt.trim()
526
- ? record.createdAt.trim()
527
- : now,
628
+ createdAt,
528
629
  updatedAt:
529
630
  typeof record.updatedAt === "string" && record.updatedAt.trim()
530
631
  ? record.updatedAt.trim()
531
632
  : now,
633
+ expiresAt:
634
+ cleanOptionalString(record.expiresAt) ??
635
+ pendingWorkspaceAppExpiresAt(createdAt),
532
636
  } satisfies PendingWorkspaceApp;
533
637
  })
534
638
  .filter((app): app is PendingWorkspaceApp => !!app)
@@ -597,7 +701,9 @@ export async function removePendingWorkspaceApp(input: {
597
701
  if (!appId) throw new Error("appId is required");
598
702
  const raw = await readSettingsRecord();
599
703
  const pending = parsePendingWorkspaceApps(raw.pendingApps);
600
- const next = pending.filter((app) => app.id !== appId);
704
+ const next = pending.filter(
705
+ (app) => app.id !== appId || !pendingWorkspaceAppMatchesCurrentContext(app),
706
+ );
601
707
  const removed = next.length !== pending.length;
602
708
  if (!removed) return { removed: false };
603
709
  await putSetting(scopedSettingsKey(), { ...raw, pendingApps: next });
@@ -622,7 +728,7 @@ function pendingAppToSummary(app: PendingWorkspaceApp): WorkspaceAppSummary {
622
728
  publicPaths: [],
623
729
  protectedPaths: [],
624
730
  status: "pending",
625
- statusLabel: "Building in Builder",
731
+ statusLabel: "Pending Builder branch",
626
732
  builderUrl: app.builderUrl,
627
733
  branchName: app.branchName,
628
734
  createdAt: app.createdAt,
@@ -633,9 +739,11 @@ async function appendPendingWorkspaceApps(
633
739
  apps: WorkspaceAppSummary[],
634
740
  ): Promise<WorkspaceAppSummary[]> {
635
741
  const readyIds = new Set(apps.map((app) => app.id));
636
- const pendingApps = (await listPendingWorkspaceApps())
637
- .filter((app) => !readyIds.has(app.id))
638
- .map(pendingAppToSummary);
742
+ const pendingApps = dedupePendingWorkspaceAppsForCurrentContext(
743
+ (await listPendingWorkspaceApps())
744
+ .filter(pendingWorkspaceAppMatchesCurrentContext)
745
+ .filter((app) => !readyIds.has(app.id)),
746
+ ).map(pendingAppToSummary);
639
747
  return [...apps, ...pendingApps].sort(sortWorkspaceApps);
640
748
  }
641
749
 
@@ -765,9 +873,16 @@ async function recordPendingWorkspaceApp(input: {
765
873
  builderUrl?: string | null;
766
874
  }) {
767
875
  const now = new Date().toISOString();
876
+ const context = pendingWorkspaceAppContext();
768
877
  const raw = await readSettingsRecord();
769
878
  const pendingApps = parsePendingWorkspaceApps(raw.pendingApps);
770
- const existing = pendingApps.find((app) => app.id === input.appId);
879
+ const contextId = context?.id ?? null;
880
+ const samePendingEntry = (app: PendingWorkspaceApp) =>
881
+ app.id === input.appId &&
882
+ (app.contextId === contextId || (!!contextId && !app.contextId));
883
+ const existing = pendingApps
884
+ .filter((app) => !isPendingWorkspaceAppExpired(app))
885
+ .find(samePendingEntry);
771
886
  const next: PendingWorkspaceApp = {
772
887
  id: input.appId,
773
888
  name: titleCase(input.appId),
@@ -778,15 +893,18 @@ async function recordPendingWorkspaceApp(input: {
778
893
  builderUrl: input.builderUrl?.trim() || null,
779
894
  branchName: input.branchName?.trim() || null,
780
895
  projectId: input.projectId,
896
+ contextId: context?.id ?? null,
897
+ contextLabel: context?.label ?? null,
781
898
  createdAt: existing?.createdAt || now,
782
899
  updatedAt: now,
900
+ expiresAt: pendingWorkspaceAppExpiresAt(existing?.createdAt || now),
783
901
  };
784
902
 
785
903
  await putSetting(scopedSettingsKey(), {
786
904
  ...raw,
787
905
  pendingApps: [
788
906
  next,
789
- ...pendingApps.filter((app) => app.id !== input.appId),
907
+ ...pendingApps.filter((app) => !samePendingEntry(app)),
790
908
  ].slice(0, MAX_PENDING_APPS),
791
909
  });
792
910
 
@@ -807,6 +925,7 @@ async function recordPendingWorkspaceApp(input: {
807
925
  builderBranchUrlConfigured: !!next.builderUrl,
808
926
  branchName: next.branchName,
809
927
  projectIdConfigured: !!next.projectId,
928
+ contextLabel: next.contextLabel,
810
929
  },
811
930
  });
812
931
  }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ isAppAllowedByMcpAccess,
4
+ normalizeMcpAppAccessSettings,
5
+ } from "./mcp-access-store.js";
6
+
7
+ describe("normalizeMcpAppAccessSettings", () => {
8
+ it("defaults to all apps", () => {
9
+ expect(normalizeMcpAppAccessSettings(null)).toEqual({
10
+ mode: "all-apps",
11
+ selectedAppIds: [],
12
+ updatedAt: undefined,
13
+ updatedBy: undefined,
14
+ });
15
+ });
16
+
17
+ it("normalizes selected app ids", () => {
18
+ expect(
19
+ normalizeMcpAppAccessSettings({
20
+ mode: "selected-apps",
21
+ selectedAppIds: [" Mail ", "mail", "calendar"],
22
+ updatedAt: "2026-05-20T12:00:00.000Z",
23
+ updatedBy: "admin@example.test",
24
+ }),
25
+ ).toEqual({
26
+ mode: "selected-apps",
27
+ selectedAppIds: ["mail", "calendar"],
28
+ updatedAt: "2026-05-20T12:00:00.000Z",
29
+ updatedBy: "admin@example.test",
30
+ });
31
+ });
32
+ });
33
+
34
+ describe("isAppAllowedByMcpAccess", () => {
35
+ it("allows every app in all-apps mode", () => {
36
+ expect(
37
+ isAppAllowedByMcpAccess("mail", {
38
+ mode: "all-apps",
39
+ selectedAppIds: [],
40
+ }),
41
+ ).toBe(true);
42
+ });
43
+
44
+ it("checks selected grants in selected-apps mode", () => {
45
+ expect(
46
+ isAppAllowedByMcpAccess("mail", {
47
+ mode: "selected-apps",
48
+ selectedAppIds: ["calendar"],
49
+ }),
50
+ ).toBe(false);
51
+ expect(
52
+ isAppAllowedByMcpAccess("calendar", {
53
+ mode: "selected-apps",
54
+ selectedAppIds: ["calendar"],
55
+ }),
56
+ ).toBe(true);
57
+ });
58
+ });
@@ -0,0 +1,104 @@
1
+ import {
2
+ getOrgSetting,
3
+ getUserSetting,
4
+ putOrgSetting,
5
+ putUserSetting,
6
+ } from "@agent-native/core/settings";
7
+ import {
8
+ getRequestOrgId,
9
+ getRequestUserEmail,
10
+ } from "@agent-native/core/server";
11
+
12
+ export const MCP_APP_ACCESS_SETTINGS_KEY = "dispatch-mcp-app-access";
13
+
14
+ export type DispatchMcpAppAccessMode = "all-apps" | "selected-apps";
15
+
16
+ export interface DispatchMcpAppAccessSettings {
17
+ mode: DispatchMcpAppAccessMode;
18
+ selectedAppIds: string[];
19
+ updatedAt?: string;
20
+ updatedBy?: string;
21
+ }
22
+
23
+ interface AccessScope {
24
+ kind: "org" | "user";
25
+ id: string;
26
+ actor: string;
27
+ }
28
+
29
+ function uniqueAppIds(values: unknown): string[] {
30
+ const input = Array.isArray(values) ? values : [];
31
+ return Array.from(
32
+ new Set(
33
+ input
34
+ .filter((value): value is string => typeof value === "string")
35
+ .map((value) => value.trim().toLowerCase())
36
+ .filter(Boolean),
37
+ ),
38
+ );
39
+ }
40
+
41
+ export function normalizeMcpAppAccessSettings(
42
+ raw: unknown,
43
+ ): DispatchMcpAppAccessSettings {
44
+ const record =
45
+ raw && typeof raw === "object" && !Array.isArray(raw)
46
+ ? (raw as Record<string, unknown>)
47
+ : {};
48
+ const mode = record.mode === "selected-apps" ? "selected-apps" : "all-apps";
49
+ return {
50
+ mode,
51
+ selectedAppIds: uniqueAppIds(record.selectedAppIds),
52
+ updatedAt:
53
+ typeof record.updatedAt === "string" ? record.updatedAt : undefined,
54
+ updatedBy:
55
+ typeof record.updatedBy === "string" ? record.updatedBy : undefined,
56
+ };
57
+ }
58
+
59
+ function currentAccessScope(): AccessScope {
60
+ const actor = getRequestUserEmail();
61
+ if (!actor) throw new Error("no authenticated user");
62
+ const orgId = getRequestOrgId();
63
+ if (orgId) return { kind: "org", id: orgId, actor };
64
+ return { kind: "user", id: actor, actor };
65
+ }
66
+
67
+ export async function getDispatchMcpAppAccessSettings(): Promise<DispatchMcpAppAccessSettings> {
68
+ const scope = currentAccessScope();
69
+ const raw =
70
+ scope.kind === "org"
71
+ ? await getOrgSetting(scope.id, MCP_APP_ACCESS_SETTINGS_KEY)
72
+ : await getUserSetting(scope.id, MCP_APP_ACCESS_SETTINGS_KEY);
73
+ return normalizeMcpAppAccessSettings(raw);
74
+ }
75
+
76
+ export async function setDispatchMcpAppAccessSettings(input: {
77
+ mode: DispatchMcpAppAccessMode;
78
+ selectedAppIds?: string[];
79
+ }): Promise<DispatchMcpAppAccessSettings> {
80
+ const scope = currentAccessScope();
81
+ const next: DispatchMcpAppAccessSettings = {
82
+ mode: input.mode,
83
+ selectedAppIds: uniqueAppIds(input.selectedAppIds),
84
+ updatedAt: new Date().toISOString(),
85
+ updatedBy: scope.actor,
86
+ };
87
+ const value = next as unknown as Record<string, unknown>;
88
+ if (scope.kind === "org") {
89
+ await putOrgSetting(scope.id, MCP_APP_ACCESS_SETTINGS_KEY, value);
90
+ } else {
91
+ await putUserSetting(scope.id, MCP_APP_ACCESS_SETTINGS_KEY, value);
92
+ }
93
+ return next;
94
+ }
95
+
96
+ export function isAppAllowedByMcpAccess(
97
+ appId: string,
98
+ settings: DispatchMcpAppAccessSettings,
99
+ ): boolean {
100
+ const normalized = appId.trim().toLowerCase();
101
+ if (!normalized) return false;
102
+ if (settings.mode === "all-apps") return true;
103
+ return settings.selectedAppIds.includes(normalized);
104
+ }