@agent-native/dispatch 0.8.4 → 0.8.6

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 (83) 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 +59 -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/routes/pages/agents.d.ts.map +1 -1
  37. package/dist/routes/pages/agents.js +74 -3
  38. package/dist/routes/pages/agents.js.map +1 -1
  39. package/dist/routes/pages/apps.d.ts.map +1 -1
  40. package/dist/routes/pages/apps.js +23 -8
  41. package/dist/routes/pages/apps.js.map +1 -1
  42. package/dist/routes/pages/overview.d.ts.map +1 -1
  43. package/dist/routes/pages/overview.js +1 -3
  44. package/dist/routes/pages/overview.js.map +1 -1
  45. package/dist/server/lib/mcp-access-store.d.ts +16 -0
  46. package/dist/server/lib/mcp-access-store.d.ts.map +1 -0
  47. package/dist/server/lib/mcp-access-store.js +64 -0
  48. package/dist/server/lib/mcp-access-store.js.map +1 -0
  49. package/dist/server/lib/mcp-gateway.d.ts +47 -0
  50. package/dist/server/lib/mcp-gateway.d.ts.map +1 -0
  51. package/dist/server/lib/mcp-gateway.js +237 -0
  52. package/dist/server/lib/mcp-gateway.js.map +1 -0
  53. package/dist/server/lib/vault-store.d.ts.map +1 -1
  54. package/dist/server/lib/vault-store.js +20 -10
  55. package/dist/server/lib/vault-store.js.map +1 -1
  56. package/dist/server/plugins/agent-chat.d.ts.map +1 -1
  57. package/dist/server/plugins/agent-chat.js +1 -0
  58. package/dist/server/plugins/agent-chat.js.map +1 -1
  59. package/dist/server/plugins/integrations.d.ts.map +1 -1
  60. package/dist/server/plugins/integrations.js +2 -1
  61. package/dist/server/plugins/integrations.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/actions/ask_app.ts +13 -0
  64. package/src/actions/create_embed_session.ts +29 -0
  65. package/src/actions/index.spec.ts +6 -0
  66. package/src/actions/index.ts +12 -0
  67. package/src/actions/list-mcp-app-access.ts +26 -0
  68. package/src/actions/list_apps.ts +27 -0
  69. package/src/actions/open_app.ts +61 -0
  70. package/src/actions/set-mcp-app-access.ts +59 -0
  71. package/src/actions/start-workspace-app-creation.ts +1 -1
  72. package/src/actions/view-screen.ts +8 -0
  73. package/src/components/create-app-popover.tsx +1 -0
  74. package/src/routes/pages/agents.tsx +187 -5
  75. package/src/routes/pages/apps.tsx +209 -67
  76. package/src/routes/pages/overview.tsx +16 -10
  77. package/src/server/lib/mcp-access-store.spec.ts +58 -0
  78. package/src/server/lib/mcp-access-store.ts +104 -0
  79. package/src/server/lib/mcp-gateway.ts +333 -0
  80. package/src/server/lib/vault-store.spec.ts +15 -0
  81. package/src/server/lib/vault-store.ts +30 -7
  82. package/src/server/plugins/agent-chat.ts +1 -0
  83. package/src/server/plugins/integrations.ts +2 -1
@@ -5,6 +5,12 @@ import { dispatchActions } from "./index.js";
5
5
  describe("dispatch action registry", () => {
6
6
  it("keeps workspace resources runtime-inherited instead of exposing sync actions", () => {
7
7
  expect(dispatchActions).toHaveProperty("list-workspace-resources-for-app");
8
+ expect(dispatchActions).toHaveProperty("list-mcp-app-access");
9
+ expect(dispatchActions).toHaveProperty("set-mcp-app-access");
10
+ expect(dispatchActions).toHaveProperty("list_apps");
11
+ expect(dispatchActions).toHaveProperty("ask_app");
12
+ expect(dispatchActions).toHaveProperty("open_app");
13
+ expect(dispatchActions).toHaveProperty("create_embed_session");
8
14
  expect(dispatchActions).toHaveProperty(
9
15
  "get-workspace-resource-effective-context",
10
16
  );
@@ -2,6 +2,7 @@ import type { ActionEntry } from "@agent-native/core/server";
2
2
  import approveDispatchChange from "./approve-dispatch-change.js";
3
3
  import approveVaultRequest from "./approve-vault-request.js";
4
4
  import archiveWorkspaceApp from "./archive-workspace-app.js";
5
+ import askApp from "./ask_app.js";
5
6
  import createLinkToken from "./create-link-token.js";
6
7
  import createPylonTicket from "./create-pylon-ticket.js";
7
8
  import createVaultGrant from "./create-vault-grant.js";
@@ -9,6 +10,7 @@ import createVaultSecret from "./create-vault-secret.js";
9
10
  import createWorkspaceResourceGrant from "./create-workspace-resource-grant.js";
10
11
  import createWorkspaceResource from "./create-workspace-resource.js";
11
12
  import createDreamReport from "./create-dream-report.js";
13
+ import createEmbedSession from "./create_embed_session.js";
12
14
  import deleteDestination from "./delete-destination.js";
13
15
  import deleteVaultSecret from "./delete-vault-secret.js";
14
16
  import deleteWorkspaceResource from "./delete-workspace-resource.js";
@@ -36,6 +38,8 @@ import listDreamCandidates from "./list-dream-candidates.js";
36
38
  import listDreams from "./list-dreams.js";
37
39
  import listIntegrationsCatalog from "./list-integrations-catalog.js";
38
40
  import listLinkedIdentities from "./list-linked-identities.js";
41
+ import listMcpAppAccess from "./list-mcp-app-access.js";
42
+ import listApps from "./list_apps.js";
39
43
  import listVaultAudit from "./list-vault-audit.js";
40
44
  import listVaultGrants from "./list-vault-grants.js";
41
45
  import listVaultRequests from "./list-vault-requests.js";
@@ -47,6 +51,7 @@ import listWorkspaceResourceGrants from "./list-workspace-resource-grants.js";
47
51
  import listWorkspaceResourcesForApp from "./list-workspace-resources-for-app.js";
48
52
  import listWorkspaceResources from "./list-workspace-resources.js";
49
53
  import navigate from "./navigate.js";
54
+ import openApp from "./open_app.js";
50
55
  import applyDreamProposal from "./apply-dream-proposal.js";
51
56
  import previewDreamProposal from "./preview-dream-proposal.js";
52
57
  import previewWorkspaceResourceChange from "./preview-workspace-resource-change.js";
@@ -64,6 +69,7 @@ import sendPlatformMessage from "./send-platform-message.js";
64
69
  import setAppCreationSettings from "./set-app-creation-settings.js";
65
70
  import setDispatchApprovalPolicy from "./set-dispatch-approval-policy.js";
66
71
  import setDreamSettings from "./set-dream-settings.js";
72
+ import setMcpAppAccess from "./set-mcp-app-access.js";
67
73
  import setVaultAccessSettings from "./set-vault-access-settings.js";
68
74
  import startWorkspaceAppCreation from "./start-workspace-app-creation.js";
69
75
  import syncVaultToApp from "./sync-vault-to-app.js";
@@ -84,6 +90,7 @@ export const dispatchActions: Record<string, ActionEntry> = {
84
90
  "approve-dispatch-change": approveDispatchChange,
85
91
  "approve-vault-request": approveVaultRequest,
86
92
  "archive-workspace-app": archiveWorkspaceApp,
93
+ ask_app: askApp,
87
94
  "create-link-token": createLinkToken,
88
95
  "create-pylon-ticket": createPylonTicket,
89
96
  "create-vault-grant": createVaultGrant,
@@ -91,6 +98,7 @@ export const dispatchActions: Record<string, ActionEntry> = {
91
98
  "create-workspace-resource-grant": createWorkspaceResourceGrant,
92
99
  "create-workspace-resource": createWorkspaceResource,
93
100
  "create-dream-report": createDreamReport,
101
+ create_embed_session: createEmbedSession,
94
102
  "delete-destination": deleteDestination,
95
103
  "delete-vault-secret": deleteVaultSecret,
96
104
  "delete-workspace-resource": deleteWorkspaceResource,
@@ -119,6 +127,8 @@ export const dispatchActions: Record<string, ActionEntry> = {
119
127
  "list-dreams": listDreams,
120
128
  "list-integrations-catalog": listIntegrationsCatalog,
121
129
  "list-linked-identities": listLinkedIdentities,
130
+ "list-mcp-app-access": listMcpAppAccess,
131
+ list_apps: listApps,
122
132
  "list-vault-audit": listVaultAudit,
123
133
  "list-vault-grants": listVaultGrants,
124
134
  "list-vault-requests": listVaultRequests,
@@ -130,6 +140,7 @@ export const dispatchActions: Record<string, ActionEntry> = {
130
140
  "list-workspace-resources-for-app": listWorkspaceResourcesForApp,
131
141
  "list-workspace-resources": listWorkspaceResources,
132
142
  navigate: navigate,
143
+ open_app: openApp,
133
144
  "apply-dream-proposal": applyDreamProposal,
134
145
  "preview-dream-proposal": previewDreamProposal,
135
146
  "preview-workspace-resource-change": previewWorkspaceResourceChange,
@@ -147,6 +158,7 @@ export const dispatchActions: Record<string, ActionEntry> = {
147
158
  "set-app-creation-settings": setAppCreationSettings,
148
159
  "set-dispatch-approval-policy": setDispatchApprovalPolicy,
149
160
  "set-dream-settings": setDreamSettings,
161
+ "set-mcp-app-access": setMcpAppAccess,
150
162
  "set-vault-access-settings": setVaultAccessSettings,
151
163
  "start-workspace-app-creation": startWorkspaceAppCreation,
152
164
  "sync-vault-to-app": syncVaultToApp,
@@ -0,0 +1,26 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { z } from "zod";
3
+ import { listDispatchMcpApps } from "../server/lib/mcp-gateway.js";
4
+
5
+ export default defineAction({
6
+ description:
7
+ "List the apps exposed through Dispatch's unified MCP gateway and the current app access policy.",
8
+ schema: z.object({}),
9
+ http: { method: "GET" },
10
+ readOnly: true,
11
+ run: async () => {
12
+ const { settings, apps } = await listDispatchMcpApps();
13
+ return {
14
+ mode: settings.mode,
15
+ selectedAppIds: settings.selectedAppIds,
16
+ updatedAt: settings.updatedAt,
17
+ updatedBy: settings.updatedBy,
18
+ apps,
19
+ grantedApps: apps.filter((app) => app.granted),
20
+ counts: {
21
+ apps: apps.length,
22
+ granted: apps.filter((app) => app.granted).length,
23
+ },
24
+ };
25
+ },
26
+ });
@@ -0,0 +1,27 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { z } from "zod";
3
+ import { listGrantedDispatchMcpApps } from "../server/lib/mcp-gateway.js";
4
+
5
+ export default defineAction({
6
+ description:
7
+ "List the apps this Dispatch MCP gateway can route to. The result is filtered by Dispatch's MCP app access policy.",
8
+ schema: z.object({}),
9
+ http: { method: "GET" },
10
+ readOnly: true,
11
+ parallelSafe: true,
12
+ run: async () => {
13
+ const apps = await listGrantedDispatchMcpApps();
14
+ return {
15
+ workspace: true,
16
+ gateway: "dispatch",
17
+ apps: apps.map((app) => ({
18
+ id: app.id,
19
+ name: app.name,
20
+ description: app.description,
21
+ url: app.url,
22
+ running: true,
23
+ source: "dispatch-mcp-grant",
24
+ })),
25
+ };
26
+ },
27
+ });
@@ -0,0 +1,61 @@
1
+ import { defineAction, embedApp } from "@agent-native/core";
2
+ import { z } from "zod";
3
+ import { openGrantedDispatchMcpApp } from "../server/lib/mcp-gateway.js";
4
+
5
+ const deepLinkParam = z.union([z.string(), z.number(), z.boolean()]);
6
+ const openAppSchema = z
7
+ .object({
8
+ app: z.string().describe("Granted app id, e.g. mail or calendar."),
9
+ view: z.string().optional().describe("Target view in the app, e.g. inbox."),
10
+ path: z
11
+ .string()
12
+ .optional()
13
+ .describe(
14
+ "Optional same-origin app route to open directly, e.g. /dashboards/q2.",
15
+ ),
16
+ params: z
17
+ .record(z.string(), deepLinkParam)
18
+ .optional()
19
+ .describe("Optional record-focus or filter params."),
20
+ embed: z
21
+ .boolean()
22
+ .optional()
23
+ .describe("Render the app inline in MCP Apps when supported."),
24
+ chrome: z
25
+ .enum(["full", "minimal"])
26
+ .optional()
27
+ .describe("Embed chrome preference for compatible app routes."),
28
+ })
29
+ .refine((input) => input.view?.trim() || input.path?.trim(), {
30
+ message: "open_app requires either view or path",
31
+ path: ["view"],
32
+ });
33
+
34
+ export default defineAction({
35
+ description:
36
+ "Build a deep link or embeddable app route for an app available through Dispatch MCP. No side effects; surface the returned Open link to the user.",
37
+ schema: openAppSchema,
38
+ http: { method: "GET" },
39
+ readOnly: true,
40
+ parallelSafe: true,
41
+ run: async (args) => openGrantedDispatchMcpApp(args),
42
+ link: ({ result }) => {
43
+ if (!result || typeof result !== "object") return null;
44
+ const r = result as { url?: string; app?: string; view?: string };
45
+ if (!r.url) return null;
46
+ return {
47
+ url: r.url,
48
+ label: `Open ${r.app ?? "app"}`,
49
+ view: r.view,
50
+ };
51
+ },
52
+ mcpApp: {
53
+ resource: embedApp({
54
+ title: "Open app",
55
+ description: "Render the requested granted app route inline.",
56
+ iframeTitle: "Dispatch MCP app",
57
+ openLabel: "Open app",
58
+ frameDomains: ["https:", "http://localhost:*", "http://127.0.0.1:*"],
59
+ }),
60
+ },
61
+ });
@@ -0,0 +1,59 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { z } from "zod";
3
+ import { discoverAgents } from "@agent-native/core/server/agent-discovery";
4
+ import { setDispatchMcpAppAccessSettings } from "../server/lib/mcp-access-store.js";
5
+ import { recordAudit } from "../server/lib/dispatch-store.js";
6
+ import listMcpAppAccess from "./list-mcp-app-access.js";
7
+
8
+ const modeSchema = z.enum(["all-apps", "selected-apps"]);
9
+
10
+ export default defineAction({
11
+ description:
12
+ "Set which apps are available through Dispatch's unified MCP gateway.",
13
+ schema: z.object({
14
+ mode: modeSchema.describe(
15
+ "Use all-apps to expose every discovered app, or selected-apps to allow only selectedAppIds.",
16
+ ),
17
+ selectedAppIds: z
18
+ .array(z.string())
19
+ .default([])
20
+ .describe("App IDs to expose when mode is selected-apps."),
21
+ }),
22
+ run: async (args) => {
23
+ const agents = await discoverAgents("dispatch");
24
+ const knownIds = new Set(agents.map((agent) => agent.id));
25
+ const selectedAppIds = Array.from(
26
+ new Set(
27
+ args.selectedAppIds
28
+ .map((id) => id.trim().toLowerCase())
29
+ .filter(Boolean),
30
+ ),
31
+ );
32
+ const unknown = selectedAppIds.filter((id) => !knownIds.has(id));
33
+ if (unknown.length > 0) {
34
+ throw new Error(
35
+ `Unknown app(s): ${unknown.join(", ")}. Use list-mcp-app-access to see available app IDs.`,
36
+ );
37
+ }
38
+ if (args.mode === "selected-apps" && selectedAppIds.length === 0) {
39
+ throw new Error("selected-apps mode requires at least one app ID.");
40
+ }
41
+
42
+ await setDispatchMcpAppAccessSettings({
43
+ mode: args.mode,
44
+ selectedAppIds,
45
+ });
46
+ await recordAudit({
47
+ action: "mcp-access.updated",
48
+ targetType: "dispatch-mcp-access",
49
+ targetId: "unified-mcp",
50
+ summary:
51
+ args.mode === "all-apps"
52
+ ? "Allowed Dispatch MCP access to all apps"
53
+ : `Allowed Dispatch MCP access to ${selectedAppIds.length} selected app(s)`,
54
+ metadata: { mode: args.mode, selectedAppIds },
55
+ }).catch(() => {});
56
+
57
+ return listMcpAppAccess.run({});
58
+ },
59
+ });
@@ -5,7 +5,7 @@ import { startWorkspaceAppCreation } from "../server/lib/app-creation-store.js";
5
5
 
6
6
  export default defineAction({
7
7
  description:
8
- "Start creating a new workspace app from Dispatch when the request truly needs its own app. In local dev this returns a code-agent prompt; in production it creates a Builder branch when a Builder project is configured. The result must be a separate workspace app under apps/<app-id>, not a new route or file in apps/starter. If the request needs Mail, Calendar, Analytics, Brain, or another first-party app, use the existing hosted/connected app via links or A2A; do not clone, wrap, or nest those templates inside the new app unless the user explicitly asks for a customized copy.",
8
+ 'Start creating a new workspace app from Dispatch when the request truly needs its own app. Callers should include a concise generated description by default; Dispatch generates one from the prompt when omitted. In local dev this returns a code-agent prompt; in production it creates a Builder branch when a Builder project is configured. The result must be a separate workspace app under apps/<app-id>, not a new route or file in apps/starter. If starter is used as the source template, the finished app must be branded as the requested app and must not leave visible "Starter", "Blank app", or "New app" UI behind. If the request needs Mail, Calendar, Analytics, Brain, or another first-party app, use the existing hosted/connected app via links or A2A; do not clone, wrap, or nest those templates inside the new app unless the user explicitly asks for a customized copy.',
9
9
  schema: z.object({
10
10
  prompt: z.string().min(1).describe("The user's app creation request"),
11
11
  appId: z
@@ -74,6 +74,14 @@ export default defineAction({
74
74
  if (navigation?.view === "destinations") {
75
75
  screen.recentDestinations = overview.recentDestinations;
76
76
  }
77
+ if (navigation?.view === "agents") {
78
+ const [connectedAgents, mcpAccess] = await Promise.all([
79
+ runLocalDispatchAction("list-connected-agents", {}),
80
+ runLocalDispatchAction("list-mcp-app-access", {}),
81
+ ]);
82
+ screen.connectedAgents = connectedAgents;
83
+ screen.mcpAppAccess = mcpAccess;
84
+ }
77
85
  if (
78
86
  navigation?.view === "overview" ||
79
87
  navigation?.view === "metrics" ||
@@ -111,6 +111,7 @@ function buildAppCreationPrompt(input: {
111
111
  `Dispatch workspace resources with scope=all are inherited workspace context. Do not copy or sync them into the new app; every workspace app reads them at runtime and may override with app shared or personal resources.`,
112
112
  ``,
113
113
  `Pick a starter template that fits the user's prompt — analytics, brain, calendar, content, design, dispatch, forms, mail, slides, clips, or starter when none of the others fit.`,
114
+ `If you use the starter template, treat it as scaffolding only: the finished app must use the requested app's real name, home screen, navigation, package metadata, and manifest, and it must not leave visible "Starter", "Blank app", or "New app" UI behind.`,
114
115
  `Use the workspace app layout: create it under apps/${input.appId}, mount it at /${input.appId}, keep it on the shared workspace database/hosting model, and avoid table-name collisions by namespacing any new domain tables to the app.`,
115
116
  `Important routing rule: from outside the app, link to /${input.appId}; inside apps/${input.appId}, React Router routes are app-local. Use <Link to="/review"> and navigate("/review"), not "/${input.appId}/review"; APP_BASE_PATH supplies the mounted prefix, and hardcoding it causes doubled URLs like /${input.appId}/${input.appId}/review.`,
116
117
  `Prefer useActionQuery/useActionMutation for actions. If you must raw-fetch framework endpoints, wrap them with agentNativePath("/_agent-native/actions/<name>") so mounted apps call the right URL.`,
@@ -1,11 +1,190 @@
1
- import { useActionQuery } from "@agent-native/core/client";
1
+ import { useMemo, useState } from "react";
2
+ import {
3
+ agentNativePath,
4
+ useActionMutation,
5
+ useActionQuery,
6
+ } from "@agent-native/core/client";
2
7
  import { AgentsPanel, type ConnectedAgent } from "@/components/agents-panel";
3
8
  import { DispatchShell } from "@/components/dispatch-shell";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Switch } from "@/components/ui/switch";
12
+ import { IconCheck, IconCopy, IconPlugConnected } from "@tabler/icons-react";
13
+ import { toast } from "sonner";
4
14
 
5
15
  export function meta() {
6
16
  return [{ title: "Agents — Dispatch" }];
7
17
  }
8
18
 
19
+ interface McpAccessApp {
20
+ id: string;
21
+ name: string;
22
+ description: string;
23
+ url: string;
24
+ color: string;
25
+ granted: boolean;
26
+ }
27
+
28
+ type McpAccessMode = "all-apps" | "selected-apps";
29
+
30
+ interface McpAccessState {
31
+ mode: McpAccessMode;
32
+ selectedAppIds: string[];
33
+ }
34
+
35
+ function dispatchMcpUrl(): string {
36
+ const path = agentNativePath("/_agent-native/mcp");
37
+ if (typeof window === "undefined") return path;
38
+ return new URL(path, window.location.origin).href;
39
+ }
40
+
41
+ function DispatchMcpAccessPanel() {
42
+ const { data, isLoading } = useActionQuery("list-mcp-app-access", {});
43
+ const [optimistic, setOptimistic] = useState<McpAccessState | null>(null);
44
+ const saveAccess = useActionMutation("set-mcp-app-access", {
45
+ onSuccess: () => {
46
+ setOptimistic(null);
47
+ toast.success("MCP app access updated");
48
+ },
49
+ onError: (error) => {
50
+ setOptimistic(null);
51
+ toast.error(error.message);
52
+ },
53
+ });
54
+
55
+ const apps = ((data as { apps?: McpAccessApp[] } | undefined)?.apps ??
56
+ []) as McpAccessApp[];
57
+ const access =
58
+ optimistic ??
59
+ ({
60
+ mode: ((data as { mode?: McpAccessMode } | undefined)?.mode ??
61
+ "all-apps") as McpAccessMode,
62
+ selectedAppIds:
63
+ (data as { selectedAppIds?: string[] } | undefined)?.selectedAppIds ??
64
+ [],
65
+ } satisfies McpAccessState);
66
+ const selected = useMemo(
67
+ () => new Set(access.selectedAppIds),
68
+ [access.selectedAppIds],
69
+ );
70
+ const grantedCount =
71
+ access.mode === "all-apps" ? apps.length : access.selectedAppIds.length;
72
+ const mcpUrl = dispatchMcpUrl();
73
+
74
+ function persist(next: McpAccessState) {
75
+ if (next.mode === "selected-apps" && next.selectedAppIds.length === 0) {
76
+ toast.error("Select at least one app, or expose all apps.");
77
+ return;
78
+ }
79
+ setOptimistic(next);
80
+ saveAccess.mutate(next);
81
+ }
82
+
83
+ function toggleApp(appId: string) {
84
+ const next = selected.has(appId)
85
+ ? access.selectedAppIds.filter((id) => id !== appId)
86
+ : [...access.selectedAppIds, appId];
87
+ persist({ mode: "selected-apps", selectedAppIds: next });
88
+ }
89
+
90
+ async function copyUrl() {
91
+ try {
92
+ await navigator.clipboard.writeText(mcpUrl);
93
+ toast.success("MCP URL copied");
94
+ } catch {
95
+ toast.error("Could not copy MCP URL");
96
+ }
97
+ }
98
+
99
+ return (
100
+ <section className="rounded-2xl border bg-card p-5">
101
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
102
+ <div className="min-w-0">
103
+ <div className="flex items-center gap-2 text-sm font-medium text-foreground">
104
+ <IconPlugConnected size={16} />
105
+ Unified MCP gateway
106
+ </div>
107
+ <div className="mt-1 max-w-2xl text-sm text-muted-foreground">
108
+ Connect external agents to Dispatch once, then route to granted
109
+ workspace apps through <code>list_apps</code>, <code>ask_app</code>,
110
+ and <code>open_app</code>.
111
+ </div>
112
+ </div>
113
+ <div className="flex items-center gap-3 rounded-xl border px-3 py-2">
114
+ <div>
115
+ <div className="text-xs font-medium text-foreground">
116
+ {access.mode === "all-apps" ? "All apps" : "Selected apps"}
117
+ </div>
118
+ <div className="text-xs text-muted-foreground">
119
+ {isLoading ? "Loading" : `${grantedCount} granted`}
120
+ </div>
121
+ </div>
122
+ <Switch
123
+ checked={access.mode === "all-apps"}
124
+ disabled={saveAccess.isPending || apps.length === 0}
125
+ onCheckedChange={(checked) =>
126
+ persist({
127
+ mode: checked ? "all-apps" : "selected-apps",
128
+ selectedAppIds: checked
129
+ ? access.selectedAppIds
130
+ : apps.map((app) => app.id),
131
+ })
132
+ }
133
+ aria-label="Expose all apps through Dispatch MCP"
134
+ />
135
+ </div>
136
+ </div>
137
+
138
+ <div className="mt-4 flex flex-col gap-2 sm:flex-row">
139
+ <Input readOnly value={mcpUrl} className="font-mono text-xs" />
140
+ <Button type="button" variant="outline" onClick={copyUrl}>
141
+ <IconCopy size={15} />
142
+ Copy URL
143
+ </Button>
144
+ </div>
145
+
146
+ {access.mode === "selected-apps" ? (
147
+ <div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
148
+ {apps.map((app) => {
149
+ const isSelected = selected.has(app.id);
150
+ return (
151
+ <button
152
+ key={app.id}
153
+ type="button"
154
+ disabled={
155
+ saveAccess.isPending &&
156
+ optimistic?.selectedAppIds.includes(app.id) !== isSelected
157
+ }
158
+ onClick={() => toggleApp(app.id)}
159
+ className="flex min-h-[76px] items-start gap-3 rounded-xl border bg-muted/20 px-3 py-3 text-left transition hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-60"
160
+ aria-pressed={isSelected}
161
+ >
162
+ <span
163
+ className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-xs font-bold text-white"
164
+ style={{ backgroundColor: app.color }}
165
+ >
166
+ {app.name.charAt(0).toUpperCase()}
167
+ </span>
168
+ <span className="min-w-0 flex-1">
169
+ <span className="flex items-center gap-2 text-sm font-medium text-foreground">
170
+ {app.name}
171
+ {isSelected ? (
172
+ <IconCheck size={14} className="text-emerald-500" />
173
+ ) : null}
174
+ </span>
175
+ <span className="mt-1 line-clamp-2 block text-xs text-muted-foreground">
176
+ {app.description || app.url}
177
+ </span>
178
+ </span>
179
+ </button>
180
+ );
181
+ })}
182
+ </div>
183
+ ) : null}
184
+ </section>
185
+ );
186
+ }
187
+
9
188
  export default function AgentsRoute() {
10
189
  const { data, refetch } = useActionQuery("list-connected-agents", {});
11
190
 
@@ -14,10 +193,13 @@ export default function AgentsRoute() {
14
193
  title="Agents"
15
194
  description="Dispatch can delegate to the built-in app suite over A2A by default. Add extra agents here only if you want to route work to apps outside that built-in set."
16
195
  >
17
- <AgentsPanel
18
- agents={(data || []) as ConnectedAgent[]}
19
- onRefresh={refetch}
20
- />
196
+ <div className="space-y-4">
197
+ <DispatchMcpAccessPanel />
198
+ <AgentsPanel
199
+ agents={(data || []) as ConnectedAgent[]}
200
+ onRefresh={refetch}
201
+ />
202
+ </div>
21
203
  </DispatchShell>
22
204
  );
23
205
  }