@agent-native/dispatch 0.14.2 → 0.14.4

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 (138) hide show
  1. package/README.md +114 -122
  2. package/dist/actions/approve-dispatch-change.d.ts +10 -10
  3. package/dist/actions/list_apps.d.ts +1 -0
  4. package/dist/actions/list_apps.js +16 -8
  5. package/dist/actions/list_apps.js.map +1 -1
  6. package/dist/actions/provider-api-register.js +64 -2
  7. package/dist/actions/provider-api-register.js.map +1 -1
  8. package/dist/actions/reject-dispatch-change.d.ts +10 -10
  9. package/dist/actions/set-mcp-app-access.js +1 -4
  10. package/dist/actions/set-mcp-app-access.js.map +1 -1
  11. package/dist/components/action-query-error.d.ts +8 -0
  12. package/dist/components/action-query-error.d.ts.map +1 -0
  13. package/dist/components/action-query-error.js +10 -0
  14. package/dist/components/action-query-error.js.map +1 -0
  15. package/dist/components/app-keys-popover.d.ts +4 -0
  16. package/dist/components/app-keys-popover.d.ts.map +1 -1
  17. package/dist/components/app-keys-popover.js +26 -14
  18. package/dist/components/app-keys-popover.js.map +1 -1
  19. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  20. package/dist/components/dispatch-control-plane.js +31 -27
  21. package/dist/components/dispatch-control-plane.js.map +1 -1
  22. package/dist/components/index.d.ts +1 -0
  23. package/dist/components/index.d.ts.map +1 -1
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/index.js.map +1 -1
  26. package/dist/components/layout/Layout.d.ts +1 -0
  27. package/dist/components/layout/Layout.d.ts.map +1 -1
  28. package/dist/components/layout/Layout.js +19 -21
  29. package/dist/components/layout/Layout.js.map +1 -1
  30. package/dist/components/ui/sheet.js +1 -1
  31. package/dist/components/ui/sheet.js.map +1 -1
  32. package/dist/components/ui/toast.js +1 -1
  33. package/dist/components/ui/toast.js.map +1 -1
  34. package/dist/components/workspace-app-card.d.ts.map +1 -1
  35. package/dist/components/workspace-app-card.js +8 -5
  36. package/dist/components/workspace-app-card.js.map +1 -1
  37. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  38. package/dist/components/workspace-resource-effective-stack.js +6 -1
  39. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  40. package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
  41. package/dist/components/workspace-resource-impact-preview.js +6 -1
  42. package/dist/components/workspace-resource-impact-preview.js.map +1 -1
  43. package/dist/routes/pages/$appId.d.ts.map +1 -1
  44. package/dist/routes/pages/$appId.js +6 -1
  45. package/dist/routes/pages/$appId.js.map +1 -1
  46. package/dist/routes/pages/agents.d.ts.map +1 -1
  47. package/dist/routes/pages/agents.js +5 -8
  48. package/dist/routes/pages/agents.js.map +1 -1
  49. package/dist/routes/pages/approval.d.ts.map +1 -1
  50. package/dist/routes/pages/approval.js +6 -1
  51. package/dist/routes/pages/approval.js.map +1 -1
  52. package/dist/routes/pages/approvals.d.ts.map +1 -1
  53. package/dist/routes/pages/approvals.js +8 -5
  54. package/dist/routes/pages/approvals.js.map +1 -1
  55. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  56. package/dist/routes/pages/apps.$appId.js +4 -2
  57. package/dist/routes/pages/apps.$appId.js.map +1 -1
  58. package/dist/routes/pages/apps.d.ts.map +1 -1
  59. package/dist/routes/pages/apps.js +9 -3
  60. package/dist/routes/pages/apps.js.map +1 -1
  61. package/dist/routes/pages/audit.d.ts.map +1 -1
  62. package/dist/routes/pages/audit.js +4 -2
  63. package/dist/routes/pages/audit.js.map +1 -1
  64. package/dist/routes/pages/chat.d.ts.map +1 -1
  65. package/dist/routes/pages/chat.js +16 -2
  66. package/dist/routes/pages/chat.js.map +1 -1
  67. package/dist/routes/pages/destinations.d.ts.map +1 -1
  68. package/dist/routes/pages/destinations.js +5 -3
  69. package/dist/routes/pages/destinations.js.map +1 -1
  70. package/dist/routes/pages/identities.d.ts.map +1 -1
  71. package/dist/routes/pages/identities.js +5 -3
  72. package/dist/routes/pages/identities.js.map +1 -1
  73. package/dist/routes/pages/integrations.d.ts.map +1 -1
  74. package/dist/routes/pages/integrations.js +9 -3
  75. package/dist/routes/pages/integrations.js.map +1 -1
  76. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  77. package/dist/routes/pages/thread-debug.js +7 -6
  78. package/dist/routes/pages/thread-debug.js.map +1 -1
  79. package/dist/routes/pages/vault.d.ts.map +1 -1
  80. package/dist/routes/pages/vault.js +23 -7
  81. package/dist/routes/pages/vault.js.map +1 -1
  82. package/dist/routes/pages/workspace.d.ts.map +1 -1
  83. package/dist/routes/pages/workspace.js +9 -3
  84. package/dist/routes/pages/workspace.js.map +1 -1
  85. package/dist/server/lib/dispatch-store.d.ts +24 -21
  86. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  87. package/dist/server/lib/dispatch-store.js +59 -23
  88. package/dist/server/lib/dispatch-store.js.map +1 -1
  89. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  90. package/dist/server/lib/mcp-access-store.js +30 -2
  91. package/dist/server/lib/mcp-access-store.js.map +1 -1
  92. package/dist/server/lib/vault-store.d.ts.map +1 -1
  93. package/dist/server/lib/vault-store.js +102 -37
  94. package/dist/server/lib/vault-store.js.map +1 -1
  95. package/dist/server/plugins/agent-chat.d.ts +1 -1
  96. package/package.json +2 -3
  97. package/src/actions/list_apps.spec.ts +52 -0
  98. package/src/actions/list_apps.ts +16 -8
  99. package/src/actions/provider-api-register.spec.ts +193 -0
  100. package/src/actions/provider-api-register.ts +66 -1
  101. package/src/actions/set-mcp-app-access.spec.ts +73 -0
  102. package/src/actions/set-mcp-app-access.ts +1 -5
  103. package/src/components/action-query-error.spec.tsx +56 -0
  104. package/src/components/action-query-error.tsx +32 -0
  105. package/src/components/app-keys-popover.spec.tsx +85 -0
  106. package/src/components/app-keys-popover.tsx +76 -50
  107. package/src/components/dispatch-control-plane.spec.tsx +106 -0
  108. package/src/components/dispatch-control-plane.tsx +71 -115
  109. package/src/components/index.ts +1 -0
  110. package/src/components/layout/Layout.spec.tsx +42 -2
  111. package/src/components/layout/Layout.tsx +22 -27
  112. package/src/components/ui/sheet.tsx +1 -1
  113. package/src/components/ui/toast.tsx +1 -1
  114. package/src/components/workspace-app-card.spec.tsx +80 -0
  115. package/src/components/workspace-app-card.tsx +36 -25
  116. package/src/components/workspace-resource-effective-stack.tsx +13 -1
  117. package/src/components/workspace-resource-impact-preview.tsx +12 -1
  118. package/src/routes/pages/$appId.tsx +17 -1
  119. package/src/routes/pages/agents.tsx +24 -11
  120. package/src/routes/pages/approval.tsx +16 -4
  121. package/src/routes/pages/approvals.tsx +108 -89
  122. package/src/routes/pages/apps.$appId.tsx +9 -2
  123. package/src/routes/pages/apps.tsx +20 -7
  124. package/src/routes/pages/audit.tsx +29 -20
  125. package/src/routes/pages/chat.spec.tsx +94 -0
  126. package/src/routes/pages/chat.tsx +30 -10
  127. package/src/routes/pages/destinations.tsx +75 -62
  128. package/src/routes/pages/identities.tsx +74 -63
  129. package/src/routes/pages/integrations.tsx +17 -10
  130. package/src/routes/pages/thread-debug.tsx +18 -12
  131. package/src/routes/pages/vault.tsx +59 -26
  132. package/src/routes/pages/workspace.tsx +69 -55
  133. package/src/server/lib/approval-fencing.spec.ts +420 -0
  134. package/src/server/lib/dispatch-store.ts +94 -21
  135. package/src/server/lib/mcp-access-store.spec.ts +116 -4
  136. package/src/server/lib/mcp-access-store.ts +37 -2
  137. package/src/server/lib/mcp-gateway.spec.ts +79 -3
  138. package/src/server/lib/vault-store.ts +151 -45
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
- import { and, desc, eq, isNull, or } from "@agent-native/core/db/schema";
3
+ import { and, desc, eq, isNull, or, sql } from "@agent-native/core/db/schema";
4
4
  import {
5
5
  getRequestUserEmail,
6
6
  getRequestOrgId,
@@ -11,6 +11,7 @@ import { getDb, schema } from "../../db/index.js";
11
11
 
12
12
  export const SHARED_DISPATCH_OWNER = "dispatch@shared";
13
13
  const APPROVAL_POLICY_KEY = "dispatch-approval-policy";
14
+ const APPROVAL_APPLY_LEASE_MS = 5 * 60 * 1000;
14
15
 
15
16
  // ─── /link rate limiting ──────────────────────────────────────────────────
16
17
  //
@@ -188,8 +189,8 @@ export async function getApprovalPolicy(): Promise<DispatchApprovalPolicy> {
188
189
  async function applyApprovalPolicy(
189
190
  input: DispatchApprovalPolicy,
190
191
  actor = currentOwnerEmail(),
192
+ orgId = currentOrgId(),
191
193
  ) {
192
- const orgId = currentOrgId();
193
194
  if (!orgId) {
194
195
  throw new Error(
195
196
  "Dispatch approval settings require an active organization",
@@ -209,7 +210,10 @@ async function applyApprovalPolicy(
209
210
  metadata: input,
210
211
  actor,
211
212
  });
212
- return getApprovalPolicy();
213
+ return {
214
+ enabled: input.enabled,
215
+ approverEmails: input.approverEmails,
216
+ };
213
217
  }
214
218
 
215
219
  export async function setApprovalPolicy(input: DispatchApprovalPolicy) {
@@ -564,6 +568,7 @@ async function applyApprovedRequest(request: DispatchApprovalRequest) {
564
568
  return applyApprovalPolicy(
565
569
  payload,
566
570
  request.reviewedBy || currentOwnerEmail(),
571
+ requestCtx.orgId,
567
572
  );
568
573
  }
569
574
  if (request.changeType === "dream-proposal.apply") {
@@ -610,22 +615,78 @@ export async function approveRequest(requestId: string) {
610
615
  const ctx = requireDispatchCtx();
611
616
  const request = await getApprovalRequest(requestId, ctx);
612
617
  if (!request) throw new Error("Approval request not found");
613
- if (request.status !== "pending") {
614
- throw new Error("Only pending approvals can be approved");
615
- }
618
+
616
619
  const timestamp = now();
617
- await db
620
+ const staleApplyingBefore = timestamp - APPROVAL_APPLY_LEASE_MS;
621
+ // Fence the transition on the current status so a concurrent approve can't
622
+ // both win. A crashed worker can leave its lease in "applying", so reclaim
623
+ // only a lease that has been idle for the full lease interval.
624
+ const claimed = await db
625
+ .update(schema.dispatchApprovalRequests)
626
+ .set({
627
+ status: "applying",
628
+ updatedAt: timestamp,
629
+ })
630
+ .where(
631
+ and(
632
+ eq(schema.dispatchApprovalRequests.id, requestId),
633
+ ctxScope(schema.dispatchApprovalRequests, ctx),
634
+ or(
635
+ eq(schema.dispatchApprovalRequests.status, "pending"),
636
+ and(
637
+ eq(schema.dispatchApprovalRequests.status, "applying"),
638
+ sql`${schema.dispatchApprovalRequests.updatedAt} < ${staleApplyingBefore}`,
639
+ ),
640
+ ),
641
+ ),
642
+ )
643
+ .returning();
644
+
645
+ if (claimed.length === 0) {
646
+ const current = (await getApprovalRequest(requestId, ctx)) ?? request;
647
+ if (current.status === "applying") {
648
+ throw new Error("Approval is already being applied");
649
+ }
650
+ return current;
651
+ }
652
+
653
+ const applying = claimed[0];
654
+ try {
655
+ await applyApprovedRequest(applying);
656
+ } catch (error) {
657
+ // Applying a request can have succeeded partially before throwing. Do not
658
+ // return it to pending: that would allow an immediate retry to duplicate a
659
+ // non-idempotent effect. Keep the lease until it becomes stale, which
660
+ // serializes any recovery attempt behind the same fencing rule.
661
+ await db
662
+ .update(schema.dispatchApprovalRequests)
663
+ .set({ updatedAt: now() })
664
+ .where(
665
+ and(
666
+ eq(schema.dispatchApprovalRequests.id, requestId),
667
+ ctxScope(schema.dispatchApprovalRequests, ctx),
668
+ eq(schema.dispatchApprovalRequests.status, "applying"),
669
+ ),
670
+ );
671
+ throw error;
672
+ }
673
+ const [updated] = await db
618
674
  .update(schema.dispatchApprovalRequests)
619
675
  .set({
620
676
  status: "approved",
621
677
  reviewedBy: currentOwnerEmail(),
622
678
  reviewedAt: timestamp,
623
- updatedAt: timestamp,
679
+ updatedAt: now(),
624
680
  })
625
- .where(eq(schema.dispatchApprovalRequests.id, requestId));
626
- const updated = await getApprovalRequest(requestId, ctx);
681
+ .where(
682
+ and(
683
+ eq(schema.dispatchApprovalRequests.id, requestId),
684
+ ctxScope(schema.dispatchApprovalRequests, ctx),
685
+ eq(schema.dispatchApprovalRequests.status, "applying"),
686
+ ),
687
+ )
688
+ .returning();
627
689
  if (!updated) throw new Error("Approval request disappeared");
628
- await applyApprovedRequest(updated);
629
690
  await recordAudit({
630
691
  action: "approval.approved",
631
692
  targetType: updated.targetType,
@@ -638,13 +699,12 @@ export async function approveRequest(requestId: string) {
638
699
 
639
700
  export async function rejectRequest(requestId: string, reason?: string | null) {
640
701
  const db = getDb();
641
- const request = await getApprovalRequest(requestId);
702
+ const ctx = requireDispatchCtx();
703
+ const request = await getApprovalRequest(requestId, ctx);
642
704
  if (!request) throw new Error("Approval request not found");
643
- if (request.status !== "pending") {
644
- throw new Error("Only pending approvals can be rejected");
645
- }
705
+
646
706
  const timestamp = now();
647
- await db
707
+ const claimed = await db
648
708
  .update(schema.dispatchApprovalRequests)
649
709
  .set({
650
710
  status: "rejected",
@@ -652,15 +712,28 @@ export async function rejectRequest(requestId: string, reason?: string | null) {
652
712
  reviewedAt: timestamp,
653
713
  updatedAt: timestamp,
654
714
  })
655
- .where(eq(schema.dispatchApprovalRequests.id, requestId));
715
+ .where(
716
+ and(
717
+ eq(schema.dispatchApprovalRequests.id, requestId),
718
+ ctxScope(schema.dispatchApprovalRequests, ctx),
719
+ eq(schema.dispatchApprovalRequests.status, "pending"),
720
+ ),
721
+ )
722
+ .returning();
723
+
724
+ if (claimed.length === 0) {
725
+ return (await getApprovalRequest(requestId, ctx)) ?? request;
726
+ }
727
+
728
+ const updated = claimed[0];
656
729
  await recordAudit({
657
730
  action: "approval.rejected",
658
- targetType: request.targetType,
731
+ targetType: updated.targetType,
659
732
  targetId: requestId,
660
- summary: `Rejected ${request.summary}`,
661
- metadata: { request, reason: reason || null },
733
+ summary: `Rejected ${updated.summary}`,
734
+ metadata: { request: updated, reason: reason || null },
662
735
  });
663
- return getApprovalRequest(requestId);
736
+ return updated;
664
737
  }
665
738
 
666
739
  export async function createLinkToken(platform: string) {
@@ -1,15 +1,42 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ execute: vi.fn(),
5
+ getOrgSetting: vi.fn(),
6
+ getUserSetting: vi.fn(),
7
+ putOrgSetting: vi.fn(),
8
+ putUserSetting: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("@agent-native/core/db", () => ({
12
+ getDbExec: () => ({ execute: mocks.execute }),
13
+ }));
14
+
15
+ vi.mock("@agent-native/core/settings", () => ({
16
+ getOrgSetting: mocks.getOrgSetting,
17
+ getUserSetting: mocks.getUserSetting,
18
+ putOrgSetting: mocks.putOrgSetting,
19
+ putUserSetting: mocks.putUserSetting,
20
+ }));
21
+
22
+ import { runWithRequestContext } from "@agent-native/core/server";
2
23
 
3
24
  import {
4
25
  isAppAllowedByMcpAccess,
5
26
  normalizeMcpAppAccessSettings,
27
+ setDispatchMcpAppAccessSettings,
6
28
  } from "./mcp-access-store.js";
7
29
 
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ mocks.execute.mockResolvedValue({ rows: [] });
33
+ });
34
+
8
35
  describe("normalizeMcpAppAccessSettings", () => {
9
- it("defaults to all apps", () => {
36
+ it("defaults to Dispatch only", () => {
10
37
  expect(normalizeMcpAppAccessSettings(null)).toEqual({
11
- mode: "all-apps",
12
- selectedAppIds: [],
38
+ mode: "selected-apps",
39
+ selectedAppIds: ["dispatch"],
13
40
  updatedAt: undefined,
14
41
  updatedBy: undefined,
15
42
  });
@@ -32,6 +59,91 @@ describe("normalizeMcpAppAccessSettings", () => {
32
59
  });
33
60
  });
34
61
 
62
+ describe("setDispatchMcpAppAccessSettings", () => {
63
+ it.each(["owner", "admin"])(
64
+ "lets an organization %s change the app grants",
65
+ async (role) => {
66
+ mocks.execute.mockResolvedValue({ rows: [{ role }] });
67
+
68
+ const result = await runWithRequestContext(
69
+ { userEmail: "manager@example.test", orgId: "org-1" },
70
+ () =>
71
+ setDispatchMcpAppAccessSettings({
72
+ mode: "selected-apps",
73
+ selectedAppIds: ["dispatch", "analytics"],
74
+ }),
75
+ );
76
+
77
+ expect(mocks.execute).toHaveBeenCalledWith({
78
+ sql: expect.stringContaining("FROM org_members"),
79
+ args: ["org-1", "manager@example.test"],
80
+ });
81
+ expect(mocks.putOrgSetting).toHaveBeenCalledWith(
82
+ "org-1",
83
+ "dispatch-mcp-app-access",
84
+ expect.objectContaining({
85
+ mode: "selected-apps",
86
+ selectedAppIds: ["dispatch", "analytics"],
87
+ updatedBy: "manager@example.test",
88
+ }),
89
+ );
90
+ expect(result.mode).toBe("selected-apps");
91
+ },
92
+ );
93
+
94
+ it("rejects an organization member without changing grants", async () => {
95
+ mocks.execute.mockResolvedValue({ rows: [{ role: "member" }] });
96
+
97
+ await expect(
98
+ runWithRequestContext(
99
+ { userEmail: "member@example.test", orgId: "org-1" },
100
+ () =>
101
+ setDispatchMcpAppAccessSettings({
102
+ mode: "all-apps",
103
+ }),
104
+ ),
105
+ ).rejects.toMatchObject({
106
+ message: expect.stringMatching(/Only organization owners and admins/),
107
+ statusCode: 403,
108
+ });
109
+ expect(mocks.putOrgSetting).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it("fails closed when organization membership cannot be verified", async () => {
113
+ mocks.execute.mockRejectedValue(new Error("org lookup unavailable"));
114
+
115
+ await expect(
116
+ runWithRequestContext(
117
+ { userEmail: "owner@example.test", orgId: "org-1" },
118
+ () =>
119
+ setDispatchMcpAppAccessSettings({
120
+ mode: "all-apps",
121
+ }),
122
+ ),
123
+ ).rejects.toThrow(/Only organization owners and admins/);
124
+ expect(mocks.putOrgSetting).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it("lets an authenticated solo user manage only their own grants", async () => {
128
+ await runWithRequestContext({ userEmail: "solo@example.test" }, () =>
129
+ setDispatchMcpAppAccessSettings({
130
+ mode: "selected-apps",
131
+ selectedAppIds: [],
132
+ }),
133
+ );
134
+
135
+ expect(mocks.execute).not.toHaveBeenCalled();
136
+ expect(mocks.putUserSetting).toHaveBeenCalledWith(
137
+ "solo@example.test",
138
+ "dispatch-mcp-app-access",
139
+ expect.objectContaining({
140
+ mode: "selected-apps",
141
+ selectedAppIds: [],
142
+ }),
143
+ );
144
+ });
145
+ });
146
+
35
147
  describe("isAppAllowedByMcpAccess", () => {
36
148
  it("allows every app in all-apps mode", () => {
37
149
  expect(
@@ -1,3 +1,4 @@
1
+ import { getDbExec } from "@agent-native/core/db";
1
2
  import {
2
3
  getRequestOrgId,
3
4
  getRequestUserEmail,
@@ -20,6 +21,16 @@ export interface DispatchMcpAppAccessSettings {
20
21
  updatedBy?: string;
21
22
  }
22
23
 
24
+ class McpAppAccessError extends Error {
25
+ statusCode: number;
26
+
27
+ constructor(message: string, statusCode: number) {
28
+ super(message);
29
+ this.name = "McpAppAccessError";
30
+ this.statusCode = statusCode;
31
+ }
32
+ }
33
+
23
34
  interface AccessScope {
24
35
  kind: "org" | "user";
25
36
  id: string;
@@ -45,10 +56,11 @@ export function normalizeMcpAppAccessSettings(
45
56
  raw && typeof raw === "object" && !Array.isArray(raw)
46
57
  ? (raw as Record<string, unknown>)
47
58
  : {};
48
- const mode = record.mode === "selected-apps" ? "selected-apps" : "all-apps";
59
+ const mode = record.mode === "all-apps" ? "all-apps" : "selected-apps";
49
60
  return {
50
61
  mode,
51
- selectedAppIds: uniqueAppIds(record.selectedAppIds),
62
+ selectedAppIds:
63
+ raw == null ? ["dispatch"] : uniqueAppIds(record.selectedAppIds),
52
64
  updatedAt:
53
65
  typeof record.updatedAt === "string" ? record.updatedAt : undefined,
54
66
  updatedBy:
@@ -64,6 +76,28 @@ function currentAccessScope(): AccessScope {
64
76
  return { kind: "user", id: actor, actor };
65
77
  }
66
78
 
79
+ async function assertCanManageMcpAppAccess(scope: AccessScope): Promise<void> {
80
+ if (scope.kind === "user") return;
81
+
82
+ let role: unknown = null;
83
+ try {
84
+ const { rows } = await getDbExec().execute({
85
+ sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`,
86
+ args: [scope.id, scope.actor.toLowerCase()],
87
+ });
88
+ role = rows[0]?.role;
89
+ } catch {
90
+ // Fail closed when org membership cannot be verified.
91
+ }
92
+
93
+ if (role !== "owner" && role !== "admin") {
94
+ throw new McpAppAccessError(
95
+ "Only organization owners and admins can change Dispatch MCP app access.",
96
+ 403,
97
+ );
98
+ }
99
+ }
100
+
67
101
  export async function getDispatchMcpAppAccessSettings(): Promise<DispatchMcpAppAccessSettings> {
68
102
  const scope = currentAccessScope();
69
103
  const raw =
@@ -78,6 +112,7 @@ export async function setDispatchMcpAppAccessSettings(input: {
78
112
  selectedAppIds?: string[];
79
113
  }): Promise<DispatchMcpAppAccessSettings> {
80
114
  const scope = currentAccessScope();
115
+ await assertCanManageMcpAppAccess(scope);
81
116
  const next: DispatchMcpAppAccessSettings = {
82
117
  mode: input.mode,
83
118
  selectedAppIds: uniqueAppIds(input.selectedAppIds),
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
12
12
  managerStop: vi.fn(),
13
13
  managerCallTool: vi.fn(),
14
14
  managerConstructor: vi.fn(),
15
+ callAgent: vi.fn(),
15
16
  signA2AToken: vi.fn(),
16
17
  getOrgA2ASecret: vi.fn(),
17
18
  getOrgDomain: vi.fn(),
@@ -39,7 +40,7 @@ vi.mock("@agent-native/core/server", async (importOriginal) => {
39
40
  });
40
41
 
41
42
  vi.mock("@agent-native/core/a2a", () => ({
42
- callAgent: vi.fn(),
43
+ callAgent: mocks.callAgent,
43
44
  signA2AToken: mocks.signA2AToken,
44
45
  }));
45
46
 
@@ -74,6 +75,7 @@ import { runWithRequestContext } from "@agent-native/core/server";
74
75
 
75
76
  import {
76
77
  createGrantedDispatchMcpEmbedSession,
78
+ askGrantedDispatchMcpApp,
77
79
  listGrantedDispatchMcpApps,
78
80
  listGrantedDispatchMcpAppOrigins,
79
81
  openGrantedDispatchMcpApp,
@@ -90,8 +92,8 @@ const analyticsAgent = {
90
92
 
91
93
  beforeEach(() => {
92
94
  mocks.discoverAgents.mockResolvedValue([analyticsAgent]);
93
- mocks.getUserSetting.mockResolvedValue(null);
94
- mocks.getOrgSetting.mockResolvedValue(null);
95
+ mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
96
+ mocks.getOrgSetting.mockResolvedValue({ mode: "all-apps" });
95
97
  mocks.createEmbedSessionTicket.mockResolvedValue({
96
98
  ticket: "ticket-123",
97
99
  ticketHash: "hash-123",
@@ -104,6 +106,7 @@ beforeEach(() => {
104
106
  startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
105
107
  },
106
108
  });
109
+ mocks.callAgent.mockResolvedValue("Created the requested dashboard.");
107
110
  mocks.signA2AToken.mockResolvedValue("signed-token");
108
111
  mocks.getOrgA2ASecret.mockResolvedValue(null);
109
112
  mocks.getOrgDomain.mockResolvedValue(null);
@@ -115,7 +118,21 @@ afterEach(() => {
115
118
  });
116
119
 
117
120
  describe("Dispatch MCP gateway app discovery", () => {
121
+ it("defaults to exposing Dispatch only until an owner or admin grants more apps", async () => {
122
+ mocks.getUserSetting.mockResolvedValue(null);
123
+ const apps = await runWithRequestContext(
124
+ {
125
+ userEmail: "owner@example.test",
126
+ requestOrigin: "http://localhost:8092",
127
+ },
128
+ () => listGrantedDispatchMcpApps(),
129
+ );
130
+
131
+ expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
132
+ });
133
+
118
134
  it("includes Dispatch itself so agents can target extension routes", async () => {
135
+ mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
119
136
  const apps = await runWithRequestContext(
120
137
  {
121
138
  userEmail: "owner@example.test",
@@ -222,6 +239,7 @@ describe("Dispatch MCP gateway app discovery", () => {
222
239
  color: "#111827",
223
240
  },
224
241
  ]);
242
+ mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
225
243
 
226
244
  await expect(
227
245
  runWithRequestContext(
@@ -235,6 +253,64 @@ describe("Dispatch MCP gateway app discovery", () => {
235
253
  });
236
254
  });
237
255
 
256
+ describe("askGrantedDispatchMcpApp", () => {
257
+ it("routes the authenticated user and active org identity to the granted app", async () => {
258
+ mocks.getOrgSetting.mockResolvedValue({
259
+ mode: "selected-apps",
260
+ selectedAppIds: ["analytics"],
261
+ });
262
+ mocks.getOrgDomain.mockResolvedValue("builder.io");
263
+ mocks.getOrgA2ASecret.mockResolvedValue("org-specific-secret");
264
+
265
+ const result = await runWithRequestContext(
266
+ {
267
+ userEmail: "owner@example.test",
268
+ orgId: "org-1",
269
+ requestOrigin: "http://localhost:8092",
270
+ },
271
+ () =>
272
+ askGrantedDispatchMcpApp(
273
+ "analytics",
274
+ "Build a weekly active users dashboard.",
275
+ ),
276
+ );
277
+
278
+ expect(mocks.callAgent).toHaveBeenCalledWith(
279
+ "http://localhost:8086",
280
+ "Build a weekly active users dashboard.",
281
+ {
282
+ userEmail: "owner@example.test",
283
+ orgDomain: "builder.io",
284
+ orgSecret: "org-specific-secret",
285
+ timeoutMs: 5 * 60_000,
286
+ },
287
+ );
288
+ expect(result).toEqual({
289
+ app: "analytics",
290
+ routedVia: "a2a",
291
+ response: "Created the requested dashboard.",
292
+ });
293
+ });
294
+
295
+ it("rejects delegation to an app outside the grant", async () => {
296
+ mocks.getUserSetting.mockResolvedValue({
297
+ mode: "selected-apps",
298
+ selectedAppIds: ["dispatch"],
299
+ });
300
+
301
+ await expect(
302
+ runWithRequestContext(
303
+ {
304
+ userEmail: "owner@example.test",
305
+ requestOrigin: "http://localhost:8092",
306
+ },
307
+ () => askGrantedDispatchMcpApp("analytics", "Show signups."),
308
+ ),
309
+ ).rejects.toThrow(/not granted/);
310
+ expect(mocks.callAgent).not.toHaveBeenCalled();
311
+ });
312
+ });
313
+
238
314
  describe("openGrantedDispatchMcpApp", () => {
239
315
  it("opens Dispatch extension routes through the Dispatch app id", async () => {
240
316
  const result = await runWithRequestContext(