@agent-native/dispatch 0.14.3 → 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 (127) hide show
  1. package/README.md +114 -128
  2. package/dist/actions/list_apps.d.ts +1 -0
  3. package/dist/actions/list_apps.js +16 -8
  4. package/dist/actions/list_apps.js.map +1 -1
  5. package/dist/actions/set-mcp-app-access.js +1 -4
  6. package/dist/actions/set-mcp-app-access.js.map +1 -1
  7. package/dist/components/action-query-error.d.ts +8 -0
  8. package/dist/components/action-query-error.d.ts.map +1 -0
  9. package/dist/components/action-query-error.js +10 -0
  10. package/dist/components/action-query-error.js.map +1 -0
  11. package/dist/components/app-keys-popover.d.ts +4 -0
  12. package/dist/components/app-keys-popover.d.ts.map +1 -1
  13. package/dist/components/app-keys-popover.js +26 -14
  14. package/dist/components/app-keys-popover.js.map +1 -1
  15. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  16. package/dist/components/dispatch-control-plane.js +31 -27
  17. package/dist/components/dispatch-control-plane.js.map +1 -1
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.d.ts.map +1 -1
  20. package/dist/components/index.js +1 -0
  21. package/dist/components/index.js.map +1 -1
  22. package/dist/components/layout/Layout.d.ts +1 -0
  23. package/dist/components/layout/Layout.d.ts.map +1 -1
  24. package/dist/components/layout/Layout.js +19 -21
  25. package/dist/components/layout/Layout.js.map +1 -1
  26. package/dist/components/ui/sheet.js +1 -1
  27. package/dist/components/ui/sheet.js.map +1 -1
  28. package/dist/components/ui/toast.js +1 -1
  29. package/dist/components/ui/toast.js.map +1 -1
  30. package/dist/components/workspace-app-card.d.ts.map +1 -1
  31. package/dist/components/workspace-app-card.js +8 -5
  32. package/dist/components/workspace-app-card.js.map +1 -1
  33. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  34. package/dist/components/workspace-resource-effective-stack.js +6 -1
  35. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  36. package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
  37. package/dist/components/workspace-resource-impact-preview.js +6 -1
  38. package/dist/components/workspace-resource-impact-preview.js.map +1 -1
  39. package/dist/routes/pages/$appId.d.ts.map +1 -1
  40. package/dist/routes/pages/$appId.js +6 -1
  41. package/dist/routes/pages/$appId.js.map +1 -1
  42. package/dist/routes/pages/agents.d.ts.map +1 -1
  43. package/dist/routes/pages/agents.js +5 -8
  44. package/dist/routes/pages/agents.js.map +1 -1
  45. package/dist/routes/pages/approval.d.ts.map +1 -1
  46. package/dist/routes/pages/approval.js +6 -1
  47. package/dist/routes/pages/approval.js.map +1 -1
  48. package/dist/routes/pages/approvals.d.ts.map +1 -1
  49. package/dist/routes/pages/approvals.js +8 -5
  50. package/dist/routes/pages/approvals.js.map +1 -1
  51. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  52. package/dist/routes/pages/apps.$appId.js +4 -2
  53. package/dist/routes/pages/apps.$appId.js.map +1 -1
  54. package/dist/routes/pages/apps.d.ts.map +1 -1
  55. package/dist/routes/pages/apps.js +9 -3
  56. package/dist/routes/pages/apps.js.map +1 -1
  57. package/dist/routes/pages/audit.d.ts.map +1 -1
  58. package/dist/routes/pages/audit.js +4 -2
  59. package/dist/routes/pages/audit.js.map +1 -1
  60. package/dist/routes/pages/chat.d.ts.map +1 -1
  61. package/dist/routes/pages/chat.js +16 -2
  62. package/dist/routes/pages/chat.js.map +1 -1
  63. package/dist/routes/pages/destinations.d.ts.map +1 -1
  64. package/dist/routes/pages/destinations.js +5 -3
  65. package/dist/routes/pages/destinations.js.map +1 -1
  66. package/dist/routes/pages/identities.d.ts.map +1 -1
  67. package/dist/routes/pages/identities.js +5 -3
  68. package/dist/routes/pages/identities.js.map +1 -1
  69. package/dist/routes/pages/integrations.d.ts.map +1 -1
  70. package/dist/routes/pages/integrations.js +9 -3
  71. package/dist/routes/pages/integrations.js.map +1 -1
  72. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  73. package/dist/routes/pages/thread-debug.js +7 -6
  74. package/dist/routes/pages/thread-debug.js.map +1 -1
  75. package/dist/routes/pages/vault.d.ts.map +1 -1
  76. package/dist/routes/pages/vault.js +23 -7
  77. package/dist/routes/pages/vault.js.map +1 -1
  78. package/dist/routes/pages/workspace.d.ts.map +1 -1
  79. package/dist/routes/pages/workspace.js +9 -3
  80. package/dist/routes/pages/workspace.js.map +1 -1
  81. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  82. package/dist/server/lib/dispatch-store.js +2 -2
  83. package/dist/server/lib/dispatch-store.js.map +1 -1
  84. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  85. package/dist/server/lib/mcp-access-store.js +30 -2
  86. package/dist/server/lib/mcp-access-store.js.map +1 -1
  87. package/dist/server/plugins/agent-chat.d.ts +1 -1
  88. package/package.json +2 -2
  89. package/src/actions/list_apps.spec.ts +52 -0
  90. package/src/actions/list_apps.ts +16 -8
  91. package/src/actions/set-mcp-app-access.spec.ts +73 -0
  92. package/src/actions/set-mcp-app-access.ts +1 -5
  93. package/src/components/action-query-error.spec.tsx +56 -0
  94. package/src/components/action-query-error.tsx +32 -0
  95. package/src/components/app-keys-popover.spec.tsx +85 -0
  96. package/src/components/app-keys-popover.tsx +76 -50
  97. package/src/components/dispatch-control-plane.spec.tsx +106 -0
  98. package/src/components/dispatch-control-plane.tsx +71 -115
  99. package/src/components/index.ts +1 -0
  100. package/src/components/layout/Layout.spec.tsx +42 -2
  101. package/src/components/layout/Layout.tsx +22 -27
  102. package/src/components/ui/sheet.tsx +1 -1
  103. package/src/components/ui/toast.tsx +1 -1
  104. package/src/components/workspace-app-card.spec.tsx +80 -0
  105. package/src/components/workspace-app-card.tsx +36 -25
  106. package/src/components/workspace-resource-effective-stack.tsx +13 -1
  107. package/src/components/workspace-resource-impact-preview.tsx +12 -1
  108. package/src/routes/pages/$appId.tsx +17 -1
  109. package/src/routes/pages/agents.tsx +24 -11
  110. package/src/routes/pages/approval.tsx +16 -4
  111. package/src/routes/pages/approvals.tsx +108 -89
  112. package/src/routes/pages/apps.$appId.tsx +9 -2
  113. package/src/routes/pages/apps.tsx +20 -7
  114. package/src/routes/pages/audit.tsx +29 -20
  115. package/src/routes/pages/chat.spec.tsx +94 -0
  116. package/src/routes/pages/chat.tsx +30 -10
  117. package/src/routes/pages/destinations.tsx +75 -62
  118. package/src/routes/pages/identities.tsx +74 -63
  119. package/src/routes/pages/integrations.tsx +17 -10
  120. package/src/routes/pages/thread-debug.tsx +18 -12
  121. package/src/routes/pages/vault.tsx +59 -26
  122. package/src/routes/pages/workspace.tsx +69 -55
  123. package/src/server/lib/approval-fencing.spec.ts +113 -2
  124. package/src/server/lib/dispatch-store.ts +2 -0
  125. package/src/server/lib/mcp-access-store.spec.ts +116 -4
  126. package/src/server/lib/mcp-access-store.ts +37 -2
  127. package/src/server/lib/mcp-gateway.spec.ts +79 -3
@@ -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(